1use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::{Arc, Mutex};
27
28use futures::future;
29use myco_model::{
30 self as generative_model, Content, GenerateError, GenerationFailure, GenerativeModel, Message,
31 Recovery, RetryPolicy, TokenUsage, ToolResult, ToolUse, TurnEndReason,
32};
33use uuid::Uuid;
34
35mod context_size;
36mod generation;
37mod state;
38pub use state::{
39 AgentState, Effect, OperationId, PendingOperation, StateError, recover_checkpoint,
40 validate_checkpoint, validate_context,
41};
42
43pub use tokio_util::sync::CancellationToken as CancelToken;
44pub type Async<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
45pub const DEFAULT_MAX_TRUNCATED_RESUMES: u32 = 3;
46
47pub trait ToolExecutor: Send + Sync {
49 fn tool_specs(&self) -> Vec<generative_model::ToolSpec>;
52 fn dispatch(
57 self: Arc<Self>,
58 tool: ToolUse,
59 cancel: CancelToken,
60 background: CancelToken,
61 ) -> Async<ToolResult>;
62}
63
64#[derive(Debug, Clone)]
71pub struct TraceContext {
72 pub agent_id: Uuid,
74 pub depth: usize,
76 pub session_id: Option<String>,
77 pub thread_id: Option<String>,
78}
79
80impl Default for TraceContext {
81 fn default() -> Self {
82 Self {
83 agent_id: Uuid::nil(),
84 depth: 0,
85 session_id: None,
86 thread_id: None,
87 }
88 }
89}
90
91impl TraceContext {
92 pub fn root() -> Self {
93 Self {
94 agent_id: Uuid::new_v4(),
95 depth: 0,
96 session_id: None,
97 thread_id: None,
98 }
99 }
100}
101
102#[derive(Debug, Clone)]
106pub enum AgentEvent {
107 GenerationStarted {
109 context: TraceContext,
110 },
111 GenerationFinished {
113 context: TraceContext,
114 },
115 GenerationCommitted {
120 context: TraceContext,
121 },
122 Failure {
123 failure: GenerationFailure,
124 attempt: u32,
125 max_attempts: u32,
126 retry_in: Option<std::time::Duration>,
127 recovery_remaining: Option<std::time::Duration>,
129 context: TraceContext,
130 },
131 TextDelta {
133 text: String,
134 context: TraceContext,
135 },
136 ThinkingDelta {
138 text: String,
139 context: TraceContext,
140 },
141 ToolStarted {
142 call_id: Uuid,
143 tool_use: ToolUse,
144 background: CancelToken,
145 context: TraceContext,
146 },
147 ToolFinished {
148 call_id: Uuid,
149 tool_use: ToolUse,
150 result: ToolResult,
151 context: TraceContext,
152 },
153 TurnFinished {
154 context: TraceContext,
155 },
156}
157
158pub trait EventSink: Send + Sync {
160 fn emit(&self, event: AgentEvent);
165}
166
167#[derive(Debug, Default)]
169pub struct NullEventSink;
170
171impl EventSink for NullEventSink {
172 fn emit(&self, _event: AgentEvent) {}
173}
174
175pub type Checkpoint = Box<dyn Fn(&AgentState) -> Result<(), String> + Send + Sync>;
183
184pub type BeforeGenerationNotice =
190 Box<dyn Fn(&TraceContext, &[Message]) -> Async<Option<String>> + Send + Sync>;
191
192const CANCEL_TOOL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
196
197pub const CONTINUE_PROMPT: &str = "Continue from exactly where you stopped. Do not repeat anything you have already written, \
203 and do not acknowledge this message.";
204
205pub const LOST_THINKING_PROMPT: &str = "Your previous response reached the output limit while still thinking, \
212 before any visible text or tool call, and that thinking is not available to you. Keep thinking brief: \
213 write your conclusions so far as visible text or make the next tool call early in this response. \
214 Do not acknowledge this message.";
215
216pub fn lost_thinking(message: &Message) -> bool {
219 matches!(message, Message::AssistantMessage {
220 content,
221 tool_uses,
222 turn_end_reason: Some(TurnEndReason::MaxTokens),
223 } if tool_uses.is_empty() && generative_model::answer_content(content).is_empty())
224}
225
226#[derive(Debug, Clone)]
228pub struct RunOutcome {
229 pub answer: Vec<Content>,
230 pub reason: TurnEndReason,
231 pub usage: Option<TokenUsage>,
232}
233
234pub struct Agent {
240 retry_policy: RetryPolicy,
241 model: Arc<dyn GenerativeModel>,
242 tools: Arc<dyn ToolExecutor>,
243 sink: Arc<dyn EventSink>,
244 context: TraceContext,
245 state: AgentState,
246 context_window_tokens: u64,
248 checkpoint: Option<Checkpoint>,
249 before_generation_notice: Option<BeforeGenerationNotice>,
250 prepared_generation: Option<OperationId>,
251 notice_pending_checkpoint: Option<OperationId>,
252 checkpoint_failed: AtomicBool,
253 pending_generation_commit: Mutex<Option<TraceContext>>,
255}
256
257impl Agent {
258 pub fn new(
259 model: Arc<dyn GenerativeModel>,
260 tools: Arc<dyn ToolExecutor>,
261 sink: Arc<dyn EventSink>,
262 ) -> Self {
263 Self::with_context(model, tools, sink, TraceContext::root())
264 }
265
266 pub fn with_context(
267 model: Arc<dyn GenerativeModel>,
268 tools: Arc<dyn ToolExecutor>,
269 sink: Arc<dyn EventSink>,
270 context: TraceContext,
271 ) -> Self {
272 Self {
273 retry_policy: RetryPolicy::default(),
274 model,
275 tools,
276 sink,
277 context,
278 state: AgentState::default(),
279 context_window_tokens: 200_000,
280 checkpoint: None,
281 before_generation_notice: None,
282 prepared_generation: None,
283 notice_pending_checkpoint: None,
284 checkpoint_failed: AtomicBool::new(false),
285 pending_generation_commit: Mutex::new(None),
286 }
287 }
288
289 pub fn set_tools(&mut self, tools: Arc<dyn ToolExecutor>) {
292 self.tools = tools;
293 }
294
295 pub fn set_context(&mut self, context: TraceContext) {
296 self.context = context;
297 }
298
299 pub fn set_checkpoint(&mut self, checkpoint: Option<Checkpoint>) {
301 if checkpoint.is_none() {
302 self.pending_generation_commit.get_mut().unwrap().take();
303 }
304 self.checkpoint = checkpoint;
305 }
306
307 pub fn set_before_generation_notice(&mut self, notice: Option<BeforeGenerationNotice>) {
308 self.before_generation_notice = notice;
309 }
310
311 fn emit_checkpoint(&self) -> Result<(), AgentInteractionError> {
312 let result = self
313 .checkpoint
314 .as_ref()
315 .map_or(Ok(()), |checkpoint| checkpoint(&self.state));
316 self.checkpoint_failed
317 .store(result.is_err(), Ordering::Relaxed);
318 result.map_err(AgentInteractionError::Checkpoint)?;
319 if self.checkpoint.is_some() {
320 let pending = self.pending_generation_commit.lock().unwrap().take();
321 if let Some(context) = pending {
322 self.sink.emit(AgentEvent::GenerationCommitted { context });
323 }
324 }
325 Ok(())
326 }
327
328 pub fn checkpoint_failed(&self) -> bool {
329 self.checkpoint_failed.load(Ordering::Relaxed)
330 }
331
332 pub fn checkpoint(&self) -> Result<(), AgentInteractionError> {
334 self.emit_checkpoint()
335 }
336
337 pub fn history(&self) -> &[Message] {
338 self.state.history()
339 }
340
341 pub fn replace_context(
343 &mut self,
344 history: Vec<Message>,
345 usage: Option<TokenUsage>,
346 ) -> Result<(), StateError> {
347 self.state.replace_context(history, usage)?;
348 self.pending_generation_commit.get_mut().unwrap().take();
349 Ok(())
350 }
351
352 pub fn state(&self) -> &AgentState {
353 &self.state
354 }
355
356 pub fn set_retry_policy(&mut self, retry_policy: RetryPolicy) {
357 self.retry_policy = retry_policy;
358 }
359
360 pub fn set_model(&mut self, model: Arc<dyn GenerativeModel>) {
362 self.model = model;
363 }
364
365 pub fn append_input(&mut self, message: Message) -> Result<(), AgentInteractionError> {
367 self.state.append_input(message)?;
368 self.emit_checkpoint()?;
369 Ok(())
370 }
371
372 pub fn append_system(&mut self, parts: Vec<Content>) -> Result<(), AgentInteractionError> {
373 self.state.append_system(parts)?;
374 self.emit_checkpoint()
375 }
376
377 pub fn truncate_history(
378 &mut self,
379 index: usize,
380 ) -> Result<Vec<Message>, AgentInteractionError> {
381 let dropped = self.state.truncate_history(index)?;
382 self.pending_generation_commit.get_mut().unwrap().take();
383 self.emit_checkpoint()?;
384 Ok(dropped)
385 }
386
387 pub fn set_context_window_tokens(&mut self, tokens: u64) {
389 self.context_window_tokens = tokens.max(1);
390 }
391
392 pub fn context_window_tokens(&self) -> u64 {
393 self.context_window_tokens
394 }
395
396 pub fn set_max_truncated_resumes(&mut self, resumes: u32) {
399 self.state.set_max_truncated_resumes(resumes);
400 }
401
402 pub fn last_usage(&self) -> Option<TokenUsage> {
404 self.state.last_usage()
405 }
406
407 pub fn context_tokens_estimate(&self) -> u64 {
410 self.state.context_tokens_estimate()
411 }
412
413 pub fn restore_context_size_hint(&mut self, tokens: Option<u64>) {
415 self.state.restore_context_size_hint(tokens);
416 }
417
418 pub fn context(&self) -> &TraceContext {
419 &self.context
420 }
421
422 pub async fn run(
430 &mut self,
431 cancel: CancelToken,
432 ) -> Result<Vec<Content>, AgentInteractionError> {
433 self.run_with_outcome(cancel)
434 .await
435 .map(|outcome| outcome.answer)
436 }
437
438 pub async fn run_with_outcome(
440 &mut self,
441 cancel: CancelToken,
442 ) -> Result<RunOutcome, AgentInteractionError> {
443 if let Err(error) = self.start_run() {
444 self.finish_output();
445 return Err(error);
446 }
447 self.continue_run(cancel).await
448 }
449
450 pub fn start_run(&mut self) -> Result<(), AgentInteractionError> {
451 self.state.start()?;
452 Ok(())
453 }
454
455 pub async fn continue_run(
458 &mut self,
459 cancel: CancelToken,
460 ) -> Result<RunOutcome, AgentInteractionError> {
461 loop {
462 if let Some(outcome) = self.step(cancel.clone()).await? {
463 return Ok(outcome);
464 }
465 }
466 }
467
468 pub fn replace_at_boundary(
469 &mut self,
470 history: Vec<Message>,
471 usage: Option<TokenUsage>,
472 ) -> Result<(), StateError> {
473 self.state.replace_at_boundary(history, usage)?;
474 self.pending_generation_commit.get_mut().unwrap().take();
475 Ok(())
476 }
477
478 pub fn recover_interrupted(&mut self) -> Result<(), AgentInteractionError> {
479 self.state.recover_interrupted()?;
480 self.emit_checkpoint()
481 }
482
483 pub fn cancel_at_boundary(&mut self) -> Result<(), AgentInteractionError> {
484 self.state.cancel_at_boundary()?;
485 self.finish_output();
486 self.emit_checkpoint()
487 }
488
489 pub async fn step(
492 &mut self,
493 cancel: CancelToken,
494 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
495 self.step_with_policy(cancel, self.retry_policy).await
496 }
497
498 pub async fn step_once(
502 &mut self,
503 cancel: CancelToken,
504 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
505 let policy = RetryPolicy {
506 max_attempts: 1,
507 ..self.retry_policy
508 };
509 self.step_with_policy(cancel, policy).await
510 }
511
512 async fn step_with_policy(
513 &mut self,
514 cancel: CancelToken,
515 policy: RetryPolicy,
516 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
517 let result = self.step_effect(cancel, policy).await;
518 let result = match self.emit_checkpoint() {
519 Ok(()) => result,
520 Err(error) => Err(error),
521 };
522 if !matches!(result, Ok(None)) {
523 self.finish_output();
524 }
525 result
526 }
527
528 fn finish_output(&self) {
529 self.sink.emit(AgentEvent::TurnFinished {
530 context: self.context.clone(),
531 });
532 }
533
534 async fn step_effect(
535 &mut self,
536 cancel: CancelToken,
537 policy: RetryPolicy,
538 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
539 self.emit_checkpoint()?;
540 let effect = self
541 .state
542 .effect()
543 .ok_or(StateError::UnexpectedCompletion)?;
544 let next = match effect {
545 Effect::Generate { operation } => {
546 let output = match async {
547 self.prepare_generation(cancel.clone()).await?;
548 self.state.begin_effect(operation)?;
549 generation::generate(self, cancel, policy).await
550 }
551 .await
552 {
553 Ok(output) => output,
554 Err(error) => {
555 self.state.generation_failed(operation)?;
556 return Err(error);
557 }
558 };
559 let next = self.state.generated(operation, output)?;
560 if self.checkpoint.is_some() {
561 *self.pending_generation_commit.get_mut().unwrap() = Some(self.context.clone());
562 }
563 next
564 }
565 Effect::ExecuteTools { operation, calls } => {
566 self.state.begin_effect(operation)?;
567 let results = future::join_all(
568 calls
569 .into_iter()
570 .map(|call| self.dispatch_tool_use(call, cancel.clone())),
571 )
572 .await;
573 self.state
574 .tools_completed(operation, results, cancel.is_cancelled())?
575 }
576 terminal => terminal,
577 };
578 match next {
579 Effect::Finished { answer, reason } => Ok(Some(RunOutcome {
580 answer,
581 reason,
582 usage: self.state.run_usage(),
583 })),
584 Effect::Cancelled => Err(AgentInteractionError::Cancelled),
585 _ => Ok(None),
586 }
587 }
588
589 pub async fn prepare_generation(
592 &mut self,
593 cancel: CancelToken,
594 ) -> Result<(), AgentInteractionError> {
595 let Some(PendingOperation::Generation { operation }) = self.state.pending_operation()
596 else {
597 return Ok(());
598 };
599 if !self.state.can_replace_at_boundary() {
600 return Err(StateError::Busy.into());
601 }
602 if self.prepared_generation == Some(operation) {
603 return Ok(());
604 }
605 self.emit_checkpoint()?;
606 if self.notice_pending_checkpoint != Some(operation) {
607 self.append_pending_notice(operation, &cancel).await?;
608 }
609 self.emit_checkpoint()?;
610 self.notice_pending_checkpoint = None;
611 self.prepared_generation = Some(operation);
612 Ok(())
613 }
614
615 async fn append_pending_notice(
616 &mut self,
617 operation: OperationId,
618 cancel: &CancelToken,
619 ) -> Result<(), AgentInteractionError> {
620 let Some(poll_notice) = &self.before_generation_notice else {
621 return Ok(());
622 };
623 let note = tokio::select! {
624 biased;
625 _ = cancel.cancelled() => return Err(AgentInteractionError::Cancelled),
626 note = poll_notice(&self.context, self.state.history()) => note,
627 };
628 if let Some(text) = note {
629 self.state.append_generation_notice(operation, text)?;
630 self.notice_pending_checkpoint = Some(operation);
631 }
632 Ok(())
633 }
634
635 async fn dispatch_tool_use(&self, tool_use: ToolUse, cancel: CancelToken) -> ToolResult {
636 if cancel.is_cancelled() {
637 return ToolResult::err("cancelled before dispatch");
638 }
639 let call_id = Uuid::new_v4();
640 let background = CancelToken::new();
641 self.sink.emit(AgentEvent::ToolStarted {
642 call_id,
643 tool_use: tool_use.clone(),
644 background: background.clone(),
645 context: self.context.clone(),
646 });
647
648 let work = self
649 .tools
650 .clone()
651 .dispatch(tool_use.clone(), cancel.clone(), background);
652
653 let mut work = std::pin::pin!(work);
664 let result = tokio::select! {
665 biased;
666 _ = cancel.cancelled() => {
667 match tokio::time::timeout(CANCEL_TOOL_GRACE, &mut work).await {
668 Ok(result) => result.with_status("cancel requested; partial result recorded"),
669 Err(_) => ToolResult::err("cancel requested; tool did not acknowledge before the deadline; effects are unknown").with_status("cancel requested; effects unknown"),
670 }
671 }
672 result = &mut work => result,
673 };
674 self.sink.emit(AgentEvent::ToolFinished {
675 call_id,
676 tool_use,
677 result: result.clone(),
678 context: self.context.clone(),
679 });
680 result
681 }
682}
683
684#[derive(thiserror::Error, Debug)]
685pub enum AgentInteractionError {
686 #[error("Error during generation: {0}")]
687 GenerateError(#[from] generative_model::GenerateError),
688 #[error("cancelled")]
691 Cancelled,
692 #[error("{0}")]
693 State(StateError),
694 #[error("could not persist agent state: {0}")]
695 Checkpoint(String),
696 #[error("compaction failed: {0}")]
697 Compaction(String),
698}
699
700impl From<StateError> for AgentInteractionError {
701 fn from(error: StateError) -> Self {
702 match error {
703 StateError::InvalidResponse(message) => {
704 Self::GenerateError(GenerateError::MalformedResponseError(message))
705 }
706 error => Self::State(error),
707 }
708 }
709}
710
711impl AgentInteractionError {
712 pub fn recovery(&self) -> Recovery {
716 match self {
717 AgentInteractionError::GenerateError(e) => e.recovery(),
718 AgentInteractionError::Cancelled => Recovery::Retry,
720 AgentInteractionError::State(_)
721 | AgentInteractionError::Checkpoint(_)
722 | AgentInteractionError::Compaction(_) => Recovery::Stop,
723 }
724 }
725}
726
727#[cfg(test)]
728mod test_support;
729
730#[cfg(test)]
731mod recovery_tests;
732
733#[cfg(test)]
734mod tests {
735 use super::*;
736 use crate::test_support::{
737 ScriptedModel, assistant, assistant_tool, result_text, tool_results, user,
738 };
739 use crate::test_support::{TestTools, interact};
740 use futures::stream;
741 use myco_model::{
742 ContentDelta, GenerateError, GenerateOutput, GenerationEvent, MessagePart, ToolSpec,
743 TurnEndReason,
744 };
745 use serde_json::json;
746 use std::sync::Mutex;
747 use std::time::{Duration, Instant};
748
749 #[derive(Default)]
750 struct EventLog(Mutex<Vec<AgentEvent>>);
751
752 #[tokio::test]
753 async fn preparing_a_generation_retries_its_notice_checkpoint_without_polling_twice() {
754 let model = ScriptedModel::new(vec![GenerateOutput {
755 content: vec![],
756 tool_uses: vec![],
757 usage: None,
758 turn_end_reason: TurnEndReason::EndTurn,
759 }]);
760 let mut agent = Agent::new(
761 model.clone(),
762 TestTools::new(vec![]),
763 Arc::new(NullEventSink),
764 );
765 agent.append_input(user("task")).unwrap();
766 agent.start_run().unwrap();
767 let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
768 let observed = polls.clone();
769 agent.set_before_generation_notice(Some(Box::new(move |_, _| {
770 observed.fetch_add(1, Ordering::SeqCst);
771 Box::pin(async { Some("new prelude".repeat(1000)) })
772 })));
773 let broken = Arc::new(AtomicBool::new(true));
774 let disk = broken.clone();
775 agent.set_checkpoint(Some(Box::new(move |state| {
776 if disk.load(Ordering::SeqCst) && state.context_tokens_estimate() > 100 {
777 Err("disk full".into())
778 } else {
779 Ok(())
780 }
781 })));
782 assert!(matches!(
783 agent.prepare_generation(CancelToken::new()).await,
784 Err(AgentInteractionError::Checkpoint(_))
785 ));
786 let size = agent.context_tokens_estimate();
787 assert!(size > 3000);
788 assert_eq!(model.remaining(), 1);
789 broken.store(false, Ordering::SeqCst);
790 agent.prepare_generation(CancelToken::new()).await.unwrap();
791 agent.step(CancelToken::new()).await.unwrap();
792 assert_eq!(polls.load(Ordering::SeqCst), 1);
793 assert_eq!(model.remaining(), 0);
794 assert_eq!(agent.history().iter().flat_map(Message::content).filter(|part| matches!(part, Content::System { kind, .. } if kind == "generation_notice")).count(), 1);
795 }
796
797 #[tokio::test]
798 async fn generation_commit_events_require_a_successful_history_checkpoint() {
799 for fails in [false, true] {
800 let model = ScriptedModel::new(vec![GenerateOutput {
801 content: vec![Content::Text {
802 text: "answer".into(),
803 }],
804 tool_uses: vec![],
805 usage: None,
806 turn_end_reason: TurnEndReason::EndTurn,
807 }]);
808 let events = Arc::new(EventLog::default());
809 let mut agent = Agent::new(model, TestTools::new(vec![]), events.clone());
810 agent.append_input(user("task")).unwrap();
811 agent.set_checkpoint(Some(Box::new(move |state| {
812 if fails
813 && state
814 .history()
815 .iter()
816 .any(|message| matches!(message, Message::AssistantMessage { .. }))
817 {
818 Err("disk full".into())
819 } else {
820 Ok(())
821 }
822 })));
823 agent.start_run().unwrap();
824 let result = agent.step(CancelToken::new()).await;
825 assert_eq!(result.is_ok(), !fails);
826 let events = events.0.lock().unwrap();
827 assert!(
828 events
829 .iter()
830 .any(|event| matches!(event, AgentEvent::GenerationFinished { .. }))
831 );
832 assert_eq!(
833 events
834 .iter()
835 .any(|event| matches!(event, AgentEvent::GenerationCommitted { .. })),
836 !fails
837 );
838 if !fails {
839 let committed = events
840 .iter()
841 .position(|event| matches!(event, AgentEvent::GenerationCommitted { .. }))
842 .unwrap();
843 let finished = events
844 .iter()
845 .position(|event| matches!(event, AgentEvent::TurnFinished { .. }))
846 .unwrap();
847 assert!(committed < finished);
848 }
849 }
850 }
851
852 #[tokio::test]
853 async fn cancelling_notice_poll_preserves_the_update_for_the_next_run() {
854 let started = CancelToken::new();
855 let release = CancelToken::new();
856 let model = ScriptedModel::new(vec![GenerateOutput {
857 content: vec![],
858 tool_uses: vec![],
859 turn_end_reason: TurnEndReason::EndTurn,
860 usage: None,
861 }]);
862 let mut agent = Agent::new(
863 model.clone(),
864 TestTools::new(vec![]),
865 Arc::new(NullEventSink),
866 );
867 agent.replace_context(vec![user("task")], None).unwrap();
868 agent.set_before_generation_notice(Some(Box::new({
869 let started = started.clone();
870 let release = release.clone();
871 move |_, _| {
872 let started = started.clone();
873 let release = release.clone();
874 Box::pin(async move {
875 started.cancel();
876 release.cancelled().await;
877 Some("updated context".into())
878 })
879 }
880 })));
881 let cancel = CancelToken::new();
882 let (outcome, ()) = tokio::join!(agent.run(cancel.clone()), async {
883 started.cancelled().await;
884 cancel.cancel();
885 });
886 assert!(matches!(outcome, Err(AgentInteractionError::Cancelled)));
887 assert_eq!(model.remaining(), 1);
888 assert_eq!(
889 serde_json::to_value(agent.history()).unwrap(),
890 serde_json::to_value([user("task")]).unwrap()
891 );
892
893 let saved = Arc::new(Mutex::new(Vec::new()));
894 agent.set_checkpoint(Some(Box::new({
895 let saved = saved.clone();
896 move |state| {
897 if state.pending_operation().is_some() {
898 *saved.lock().unwrap() = state.history().to_vec();
899 }
900 Ok(())
901 }
902 })));
903 release.cancel();
904 agent.run(CancelToken::new()).await.unwrap();
905 assert_eq!(model.remaining(), 0);
906 let snapshot = saved.lock().unwrap();
907 assert!(
908 matches!(snapshot.as_slice(), [Message::UserMessage { content }]
909 if matches!(content.last(), Some(Content::System { kind, text, .. })
910 if kind == "generation_notice" && text == "updated context"))
911 );
912 assert_eq!(agent.history().len(), 2);
913 }
914
915 impl EventSink for EventLog {
916 fn emit(&self, event: AgentEvent) {
917 self.0.lock().unwrap().push(event);
918 }
919 }
920
921 #[tokio::test]
922 async fn each_run_closes_its_event_stream_once_on_success_failure_or_cancel() {
923 let model = ScriptedModel::new(vec![GenerateOutput {
924 content: vec![],
925 tool_uses: vec![],
926 turn_end_reason: TurnEndReason::EndTurn,
927 usage: None,
928 }])
929 .then_fail(GenerateError::ExecutionError("unavailable".into()));
930 let events = Arc::new(EventLog::default());
931 let mut agent = Agent::new(model, TestTools::new(vec![]), events.clone());
932 let cancelled = CancelToken::new();
933 cancelled.cancel();
934 for (cancel, succeeds) in [
935 (CancelToken::new(), true),
936 (CancelToken::new(), false),
937 (cancelled, false),
938 ] {
939 let result = interact(&mut agent, vec![], cancel).await;
940 assert_eq!(result.is_ok(), succeeds);
941 let mut emitted = events.0.lock().unwrap();
942 assert!(matches!(
943 emitted.last(),
944 Some(AgentEvent::TurnFinished { .. })
945 ));
946 assert_eq!(
947 emitted
948 .iter()
949 .filter(|event| matches!(event, AgentEvent::TurnFinished { .. }))
950 .count(),
951 1
952 );
953 emitted.clear();
954 }
955 }
956
957 struct SlowService {
959 name: String,
960 delay: Duration,
961 starts: Arc<Mutex<Vec<(String, Instant)>>>,
962 ends: Arc<Mutex<Vec<(String, Instant)>>>,
963 }
964
965 impl ToolExecutor for SlowService {
966 fn tool_specs(&self) -> Vec<ToolSpec> {
967 vec![ToolSpec {
968 name: self.name.clone(),
969 description: format!("slow test tool {}", self.name),
970 input_schema: json!({
971 "type": "object",
972 "properties": {},
973 "additionalProperties": false,
974 }),
975 }]
976 }
977
978 fn dispatch(
979 self: Arc<Self>,
980 tool_use: ToolUse,
981 _cancel: CancelToken,
982 _background: CancelToken,
983 ) -> Async<ToolResult> {
984 Box::pin(async move {
985 let started = Instant::now();
986 self.starts
987 .lock()
988 .unwrap()
989 .push((tool_use.name.clone(), started));
990 tokio::time::sleep(self.delay).await;
991 let ended = Instant::now();
992 self.ends
993 .lock()
994 .unwrap()
995 .push((tool_use.name.clone(), ended));
996 ToolResult::text(format!("done:{}", tool_use.name))
997 })
998 }
999 }
1000
1001 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1002 async fn concurrent_tool_uses_overlap_and_preserve_order() {
1003 let starts = Arc::new(Mutex::new(Vec::new()));
1004 let ends = Arc::new(Mutex::new(Vec::new()));
1005 let delay = Duration::from_millis(300);
1007
1008 let slow_a = Arc::new(SlowService {
1010 name: "slow_a".into(),
1011 delay,
1012 starts: starts.clone(),
1013 ends: ends.clone(),
1014 });
1015 let slow_b = Arc::new(SlowService {
1016 name: "slow_b".into(),
1017 delay,
1018 starts: starts.clone(),
1019 ends: ends.clone(),
1020 });
1021
1022 let tools = TestTools::new(vec![
1023 slow_a as Arc<dyn ToolExecutor>,
1024 slow_b as Arc<dyn ToolExecutor>,
1025 ]);
1026
1027 let model = ScriptedModel::new(vec![
1028 GenerateOutput {
1029 content: vec![],
1030 tool_uses: vec![
1031 ToolUse {
1032 name: "slow_a".into(),
1033 input: json!({}),
1034 thought_signature: None,
1035 },
1036 ToolUse {
1037 name: "slow_b".into(),
1038 input: json!({}),
1039 thought_signature: None,
1040 },
1041 ],
1042 turn_end_reason: TurnEndReason::ToolUse,
1043 usage: None,
1044 },
1045 GenerateOutput {
1046 content: vec![Content::Text {
1047 text: "all done".into(),
1048 }],
1049 tool_uses: vec![],
1050 turn_end_reason: TurnEndReason::EndTurn,
1051 usage: None,
1052 },
1053 ]);
1054
1055 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1056 let wall_start = Instant::now();
1057 let reply = interact(
1058 &mut agent,
1059 vec![Content::Text {
1060 text: "run both".into(),
1061 }],
1062 crate::CancelToken::new(),
1063 )
1064 .await
1065 .expect("interact");
1066 let wall = wall_start.elapsed();
1067
1068 assert_eq!(reply.len(), 1);
1070 match &reply[0] {
1071 Content::Text { text } => assert_eq!(text, "all done"),
1072 other => panic!("expected text reply, got {other:?}"),
1073 }
1074
1075 let history = agent.history();
1077 assert_eq!(history.len(), 4);
1078 match &history[2] {
1079 Message::ToolResults { tool_use_results } => {
1080 assert_eq!(tool_use_results.len(), 2);
1081 assert_eq!(result_text(&tool_use_results[0]), "done:slow_a");
1083 assert_eq!(result_text(&tool_use_results[1]), "done:slow_b");
1084 assert!(!tool_use_results[0].is_error);
1085 assert!(!tool_use_results[1].is_error);
1086 }
1087 other => panic!("expected ToolResults, got {other:?}"),
1088 }
1089
1090 let starts = starts.lock().unwrap().clone();
1092 let ends = ends.lock().unwrap().clone();
1093 assert_eq!(starts.len(), 2);
1094 assert_eq!(ends.len(), 2);
1095 let first_end = ends.iter().map(|(_, t)| *t).min().unwrap();
1096 let last_start = starts.iter().map(|(_, t)| *t).max().unwrap();
1097 assert!(
1098 last_start < first_end,
1099 "expected overlapping execution: last_start={last_start:?} first_end={first_end:?} starts={starts:?} ends={ends:?}"
1100 );
1101
1102 assert!(
1106 wall < delay * 6 + Duration::from_secs(1),
1107 "expected concurrent wall time ~1 delay, got {wall:?} (delay={delay:?})"
1108 );
1109 }
1110
1111 #[tokio::test]
1114 async fn checkpoints_include_tool_intent_and_completed_observations() {
1115 let slow = Arc::new(SlowService {
1116 name: "slow_a".into(),
1117 delay: Duration::from_millis(1),
1118 starts: Arc::new(Mutex::new(Vec::new())),
1119 ends: Arc::new(Mutex::new(Vec::new())),
1120 });
1121 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1122 let model = ScriptedModel::new(vec![
1123 GenerateOutput {
1124 content: vec![],
1125 tool_uses: vec![ToolUse {
1126 name: "slow_a".into(),
1127 input: json!({}),
1128 thought_signature: None,
1129 }],
1130 turn_end_reason: TurnEndReason::ToolUse,
1131 usage: None,
1132 },
1133 GenerateOutput {
1134 content: vec![Content::Text {
1135 text: "done".into(),
1136 }],
1137 tool_uses: vec![],
1138 turn_end_reason: TurnEndReason::EndTurn,
1139 usage: None,
1140 },
1141 ]);
1142 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1143 let snapshots: Arc<Mutex<Vec<AgentState>>> = Arc::new(Mutex::new(Vec::new()));
1144 let record = snapshots.clone();
1145 agent.set_checkpoint(Some(Box::new(move |state| {
1146 validate_checkpoint(state.history(), state.pending_operation()).unwrap();
1147 record.lock().unwrap().push(state.clone());
1148 Ok(())
1149 })));
1150
1151 interact(
1152 &mut agent,
1153 vec![Content::Text { text: "run".into() }],
1154 crate::CancelToken::new(),
1155 )
1156 .await
1157 .expect("interact");
1158
1159 let snapshots = snapshots.lock().unwrap();
1160 let intent = snapshots
1161 .iter()
1162 .position(|state| {
1163 matches!(
1164 state.pending_operation(),
1165 Some(PendingOperation::Tools { .. })
1166 )
1167 })
1168 .unwrap();
1169 let observed = snapshots
1170 .iter()
1171 .position(|state| matches!(state.history().last(), Some(Message::ToolResults { .. })))
1172 .unwrap();
1173 assert!(intent < observed);
1174 assert!(matches!(
1175 snapshots[observed].pending_operation(),
1176 Some(PendingOperation::Generation { .. })
1177 ));
1178 assert_eq!(snapshots.first().unwrap().history().len(), 1);
1179 let finished = snapshots.last().unwrap();
1180 assert!(finished.pending_operation().is_none());
1181 assert_eq!(finished.history().len(), 4);
1182 }
1183
1184 #[tokio::test]
1185 async fn failed_intent_checkpoint_prevents_tool_dispatch_and_another_generation() {
1186 let starts = Arc::new(Mutex::new(Vec::new()));
1187 let tool = Arc::new(SlowService {
1188 name: "effect".into(),
1189 delay: Duration::ZERO,
1190 starts: starts.clone(),
1191 ends: Arc::new(Mutex::new(Vec::new())),
1192 });
1193 let model = ScriptedModel::new(vec![
1194 GenerateOutput {
1195 content: vec![],
1196 tool_uses: vec![ToolUse {
1197 name: "effect".into(),
1198 input: json!({}),
1199 thought_signature: None,
1200 }],
1201 turn_end_reason: TurnEndReason::ToolUse,
1202 usage: None,
1203 },
1204 GenerateOutput {
1205 content: vec![],
1206 tool_uses: vec![],
1207 turn_end_reason: TurnEndReason::EndTurn,
1208 usage: None,
1209 },
1210 ]);
1211 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1212 agent.set_checkpoint(Some(Box::new(|state| {
1213 if matches!(
1214 state.pending_operation(),
1215 Some(PendingOperation::Tools { .. })
1216 ) {
1217 Err("disk full".into())
1218 } else {
1219 Ok(())
1220 }
1221 })));
1222 let error = interact(
1223 &mut agent,
1224 vec![Content::Text {
1225 text: "task".into(),
1226 }],
1227 CancelToken::new(),
1228 )
1229 .await
1230 .unwrap_err();
1231 assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
1232 assert_eq!(error.recovery(), Recovery::Stop);
1233 assert!(starts.lock().unwrap().is_empty());
1234 assert!(matches!(
1235 agent.state().pending_operation(),
1236 Some(PendingOperation::Tools { .. })
1237 ));
1238 assert!(matches!(
1239 agent.run(CancelToken::new()).await,
1240 Err(AgentInteractionError::State(StateError::Busy))
1241 ));
1242 assert!(starts.lock().unwrap().is_empty());
1243 agent.set_checkpoint(None);
1244 agent.continue_run(CancelToken::new()).await.unwrap();
1245 assert_eq!(starts.lock().unwrap().len(), 1);
1246 }
1247
1248 #[tokio::test]
1249 async fn failed_result_checkpoint_retains_observations_and_stops_before_the_next_model_call() {
1250 let starts = Arc::new(Mutex::new(Vec::new()));
1251 let tool = Arc::new(SlowService {
1252 name: "effect".into(),
1253 delay: Duration::ZERO,
1254 starts: starts.clone(),
1255 ends: Arc::new(Mutex::new(Vec::new())),
1256 });
1257 let model = ScriptedModel::new(vec![
1258 GenerateOutput {
1259 content: vec![],
1260 tool_uses: vec![ToolUse {
1261 name: "effect".into(),
1262 input: json!({}),
1263 thought_signature: None,
1264 }],
1265 turn_end_reason: TurnEndReason::ToolUse,
1266 usage: None,
1267 },
1268 GenerateOutput {
1269 content: vec![],
1270 tool_uses: vec![],
1271 turn_end_reason: TurnEndReason::EndTurn,
1272 usage: None,
1273 },
1274 ]);
1275 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1276 agent.set_checkpoint(Some(Box::new(|state| {
1277 if matches!(state.history().last(), Some(Message::ToolResults { .. })) {
1278 Err("disk full".into())
1279 } else {
1280 Ok(())
1281 }
1282 })));
1283 let error = interact(
1284 &mut agent,
1285 vec![Content::Text {
1286 text: "task".into(),
1287 }],
1288 CancelToken::new(),
1289 )
1290 .await
1291 .unwrap_err();
1292 assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
1293 assert_eq!(starts.lock().unwrap().len(), 1);
1294 assert!(
1295 matches!(agent.history().last(), Some(Message::ToolResults { tool_use_results }) if !tool_use_results[0].is_error)
1296 );
1297 assert!(matches!(
1298 agent.state().pending_operation(),
1299 Some(PendingOperation::Generation { .. })
1300 ));
1301 agent.set_checkpoint(None);
1302 agent.continue_run(CancelToken::new()).await.unwrap();
1303 assert_eq!(starts.lock().unwrap().len(), 1);
1304 }
1305
1306 #[tokio::test]
1307 async fn dropping_a_tool_step_requires_explicit_recovery_and_never_replays_it() {
1308 let starts = Arc::new(Mutex::new(Vec::new()));
1309 let tool = Arc::new(SlowService {
1310 name: "effect".into(),
1311 delay: Duration::from_secs(60),
1312 starts: starts.clone(),
1313 ends: Arc::new(Mutex::new(Vec::new())),
1314 });
1315 let model = ScriptedModel::new(vec![
1316 GenerateOutput {
1317 content: vec![],
1318 tool_uses: vec![ToolUse {
1319 name: "effect".into(),
1320 input: json!({}),
1321 thought_signature: None,
1322 }],
1323 turn_end_reason: TurnEndReason::ToolUse,
1324 usage: None,
1325 },
1326 GenerateOutput {
1327 content: vec![],
1328 tool_uses: vec![],
1329 turn_end_reason: TurnEndReason::EndTurn,
1330 usage: None,
1331 },
1332 ]);
1333 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1334 agent
1335 .append_input(Message::UserMessage {
1336 content: vec![Content::Text {
1337 text: "task".into(),
1338 }],
1339 })
1340 .unwrap();
1341 agent.start_run().unwrap();
1342 assert!(agent.step(CancelToken::new()).await.unwrap().is_none());
1343 {
1344 let step = agent.step(CancelToken::new());
1345 futures::pin_mut!(step);
1346 assert!(futures::poll!(step).is_pending());
1347 }
1348 assert_eq!(starts.lock().unwrap().len(), 1);
1349 assert!(matches!(
1350 agent.continue_run(CancelToken::new()).await,
1351 Err(AgentInteractionError::State(StateError::Busy))
1352 ));
1353 agent.recover_interrupted().unwrap();
1354 assert!(agent.history().iter().any(|message| matches!(message, Message::ToolResults { tool_use_results } if tool_use_results[0].is_error)));
1355 agent.run(CancelToken::new()).await.unwrap();
1356 assert_eq!(starts.lock().unwrap().len(), 1);
1357 }
1358
1359 #[tokio::test]
1360 async fn last_usage_sums_output_across_tool_round_trips() {
1361 let tool = Arc::new(SlowService {
1362 name: "fast".into(),
1363 delay: Duration::ZERO,
1364 starts: Arc::new(Mutex::new(Vec::new())),
1365 ends: Arc::new(Mutex::new(Vec::new())),
1366 });
1367 let tools = TestTools::new(vec![tool as Arc<dyn ToolExecutor>]);
1368
1369 let model = ScriptedModel::new(vec![
1370 GenerateOutput {
1371 content: vec![],
1372 tool_uses: vec![ToolUse {
1373 name: "fast".into(),
1374 input: json!({}),
1375 thought_signature: None,
1376 }],
1377 turn_end_reason: TurnEndReason::ToolUse,
1378 usage: Some(TokenUsage {
1379 input_tokens: 1_000,
1380 output_tokens: 500,
1381 cached_input_tokens: 800,
1382 }),
1383 },
1384 GenerateOutput {
1385 content: vec![Content::Text {
1386 text: "done".into(),
1387 }],
1388 tool_uses: vec![],
1389 turn_end_reason: TurnEndReason::EndTurn,
1390 usage: Some(TokenUsage {
1391 input_tokens: 1_600,
1392 output_tokens: 20,
1393 cached_input_tokens: 900,
1394 }),
1395 },
1396 ]);
1397
1398 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1399 interact(
1400 &mut agent,
1401 vec![Content::Text { text: "go".into() }],
1402 crate::CancelToken::new(),
1403 )
1404 .await
1405 .expect("interact");
1406
1407 let usage = agent.last_usage().expect("usage recorded");
1409 assert_eq!(usage.input_tokens, 1_600);
1410 assert_eq!(usage.cached_input_tokens, 900);
1411 assert_eq!(usage.output_tokens, 520);
1412 assert_eq!(usage.context_tokens(), 1_600);
1413 }
1414
1415 struct SlowStreamModel {
1417 delay: Duration,
1418 chunks: usize,
1419 }
1420
1421 impl GenerativeModel for SlowStreamModel {
1422 fn generate(&self, _input: &[Message]) -> myco_model::AsyncStream<GenerationEvent> {
1423 let delay = self.delay;
1424 let chunks = self.chunks;
1425 Box::pin(stream::unfold(0usize, move |step| {
1428 let delay = delay;
1429 async move {
1430 let last = chunks + 2;
1431 if step > last {
1432 return None;
1433 }
1434 let part = if step == 0 {
1435 MessagePart::MessageStart
1436 } else if step == 1 {
1437 MessagePart::ContentStart(generative_model::ContentStart::Text { index: 0 })
1438 } else if step <= chunks + 1 {
1439 tokio::time::sleep(delay).await;
1440 MessagePart::ContentDelta(ContentDelta::Text {
1441 index: 0,
1442 delta: format!("chunk{}", step - 2),
1443 })
1444 } else {
1445 MessagePart::TurnEndReason(TurnEndReason::EndTurn)
1446 };
1447 Some((GenerationEvent::Part(part), step + 1))
1448 }
1449 }))
1450 }
1451 }
1452
1453 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1454 async fn cancel_during_generate_returns_cancelled() {
1455 let tools = TestTools::new(vec![]);
1456 let model = Arc::new(SlowStreamModel {
1457 delay: Duration::from_millis(200),
1458 chunks: 20,
1459 });
1460 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1461 let cancel = crate::CancelToken::new();
1462 let cancel2 = cancel.clone();
1463 tokio::spawn(async move {
1464 tokio::time::sleep(Duration::from_millis(50)).await;
1465 cancel2.cancel();
1466 });
1467
1468 let t0 = Instant::now();
1469 let err = interact(
1470 &mut agent,
1471 vec![Content::Text { text: "go".into() }],
1472 cancel,
1473 )
1474 .await
1475 .expect_err("should cancel");
1476 let elapsed = t0.elapsed();
1477 assert!(
1478 matches!(err, AgentInteractionError::Cancelled),
1479 "got {err:?}"
1480 );
1481 assert!(
1482 elapsed < Duration::from_secs(2),
1484 "cancel should be prompt, took {elapsed:?}"
1485 );
1486 assert_eq!(agent.history().len(), 1);
1488 assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1489 }
1490
1491 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1492 async fn cancel_during_slow_tool_records_cancelled_result() {
1493 let starts = Arc::new(Mutex::new(Vec::new()));
1494 let ends = Arc::new(Mutex::new(Vec::new()));
1495 let slow = Arc::new(SlowService {
1496 name: "slow_a".into(),
1497 delay: Duration::from_secs(5),
1499 starts: starts.clone(),
1500 ends: ends.clone(),
1501 });
1502 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1503 let model = ScriptedModel::new(vec![GenerateOutput {
1504 content: vec![],
1505 tool_uses: vec![ToolUse {
1506 name: "slow_a".into(),
1507 input: json!({}),
1508 thought_signature: None,
1509 }],
1510 turn_end_reason: TurnEndReason::ToolUse,
1511 usage: None,
1512 }]);
1513 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1515 let cancel = crate::CancelToken::new();
1516 let cancel2 = cancel.clone();
1517 let starts_bg = starts.clone();
1518 tokio::spawn(async move {
1519 let deadline = Instant::now() + Duration::from_secs(2);
1521 loop {
1522 if !starts_bg.lock().unwrap().is_empty() {
1523 break;
1524 }
1525 if Instant::now() > deadline {
1526 break;
1527 }
1528 tokio::time::sleep(Duration::from_millis(5)).await;
1529 }
1530 cancel2.cancel();
1531 });
1532
1533 let t0 = Instant::now();
1534 let err = interact(
1535 &mut agent,
1536 vec![Content::Text { text: "run".into() }],
1537 cancel,
1538 )
1539 .await
1540 .expect_err("should cancel");
1541 let elapsed = t0.elapsed();
1542 assert!(matches!(err, AgentInteractionError::Cancelled));
1543 assert!(
1546 elapsed < Duration::from_secs(4),
1547 "should wait only the cancel grace, not the full tool delay, took {elapsed:?}"
1548 );
1549
1550 let history = agent.history();
1552 assert_eq!(history.len(), 3);
1553 match &history[2] {
1554 Message::ToolResults { tool_use_results } => {
1555 assert_eq!(tool_use_results.len(), 1);
1556 assert!(tool_use_results[0].is_error);
1557 let text = result_text(&tool_use_results[0]);
1558 assert!(text.contains("effects are unknown"), "{text}");
1559 }
1560 other => panic!("expected ToolResults, got {other:?}"),
1561 }
1562 }
1563
1564 #[tokio::test]
1565 async fn generate_error_after_tool_results_keeps_well_formed_history() {
1566 let slow = Arc::new(SlowService {
1567 name: "slow_a".into(),
1568 delay: Duration::from_millis(1),
1569 starts: Arc::new(Mutex::new(Vec::new())),
1570 ends: Arc::new(Mutex::new(Vec::new())),
1571 });
1572 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1573 let model = ScriptedModel::new(vec![GenerateOutput {
1574 content: vec![],
1575 tool_uses: vec![ToolUse {
1576 name: "slow_a".into(),
1577 input: json!({}),
1578 thought_signature: None,
1579 }],
1580 turn_end_reason: TurnEndReason::ToolUse,
1581 usage: None,
1582 }])
1583 .then_fail(GenerateError::ExecutionError(
1584 "provider 500 after tools".into(),
1585 ));
1586 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1587 let err = interact(
1588 &mut agent,
1589 vec![Content::Text {
1590 text: "run tool then fail".into(),
1591 }],
1592 crate::CancelToken::new(),
1593 )
1594 .await
1595 .expect_err("second generate should fail");
1596 assert!(
1597 matches!(err, AgentInteractionError::GenerateError(_)),
1598 "got {err:?}"
1599 );
1600
1601 let history = agent.history();
1603 assert_eq!(history.len(), 3, "history={history:?}");
1604 assert!(matches!(history[0], Message::UserMessage { .. }));
1605 match &history[1] {
1606 Message::AssistantMessage {
1607 tool_uses,
1608 turn_end_reason,
1609 ..
1610 } => {
1611 assert_eq!(tool_uses.len(), 1);
1612 assert_eq!(tool_uses[0].name, "slow_a");
1613 assert_eq!(*turn_end_reason, Some(TurnEndReason::ToolUse));
1614 }
1615 other => panic!("expected assistant tool_use, got {other:?}"),
1616 }
1617 match &history[2] {
1618 Message::ToolResults { tool_use_results } => {
1619 assert_eq!(tool_use_results.len(), 1);
1620 assert!(!tool_use_results[0].is_error);
1621 }
1622 other => panic!("expected ToolResults, got {other:?}"),
1623 }
1624 }
1625
1626 #[tokio::test]
1627 async fn generate_error_before_assistant_keeps_only_user() {
1628 let tools = TestTools::new(vec![]);
1629 let model = ScriptedModel::new(vec![]).then_fail(GenerateError::ExecutionError(
1630 "boom on first generate".into(),
1631 ));
1632 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1633 let err = interact(
1634 &mut agent,
1635 vec![Content::Text { text: "hi".into() }],
1636 crate::CancelToken::new(),
1637 )
1638 .await
1639 .expect_err("generate should fail");
1640 assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1641 assert_eq!(agent.history().len(), 1);
1642 assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1643 }
1644
1645 #[tokio::test]
1647 async fn tool_use_stop_with_zero_tool_uses_errors_not_loops() {
1648 let tools = TestTools::new(vec![]);
1649 let model = ScriptedModel::new(vec![GenerateOutput {
1650 content: vec![Content::Text { text: "hmm".into() }],
1651 tool_uses: vec![],
1652 turn_end_reason: TurnEndReason::ToolUse,
1653 usage: None,
1654 }]);
1655 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1656 agent.set_retry_policy(RetryPolicy {
1657 max_attempts: 1,
1658 ..Default::default()
1659 });
1660 let err = interact(
1661 &mut agent,
1662 vec![Content::Text { text: "hi".into() }],
1663 crate::CancelToken::new(),
1664 )
1665 .await
1666 .expect_err("malformed turn should error");
1667 assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1668 assert_eq!(agent.history().len(), 1);
1669 assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1670 }
1671
1672 #[tokio::test]
1675 async fn max_tokens_mid_tool_call_answers_the_dangling_tool_use_and_resumes() {
1676 let tools = TestTools::new(vec![]);
1677 let model = ScriptedModel::new(vec![
1678 GenerateOutput {
1679 content: vec![Content::Text {
1680 text: "let me check".into(),
1681 }],
1682 tool_uses: vec![ToolUse {
1683 name: "unavailable".into(),
1684 input: serde_json::json!({}),
1685 thought_signature: None,
1686 }],
1687 turn_end_reason: TurnEndReason::MaxTokens,
1688 usage: None,
1689 },
1690 GenerateOutput {
1691 content: vec![Content::Text { text: "ok".into() }],
1692 tool_uses: vec![],
1693 turn_end_reason: TurnEndReason::EndTurn,
1694 usage: None,
1695 },
1696 ]);
1697 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1698 let reply = interact(
1699 &mut agent,
1700 vec![Content::Text { text: "hi".into() }],
1701 crate::CancelToken::new(),
1702 )
1703 .await
1704 .expect("turn should resume through the truncation, not error");
1705
1706 assert!(matches!(&reply[0], Content::Text { text } if text == "ok"));
1709
1710 assert_eq!(agent.history().len(), 4);
1713 match &agent.history()[2] {
1714 Message::ToolResults { tool_use_results } => {
1715 assert_eq!(tool_use_results.len(), 1);
1716 assert!(tool_use_results[0].is_error);
1717 let text = result_text(&tool_use_results[0]);
1718 assert!(text.contains("unknown tool 'unavailable'"), "text={text}");
1719 }
1720 other => panic!("expected ToolResults, got {other:?}"),
1721 }
1722 }
1723
1724 #[tokio::test]
1729 async fn max_tokens_without_tool_calls_resumes_with_a_continue_turn() {
1730 let tools = TestTools::new(vec![]);
1731 let model = ScriptedModel::new(vec![
1732 GenerateOutput {
1733 content: vec![Content::Text {
1734 text: "half a sen".into(),
1735 }],
1736 tool_uses: vec![],
1737 turn_end_reason: TurnEndReason::MaxTokens,
1738 usage: None,
1739 },
1740 GenerateOutput {
1741 content: vec![Content::Text {
1742 text: "tence.".into(),
1743 }],
1744 tool_uses: vec![],
1745 turn_end_reason: TurnEndReason::EndTurn,
1746 usage: None,
1747 },
1748 ]);
1749 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1750 let reply = interact(
1751 &mut agent,
1752 vec![Content::Text { text: "hi".into() }],
1753 crate::CancelToken::new(),
1754 )
1755 .await
1756 .expect("turn should resume through the truncation");
1757
1758 assert!(matches!(&reply[0], Content::Text { text } if text == "tence."));
1759
1760 assert_eq!(agent.history().len(), 4);
1762 match &agent.history()[2] {
1763 Message::UserMessage { content } => match content.as_slice() {
1764 [Content::System { text, kind, .. }] => {
1765 assert_eq!(text, CONTINUE_PROMPT);
1766 assert_eq!(kind, "continuation");
1767 }
1768 other => panic!("expected one text block, got {other:?}"),
1769 },
1770 other => panic!("expected the continuation user turn, got {other:?}"),
1771 }
1772 }
1773
1774 #[tokio::test]
1778 async fn consecutive_max_tokens_resumes_are_bounded() {
1779 const CAP: u32 = 2;
1780 let tools = TestTools::new(vec![]);
1781 let truncated_with_tool_call = || GenerateOutput {
1782 content: vec![Content::Text {
1783 text: "still going".into(),
1784 }],
1785 tool_uses: vec![ToolUse {
1786 name: "bash".into(),
1787 input: serde_json::json!({}),
1788 thought_signature: None,
1789 }],
1790 turn_end_reason: TurnEndReason::MaxTokens,
1791 usage: None,
1792 };
1793 let scripts = (0..CAP + 2).map(|_| truncated_with_tool_call()).collect();
1796 let model = ScriptedModel::new(scripts);
1797 let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1798 agent.set_max_truncated_resumes(CAP);
1799 interact(
1800 &mut agent,
1801 vec![Content::Text { text: "hi".into() }],
1802 crate::CancelToken::new(),
1803 )
1804 .await
1805 .expect("turn should hand back once the cap is hit, not error");
1806
1807 assert_eq!(model.remaining() as u32, 1);
1809 }
1810
1811 #[tokio::test]
1814 async fn zero_max_truncated_resumes_hands_back_the_partial_answer() {
1815 let tools = TestTools::new(vec![]);
1816 let model = ScriptedModel::new(vec![
1817 GenerateOutput {
1818 content: vec![Content::Text {
1819 text: "half a sen".into(),
1820 }],
1821 tool_uses: vec![],
1822 turn_end_reason: TurnEndReason::MaxTokens,
1823 usage: None,
1824 },
1825 GenerateOutput {
1826 content: vec![Content::Text {
1827 text: "unused".into(),
1828 }],
1829 tool_uses: vec![],
1830 turn_end_reason: TurnEndReason::EndTurn,
1831 usage: None,
1832 },
1833 ]);
1834 let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1835 agent.set_max_truncated_resumes(0);
1836 let reply = interact(
1837 &mut agent,
1838 vec![Content::Text { text: "hi".into() }],
1839 crate::CancelToken::new(),
1840 )
1841 .await
1842 .expect("turn should hand back the partial answer");
1843
1844 assert!(matches!(&reply[0], Content::Text { text } if text == "half a sen"));
1845 assert_eq!(agent.history().len(), 2);
1847 assert_eq!(model.remaining(), 1, "second script must stay unconsumed");
1848 }
1849
1850 #[tokio::test]
1852 async fn plain_end_turn_pushes_no_tool_results() {
1853 let tools = TestTools::new(vec![]);
1854 let model = ScriptedModel::new(vec![GenerateOutput {
1855 content: vec![Content::Text { text: "hi".into() }],
1856 tool_uses: vec![],
1857 turn_end_reason: TurnEndReason::EndTurn,
1858 usage: None,
1859 }]);
1860 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1861 interact(
1862 &mut agent,
1863 vec![Content::Text { text: "hi".into() }],
1864 crate::CancelToken::new(),
1865 )
1866 .await
1867 .expect("turn should succeed");
1868 assert_eq!(agent.history().len(), 2);
1869 }
1870
1871 #[tokio::test]
1876 async fn oversized_request_reports_omit_last_message() {
1877 let tools = TestTools::new(vec![]);
1878 let model = ScriptedModel::new(vec![])
1879 .then_fail(GenerateError::RequestTooLargeError("42 MiB".into()));
1880 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1881 let err = interact(
1882 &mut agent,
1883 vec![Content::Image {
1884 source: "data:image/png;base64,AAAA".into(),
1885 }],
1886 crate::CancelToken::new(),
1887 )
1888 .await
1889 .expect_err("oversized request should fail");
1890
1891 assert_eq!(err.recovery(), Recovery::OmitLastMessage);
1892 }
1893
1894 #[test]
1897 fn cancelled_interaction_is_retryable() {
1898 assert_eq!(AgentInteractionError::Cancelled.recovery(), Recovery::Retry);
1899 }
1900
1901 #[tokio::test]
1905 async fn truncating_context_drops_the_whole_turn_and_keeps_earlier_ones() {
1906 let tools = TestTools::new(vec![]);
1907 let model = ScriptedModel::new(vec![]);
1908 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1909 agent
1910 .replace_context(
1911 vec![
1912 user("first"),
1913 assistant("ok"),
1914 user("second"),
1915 assistant_tool(None, "noop", json!({})),
1916 tool_results(&["done"]),
1917 ],
1918 None,
1919 )
1920 .unwrap();
1921
1922 let dropped = agent.truncate_history(2).unwrap();
1923 assert!(
1924 matches!(&dropped[0], Message::UserMessage { content } if matches!(&content[0], Content::Text { text } if text == "second"))
1925 );
1926
1927 let history = agent.history();
1928 assert_eq!(history.len(), 2);
1929 assert!(matches!(history[0], Message::UserMessage { .. }));
1930 assert!(matches!(history[1], Message::AssistantMessage { .. }));
1931 }
1932
1933 #[tokio::test]
1935 async fn resume_after_tools_mid_turn_continues_cleanly() {
1936 let snapshot = vec![
1941 user("mid turn"),
1942 assistant_tool(None, "slow_a", json!({})),
1943 tool_results(&["done:slow_a"]),
1944 ];
1945
1946 let tools = TestTools::new(vec![]);
1948 let resume_model = ScriptedModel::new(vec![GenerateOutput {
1949 content: vec![Content::Text {
1950 text: "recovered".into(),
1951 }],
1952 tool_uses: vec![],
1953 turn_end_reason: TurnEndReason::EndTurn,
1954 usage: None,
1955 }]);
1956 let mut resumed = Agent::new(resume_model, tools, Arc::new(NullEventSink));
1957 resumed.replace_context(snapshot, None).unwrap();
1958
1959 let reply = interact(
1964 &mut resumed,
1965 vec![Content::Text {
1966 text: "continue".into(),
1967 }],
1968 crate::CancelToken::new(),
1969 )
1970 .await
1971 .expect("resume interact");
1972 assert_eq!(reply.len(), 1);
1973 match &reply[0] {
1974 Content::Text { text } => assert_eq!(text, "recovered"),
1975 other => panic!("expected text, got {other:?}"),
1976 }
1977
1978 let history = resumed.history();
1979 assert_eq!(history.len(), 5);
1981 assert!(matches!(history[0], Message::UserMessage { .. }));
1982 assert!(matches!(history[1], Message::AssistantMessage { .. }));
1983 assert!(matches!(history[2], Message::ToolResults { .. }));
1984 assert!(matches!(history[3], Message::UserMessage { .. }));
1985 assert!(matches!(history[4], Message::AssistantMessage { .. }));
1986 }
1987}