1use std::sync::Arc;
26use std::sync::atomic::{AtomicBool, Ordering};
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 generation;
36mod state;
37pub use state::{
38 AgentState, Effect, OperationId, PendingOperation, StateError, recover_checkpoint,
39 validate_checkpoint, validate_context,
40};
41
42pub use tokio_util::sync::CancellationToken as CancelToken;
43pub type Async<T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send>>;
44pub const DEFAULT_MAX_TRUNCATED_RESUMES: u32 = 3;
45
46pub trait ToolExecutor: Send + Sync {
48 fn tool_specs(&self) -> Vec<generative_model::ToolSpec>;
51 fn dispatch(
56 self: Arc<Self>,
57 tool: ToolUse,
58 cancel: CancelToken,
59 background: CancelToken,
60 ) -> Async<ToolResult>;
61}
62
63#[derive(Debug, Clone)]
70pub struct TraceContext {
71 pub agent_id: Uuid,
73 pub depth: usize,
75 pub session_id: Option<String>,
76 pub thread_id: Option<String>,
77}
78
79impl Default for TraceContext {
80 fn default() -> Self {
81 Self {
82 agent_id: Uuid::nil(),
83 depth: 0,
84 session_id: None,
85 thread_id: None,
86 }
87 }
88}
89
90impl TraceContext {
91 pub fn root() -> Self {
92 Self {
93 agent_id: Uuid::new_v4(),
94 depth: 0,
95 session_id: None,
96 thread_id: None,
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
105pub enum AgentEvent {
106 Failure {
107 failure: GenerationFailure,
108 attempt: u32,
109 max_attempts: u32,
110 retry_in: Option<std::time::Duration>,
111 context: TraceContext,
112 },
113 TextDelta {
115 text: String,
116 context: TraceContext,
117 },
118 ThinkingDelta {
120 text: String,
121 context: TraceContext,
122 },
123 ToolStarted {
124 call_id: Uuid,
125 tool_use: ToolUse,
126 background: CancelToken,
127 context: TraceContext,
128 },
129 ToolFinished {
130 call_id: Uuid,
131 tool_use: ToolUse,
132 result: ToolResult,
133 context: TraceContext,
134 },
135 TurnFinished {
136 context: TraceContext,
137 },
138}
139
140pub trait EventSink: Send + Sync {
142 fn emit(&self, event: AgentEvent);
144}
145
146#[derive(Debug, Default)]
148pub struct NullEventSink;
149
150impl EventSink for NullEventSink {
151 fn emit(&self, _event: AgentEvent) {}
152}
153
154pub type Checkpoint = Box<dyn Fn(&AgentState) -> Result<(), String> + Send + Sync>;
162
163pub type BeforeGenerationNotice =
169 Box<dyn Fn(&TraceContext, &[Message]) -> Async<Option<String>> + Send + Sync>;
170
171const CANCEL_TOOL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
175
176pub const CONTINUE_PROMPT: &str = "Continue from exactly where you stopped. Do not repeat anything you have already written, \
182 and do not acknowledge this message.";
183
184#[derive(Debug, Clone)]
186pub struct RunOutcome {
187 pub answer: Vec<Content>,
188 pub reason: TurnEndReason,
189 pub usage: Option<TokenUsage>,
190}
191
192pub struct Agent {
198 retry_policy: RetryPolicy,
199 model: Arc<dyn GenerativeModel>,
200 tools: Arc<dyn ToolExecutor>,
201 sink: Arc<dyn EventSink>,
202 context: TraceContext,
203 state: AgentState,
204 context_window_tokens: u64,
206 checkpoint: Option<Checkpoint>,
207 before_generation_notice: Option<BeforeGenerationNotice>,
208 checkpoint_failed: AtomicBool,
209}
210
211impl Agent {
212 pub fn new(
213 model: Arc<dyn GenerativeModel>,
214 tools: Arc<dyn ToolExecutor>,
215 sink: Arc<dyn EventSink>,
216 ) -> Self {
217 Self::with_context(model, tools, sink, TraceContext::root())
218 }
219
220 pub fn with_context(
221 model: Arc<dyn GenerativeModel>,
222 tools: Arc<dyn ToolExecutor>,
223 sink: Arc<dyn EventSink>,
224 context: TraceContext,
225 ) -> Self {
226 Self {
227 retry_policy: RetryPolicy::default(),
228 model,
229 tools,
230 sink,
231 context,
232 state: AgentState::default(),
233 context_window_tokens: 200_000,
234 checkpoint: None,
235 before_generation_notice: None,
236 checkpoint_failed: AtomicBool::new(false),
237 }
238 }
239
240 pub fn set_tools(&mut self, tools: Arc<dyn ToolExecutor>) {
243 self.tools = tools;
244 }
245
246 pub fn set_context(&mut self, context: TraceContext) {
247 self.context = context;
248 }
249
250 pub fn set_checkpoint(&mut self, checkpoint: Option<Checkpoint>) {
252 self.checkpoint = checkpoint;
253 }
254
255 pub fn set_before_generation_notice(&mut self, notice: Option<BeforeGenerationNotice>) {
256 self.before_generation_notice = notice;
257 }
258
259 fn emit_checkpoint(&self) -> Result<(), AgentInteractionError> {
260 let result = self
261 .checkpoint
262 .as_ref()
263 .map_or(Ok(()), |checkpoint| checkpoint(&self.state));
264 self.checkpoint_failed
265 .store(result.is_err(), Ordering::Relaxed);
266 result.map_err(AgentInteractionError::Checkpoint)
267 }
268
269 pub fn checkpoint_failed(&self) -> bool {
270 self.checkpoint_failed.load(Ordering::Relaxed)
271 }
272
273 pub fn checkpoint(&self) -> Result<(), AgentInteractionError> {
275 self.emit_checkpoint()
276 }
277
278 pub fn history(&self) -> &[Message] {
279 self.state.history()
280 }
281
282 pub fn replace_context(
284 &mut self,
285 history: Vec<Message>,
286 usage: Option<TokenUsage>,
287 ) -> Result<(), StateError> {
288 self.state.replace_context(history, usage)
289 }
290
291 pub fn state(&self) -> &AgentState {
292 &self.state
293 }
294
295 pub fn set_retry_policy(&mut self, retry_policy: RetryPolicy) {
296 self.retry_policy = retry_policy;
297 }
298
299 pub fn set_model(&mut self, model: Arc<dyn GenerativeModel>) {
301 self.model = model;
302 }
303
304 pub fn append_input(&mut self, message: Message) -> Result<(), AgentInteractionError> {
306 self.state.append_input(message)?;
307 self.emit_checkpoint()?;
308 Ok(())
309 }
310
311 pub fn append_system(&mut self, parts: Vec<Content>) -> Result<(), AgentInteractionError> {
312 self.state.append_system(parts)?;
313 self.emit_checkpoint()
314 }
315
316 pub fn truncate_history(
317 &mut self,
318 index: usize,
319 ) -> Result<Vec<Message>, AgentInteractionError> {
320 let dropped = self.state.truncate_history(index)?;
321 self.emit_checkpoint()?;
322 Ok(dropped)
323 }
324
325 pub fn set_context_window_tokens(&mut self, tokens: u64) {
327 self.context_window_tokens = tokens.max(1);
328 }
329
330 pub fn context_window_tokens(&self) -> u64 {
331 self.context_window_tokens
332 }
333
334 pub fn set_max_truncated_resumes(&mut self, resumes: u32) {
337 self.state.set_max_truncated_resumes(resumes);
338 }
339
340 pub fn last_usage(&self) -> Option<TokenUsage> {
342 self.state.last_usage()
343 }
344
345 pub fn context(&self) -> &TraceContext {
346 &self.context
347 }
348
349 pub async fn run(
357 &mut self,
358 cancel: CancelToken,
359 ) -> Result<Vec<Content>, AgentInteractionError> {
360 self.run_with_outcome(cancel)
361 .await
362 .map(|outcome| outcome.answer)
363 }
364
365 pub async fn run_with_outcome(
367 &mut self,
368 cancel: CancelToken,
369 ) -> Result<RunOutcome, AgentInteractionError> {
370 if let Err(error) = self.start_run() {
371 self.finish_output();
372 return Err(error);
373 }
374 self.continue_run(cancel).await
375 }
376
377 pub fn start_run(&mut self) -> Result<(), AgentInteractionError> {
378 self.state.start()?;
379 Ok(())
380 }
381
382 pub async fn continue_run(
385 &mut self,
386 cancel: CancelToken,
387 ) -> Result<RunOutcome, AgentInteractionError> {
388 loop {
389 if let Some(outcome) = self.step(cancel.clone()).await? {
390 return Ok(outcome);
391 }
392 }
393 }
394
395 pub fn replace_at_boundary(
396 &mut self,
397 history: Vec<Message>,
398 usage: Option<TokenUsage>,
399 ) -> Result<(), StateError> {
400 self.state.replace_at_boundary(history, usage)
401 }
402
403 pub fn recover_interrupted(&mut self) -> Result<(), AgentInteractionError> {
404 self.state.recover_interrupted()?;
405 self.emit_checkpoint()
406 }
407
408 pub fn cancel_at_boundary(&mut self) -> Result<(), AgentInteractionError> {
409 self.state.cancel_at_boundary()?;
410 self.finish_output();
411 self.emit_checkpoint()
412 }
413
414 pub async fn step(
417 &mut self,
418 cancel: CancelToken,
419 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
420 let result = self.step_effect(cancel).await;
421 let result = match self.emit_checkpoint() {
422 Ok(()) => result,
423 Err(error) => Err(error),
424 };
425 if !matches!(result, Ok(None)) {
426 self.finish_output();
427 }
428 result
429 }
430
431 fn finish_output(&self) {
432 self.sink.emit(AgentEvent::TurnFinished {
433 context: self.context.clone(),
434 });
435 }
436
437 async fn step_effect(
438 &mut self,
439 cancel: CancelToken,
440 ) -> Result<Option<RunOutcome>, AgentInteractionError> {
441 self.emit_checkpoint()?;
442 let effect = self
443 .state
444 .effect()
445 .ok_or(StateError::UnexpectedCompletion)?;
446 let next = match effect {
447 Effect::Generate { operation } => {
448 self.state.begin_effect(operation)?;
449 let output = match async {
450 self.append_pending_notice(operation, &cancel).await?;
451 generation::generate(self, cancel).await
452 }
453 .await
454 {
455 Ok(output) => output,
456 Err(error) => {
457 self.state.generation_failed(operation)?;
458 return Err(error);
459 }
460 };
461 self.state.generated(operation, output)?
462 }
463 Effect::ExecuteTools { operation, calls } => {
464 self.state.begin_effect(operation)?;
465 let results = future::join_all(
466 calls
467 .into_iter()
468 .map(|call| self.dispatch_tool_use(call, cancel.clone())),
469 )
470 .await;
471 self.state
472 .tools_completed(operation, results, cancel.is_cancelled())?
473 }
474 terminal => terminal,
475 };
476 match next {
477 Effect::Finished { answer, reason } => Ok(Some(RunOutcome {
478 answer,
479 reason,
480 usage: self.state.run_usage(),
481 })),
482 Effect::Cancelled => Err(AgentInteractionError::Cancelled),
483 _ => Ok(None),
484 }
485 }
486
487 async fn append_pending_notice(
488 &mut self,
489 operation: OperationId,
490 cancel: &CancelToken,
491 ) -> Result<(), AgentInteractionError> {
492 let Some(poll_notice) = &self.before_generation_notice else {
493 return Ok(());
494 };
495 let note = tokio::select! {
496 biased;
497 _ = cancel.cancelled() => return Err(AgentInteractionError::Cancelled),
498 note = poll_notice(&self.context, self.state.history()) => note,
499 };
500 if let Some(text) = note {
501 self.state.append_generation_notice(operation, text)?;
502 self.emit_checkpoint()?;
503 }
504 Ok(())
505 }
506
507 async fn dispatch_tool_use(&self, tool_use: ToolUse, cancel: CancelToken) -> ToolResult {
508 if cancel.is_cancelled() {
509 return ToolResult::err("cancelled before dispatch");
510 }
511 let call_id = Uuid::new_v4();
512 let background = CancelToken::new();
513 self.sink.emit(AgentEvent::ToolStarted {
514 call_id,
515 tool_use: tool_use.clone(),
516 background: background.clone(),
517 context: self.context.clone(),
518 });
519
520 let work = self
521 .tools
522 .clone()
523 .dispatch(tool_use.clone(), cancel.clone(), background);
524
525 let mut work = std::pin::pin!(work);
536 let result = tokio::select! {
537 biased;
538 _ = cancel.cancelled() => {
539 match tokio::time::timeout(CANCEL_TOOL_GRACE, &mut work).await {
540 Ok(result) => result.with_status("cancel requested; partial result recorded"),
541 Err(_) => ToolResult::err("cancel requested; tool did not acknowledge before the deadline; effects are unknown").with_status("cancel requested; effects unknown"),
542 }
543 }
544 result = &mut work => result,
545 };
546 self.sink.emit(AgentEvent::ToolFinished {
547 call_id,
548 tool_use,
549 result: result.clone(),
550 context: self.context.clone(),
551 });
552 result
553 }
554}
555
556#[derive(thiserror::Error, Debug)]
557pub enum AgentInteractionError {
558 #[error("Error during generation: {0}")]
559 GenerateError(#[from] generative_model::GenerateError),
560 #[error("cancelled")]
563 Cancelled,
564 #[error("{0}")]
565 State(StateError),
566 #[error("could not persist agent state; execution stopped: {0}")]
567 Checkpoint(String),
568 #[error("compaction failed: {0}")]
569 Compaction(String),
570}
571
572impl From<StateError> for AgentInteractionError {
573 fn from(error: StateError) -> Self {
574 match error {
575 StateError::InvalidResponse(message) => {
576 Self::GenerateError(GenerateError::MalformedResponseError(message))
577 }
578 error => Self::State(error),
579 }
580 }
581}
582
583impl AgentInteractionError {
584 pub fn recovery(&self) -> Recovery {
588 match self {
589 AgentInteractionError::GenerateError(e) => e.recovery(),
590 AgentInteractionError::Cancelled => Recovery::Retry,
592 AgentInteractionError::State(_)
593 | AgentInteractionError::Checkpoint(_)
594 | AgentInteractionError::Compaction(_) => Recovery::Stop,
595 }
596 }
597}
598
599#[cfg(test)]
600mod test_support;
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::test_support::{
606 ScriptedModel, assistant, assistant_tool, result_text, tool_results, user,
607 };
608 use crate::test_support::{TestTools, interact};
609 use futures::stream;
610 use myco_model::{
611 ContentDelta, GenerateError, GenerateOutput, GenerationEvent, MessagePart, ToolSpec,
612 TurnEndReason,
613 };
614 use serde_json::json;
615 use std::sync::Mutex;
616 use std::time::{Duration, Instant};
617
618 #[derive(Default)]
619 struct EventLog(Mutex<Vec<AgentEvent>>);
620
621 #[tokio::test]
622 async fn cancelling_notice_poll_preserves_the_update_for_the_next_run() {
623 let started = CancelToken::new();
624 let release = CancelToken::new();
625 let model = ScriptedModel::new(vec![GenerateOutput {
626 content: vec![],
627 tool_uses: vec![],
628 turn_end_reason: TurnEndReason::EndTurn,
629 usage: None,
630 }]);
631 let mut agent = Agent::new(
632 model.clone(),
633 TestTools::new(vec![]),
634 Arc::new(NullEventSink),
635 );
636 agent.replace_context(vec![user("task")], None).unwrap();
637 agent.set_before_generation_notice(Some(Box::new({
638 let started = started.clone();
639 let release = release.clone();
640 move |_, _| {
641 let started = started.clone();
642 let release = release.clone();
643 Box::pin(async move {
644 started.cancel();
645 release.cancelled().await;
646 Some("updated context".into())
647 })
648 }
649 })));
650 let cancel = CancelToken::new();
651 let (outcome, ()) = tokio::join!(agent.run(cancel.clone()), async {
652 started.cancelled().await;
653 cancel.cancel();
654 });
655 assert!(matches!(outcome, Err(AgentInteractionError::Cancelled)));
656 assert_eq!(model.remaining(), 1);
657 assert_eq!(
658 serde_json::to_value(agent.history()).unwrap(),
659 serde_json::to_value([user("task")]).unwrap()
660 );
661
662 let saved = Arc::new(Mutex::new(Vec::new()));
663 agent.set_checkpoint(Some(Box::new({
664 let saved = saved.clone();
665 move |state| {
666 if state.pending_operation().is_some() {
667 *saved.lock().unwrap() = state.history().to_vec();
668 }
669 Ok(())
670 }
671 })));
672 release.cancel();
673 agent.run(CancelToken::new()).await.unwrap();
674 assert_eq!(model.remaining(), 0);
675 let snapshot = saved.lock().unwrap();
676 assert!(
677 matches!(snapshot.as_slice(), [Message::UserMessage { content }]
678 if matches!(content.last(), Some(Content::System { kind, text, .. })
679 if kind == "generation_notice" && text == "updated context"))
680 );
681 assert_eq!(agent.history().len(), 2);
682 }
683
684 impl EventSink for EventLog {
685 fn emit(&self, event: AgentEvent) {
686 self.0.lock().unwrap().push(event);
687 }
688 }
689
690 #[tokio::test]
691 async fn each_run_closes_its_event_stream_once_on_success_failure_or_cancel() {
692 let model = ScriptedModel::new(vec![GenerateOutput {
693 content: vec![],
694 tool_uses: vec![],
695 turn_end_reason: TurnEndReason::EndTurn,
696 usage: None,
697 }])
698 .then_fail(GenerateError::ExecutionError("unavailable".into()));
699 let events = Arc::new(EventLog::default());
700 let mut agent = Agent::new(model, TestTools::new(vec![]), events.clone());
701 let cancelled = CancelToken::new();
702 cancelled.cancel();
703 for (cancel, succeeds) in [
704 (CancelToken::new(), true),
705 (CancelToken::new(), false),
706 (cancelled, false),
707 ] {
708 let result = interact(&mut agent, vec![], cancel).await;
709 assert_eq!(result.is_ok(), succeeds);
710 let mut emitted = events.0.lock().unwrap();
711 assert!(matches!(
712 emitted.last(),
713 Some(AgentEvent::TurnFinished { .. })
714 ));
715 assert_eq!(
716 emitted
717 .iter()
718 .filter(|event| matches!(event, AgentEvent::TurnFinished { .. }))
719 .count(),
720 1
721 );
722 emitted.clear();
723 }
724 }
725
726 struct SlowService {
728 name: String,
729 delay: Duration,
730 starts: Arc<Mutex<Vec<(String, Instant)>>>,
731 ends: Arc<Mutex<Vec<(String, Instant)>>>,
732 }
733
734 impl ToolExecutor for SlowService {
735 fn tool_specs(&self) -> Vec<ToolSpec> {
736 vec![ToolSpec {
737 name: self.name.clone(),
738 description: format!("slow test tool {}", self.name),
739 input_schema: json!({
740 "type": "object",
741 "properties": {},
742 "additionalProperties": false,
743 }),
744 }]
745 }
746
747 fn dispatch(
748 self: Arc<Self>,
749 tool_use: ToolUse,
750 _cancel: CancelToken,
751 _background: CancelToken,
752 ) -> Async<ToolResult> {
753 Box::pin(async move {
754 let started = Instant::now();
755 self.starts
756 .lock()
757 .unwrap()
758 .push((tool_use.name.clone(), started));
759 tokio::time::sleep(self.delay).await;
760 let ended = Instant::now();
761 self.ends
762 .lock()
763 .unwrap()
764 .push((tool_use.name.clone(), ended));
765 ToolResult::text(format!("done:{}", tool_use.name))
766 })
767 }
768 }
769
770 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
771 async fn concurrent_tool_uses_overlap_and_preserve_order() {
772 let starts = Arc::new(Mutex::new(Vec::new()));
773 let ends = Arc::new(Mutex::new(Vec::new()));
774 let delay = Duration::from_millis(300);
776
777 let slow_a = Arc::new(SlowService {
779 name: "slow_a".into(),
780 delay,
781 starts: starts.clone(),
782 ends: ends.clone(),
783 });
784 let slow_b = Arc::new(SlowService {
785 name: "slow_b".into(),
786 delay,
787 starts: starts.clone(),
788 ends: ends.clone(),
789 });
790
791 let tools = TestTools::new(vec![
792 slow_a as Arc<dyn ToolExecutor>,
793 slow_b as Arc<dyn ToolExecutor>,
794 ]);
795
796 let model = ScriptedModel::new(vec![
797 GenerateOutput {
798 content: vec![],
799 tool_uses: vec![
800 ToolUse {
801 name: "slow_a".into(),
802 input: json!({}),
803 },
804 ToolUse {
805 name: "slow_b".into(),
806 input: json!({}),
807 },
808 ],
809 turn_end_reason: TurnEndReason::ToolUse,
810 usage: None,
811 },
812 GenerateOutput {
813 content: vec![Content::Text {
814 text: "all done".into(),
815 }],
816 tool_uses: vec![],
817 turn_end_reason: TurnEndReason::EndTurn,
818 usage: None,
819 },
820 ]);
821
822 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
823 let wall_start = Instant::now();
824 let reply = interact(
825 &mut agent,
826 vec![Content::Text {
827 text: "run both".into(),
828 }],
829 crate::CancelToken::new(),
830 )
831 .await
832 .expect("interact");
833 let wall = wall_start.elapsed();
834
835 assert_eq!(reply.len(), 1);
837 match &reply[0] {
838 Content::Text { text } => assert_eq!(text, "all done"),
839 other => panic!("expected text reply, got {other:?}"),
840 }
841
842 let history = agent.history();
844 assert_eq!(history.len(), 4);
845 match &history[2] {
846 Message::ToolResults { tool_use_results } => {
847 assert_eq!(tool_use_results.len(), 2);
848 assert_eq!(result_text(&tool_use_results[0]), "done:slow_a");
850 assert_eq!(result_text(&tool_use_results[1]), "done:slow_b");
851 assert!(!tool_use_results[0].is_error);
852 assert!(!tool_use_results[1].is_error);
853 }
854 other => panic!("expected ToolResults, got {other:?}"),
855 }
856
857 let starts = starts.lock().unwrap().clone();
859 let ends = ends.lock().unwrap().clone();
860 assert_eq!(starts.len(), 2);
861 assert_eq!(ends.len(), 2);
862 let first_end = ends.iter().map(|(_, t)| *t).min().unwrap();
863 let last_start = starts.iter().map(|(_, t)| *t).max().unwrap();
864 assert!(
865 last_start < first_end,
866 "expected overlapping execution: last_start={last_start:?} first_end={first_end:?} starts={starts:?} ends={ends:?}"
867 );
868
869 assert!(
873 wall < delay * 6 + Duration::from_secs(1),
874 "expected concurrent wall time ~1 delay, got {wall:?} (delay={delay:?})"
875 );
876 }
877
878 #[tokio::test]
881 async fn checkpoints_include_tool_intent_and_completed_observations() {
882 let slow = Arc::new(SlowService {
883 name: "slow_a".into(),
884 delay: Duration::from_millis(1),
885 starts: Arc::new(Mutex::new(Vec::new())),
886 ends: Arc::new(Mutex::new(Vec::new())),
887 });
888 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
889 let model = ScriptedModel::new(vec![
890 GenerateOutput {
891 content: vec![],
892 tool_uses: vec![ToolUse {
893 name: "slow_a".into(),
894 input: json!({}),
895 }],
896 turn_end_reason: TurnEndReason::ToolUse,
897 usage: None,
898 },
899 GenerateOutput {
900 content: vec![Content::Text {
901 text: "done".into(),
902 }],
903 tool_uses: vec![],
904 turn_end_reason: TurnEndReason::EndTurn,
905 usage: None,
906 },
907 ]);
908 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
909 let snapshots: Arc<Mutex<Vec<AgentState>>> = Arc::new(Mutex::new(Vec::new()));
910 let record = snapshots.clone();
911 agent.set_checkpoint(Some(Box::new(move |state| {
912 validate_checkpoint(state.history(), state.pending_operation()).unwrap();
913 record.lock().unwrap().push(state.clone());
914 Ok(())
915 })));
916
917 interact(
918 &mut agent,
919 vec![Content::Text { text: "run".into() }],
920 crate::CancelToken::new(),
921 )
922 .await
923 .expect("interact");
924
925 let snapshots = snapshots.lock().unwrap();
926 let intent = snapshots
927 .iter()
928 .position(|state| {
929 matches!(
930 state.pending_operation(),
931 Some(PendingOperation::Tools { .. })
932 )
933 })
934 .unwrap();
935 let observed = snapshots
936 .iter()
937 .position(|state| matches!(state.history().last(), Some(Message::ToolResults { .. })))
938 .unwrap();
939 assert!(intent < observed);
940 assert!(matches!(
941 snapshots[observed].pending_operation(),
942 Some(PendingOperation::Generation { .. })
943 ));
944 assert_eq!(snapshots.first().unwrap().history().len(), 1);
945 let finished = snapshots.last().unwrap();
946 assert!(finished.pending_operation().is_none());
947 assert_eq!(finished.history().len(), 4);
948 }
949
950 #[tokio::test]
951 async fn failed_intent_checkpoint_prevents_tool_dispatch_and_another_generation() {
952 let starts = Arc::new(Mutex::new(Vec::new()));
953 let tool = Arc::new(SlowService {
954 name: "effect".into(),
955 delay: Duration::ZERO,
956 starts: starts.clone(),
957 ends: Arc::new(Mutex::new(Vec::new())),
958 });
959 let model = ScriptedModel::new(vec![
960 GenerateOutput {
961 content: vec![],
962 tool_uses: vec![ToolUse {
963 name: "effect".into(),
964 input: json!({}),
965 }],
966 turn_end_reason: TurnEndReason::ToolUse,
967 usage: None,
968 },
969 GenerateOutput {
970 content: vec![],
971 tool_uses: vec![],
972 turn_end_reason: TurnEndReason::EndTurn,
973 usage: None,
974 },
975 ]);
976 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
977 agent.set_checkpoint(Some(Box::new(|state| {
978 if matches!(
979 state.pending_operation(),
980 Some(PendingOperation::Tools { .. })
981 ) {
982 Err("disk full".into())
983 } else {
984 Ok(())
985 }
986 })));
987 let error = interact(
988 &mut agent,
989 vec![Content::Text {
990 text: "task".into(),
991 }],
992 CancelToken::new(),
993 )
994 .await
995 .unwrap_err();
996 assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
997 assert_eq!(error.recovery(), Recovery::Stop);
998 assert!(starts.lock().unwrap().is_empty());
999 assert!(matches!(
1000 agent.state().pending_operation(),
1001 Some(PendingOperation::Tools { .. })
1002 ));
1003 assert!(matches!(
1004 agent.run(CancelToken::new()).await,
1005 Err(AgentInteractionError::State(StateError::Busy))
1006 ));
1007 assert!(starts.lock().unwrap().is_empty());
1008 agent.set_checkpoint(None);
1009 agent.continue_run(CancelToken::new()).await.unwrap();
1010 assert_eq!(starts.lock().unwrap().len(), 1);
1011 }
1012
1013 #[tokio::test]
1014 async fn failed_result_checkpoint_retains_observations_and_stops_before_the_next_model_call() {
1015 let starts = Arc::new(Mutex::new(Vec::new()));
1016 let tool = Arc::new(SlowService {
1017 name: "effect".into(),
1018 delay: Duration::ZERO,
1019 starts: starts.clone(),
1020 ends: Arc::new(Mutex::new(Vec::new())),
1021 });
1022 let model = ScriptedModel::new(vec![
1023 GenerateOutput {
1024 content: vec![],
1025 tool_uses: vec![ToolUse {
1026 name: "effect".into(),
1027 input: json!({}),
1028 }],
1029 turn_end_reason: TurnEndReason::ToolUse,
1030 usage: None,
1031 },
1032 GenerateOutput {
1033 content: vec![],
1034 tool_uses: vec![],
1035 turn_end_reason: TurnEndReason::EndTurn,
1036 usage: None,
1037 },
1038 ]);
1039 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1040 agent.set_checkpoint(Some(Box::new(|state| {
1041 if matches!(state.history().last(), Some(Message::ToolResults { .. })) {
1042 Err("disk full".into())
1043 } else {
1044 Ok(())
1045 }
1046 })));
1047 let error = interact(
1048 &mut agent,
1049 vec![Content::Text {
1050 text: "task".into(),
1051 }],
1052 CancelToken::new(),
1053 )
1054 .await
1055 .unwrap_err();
1056 assert!(matches!(error, AgentInteractionError::Checkpoint(_)));
1057 assert_eq!(starts.lock().unwrap().len(), 1);
1058 assert!(
1059 matches!(agent.history().last(), Some(Message::ToolResults { tool_use_results }) if !tool_use_results[0].is_error)
1060 );
1061 assert!(matches!(
1062 agent.state().pending_operation(),
1063 Some(PendingOperation::Generation { .. })
1064 ));
1065 agent.set_checkpoint(None);
1066 agent.continue_run(CancelToken::new()).await.unwrap();
1067 assert_eq!(starts.lock().unwrap().len(), 1);
1068 }
1069
1070 #[tokio::test]
1071 async fn dropping_a_tool_step_requires_explicit_recovery_and_never_replays_it() {
1072 let starts = Arc::new(Mutex::new(Vec::new()));
1073 let tool = Arc::new(SlowService {
1074 name: "effect".into(),
1075 delay: Duration::from_secs(60),
1076 starts: starts.clone(),
1077 ends: Arc::new(Mutex::new(Vec::new())),
1078 });
1079 let model = ScriptedModel::new(vec![
1080 GenerateOutput {
1081 content: vec![],
1082 tool_uses: vec![ToolUse {
1083 name: "effect".into(),
1084 input: json!({}),
1085 }],
1086 turn_end_reason: TurnEndReason::ToolUse,
1087 usage: None,
1088 },
1089 GenerateOutput {
1090 content: vec![],
1091 tool_uses: vec![],
1092 turn_end_reason: TurnEndReason::EndTurn,
1093 usage: None,
1094 },
1095 ]);
1096 let mut agent = Agent::new(model, TestTools::new(vec![tool]), Arc::new(NullEventSink));
1097 agent
1098 .append_input(Message::UserMessage {
1099 content: vec![Content::Text {
1100 text: "task".into(),
1101 }],
1102 })
1103 .unwrap();
1104 agent.start_run().unwrap();
1105 assert!(agent.step(CancelToken::new()).await.unwrap().is_none());
1106 {
1107 let step = agent.step(CancelToken::new());
1108 futures::pin_mut!(step);
1109 assert!(futures::poll!(step).is_pending());
1110 }
1111 assert_eq!(starts.lock().unwrap().len(), 1);
1112 assert!(matches!(
1113 agent.continue_run(CancelToken::new()).await,
1114 Err(AgentInteractionError::State(StateError::Busy))
1115 ));
1116 agent.recover_interrupted().unwrap();
1117 assert!(agent.history().iter().any(|message| matches!(message, Message::ToolResults { tool_use_results } if tool_use_results[0].is_error)));
1118 agent.run(CancelToken::new()).await.unwrap();
1119 assert_eq!(starts.lock().unwrap().len(), 1);
1120 }
1121
1122 #[tokio::test]
1123 async fn last_usage_sums_output_across_tool_round_trips() {
1124 let tool = Arc::new(SlowService {
1125 name: "fast".into(),
1126 delay: Duration::ZERO,
1127 starts: Arc::new(Mutex::new(Vec::new())),
1128 ends: Arc::new(Mutex::new(Vec::new())),
1129 });
1130 let tools = TestTools::new(vec![tool as Arc<dyn ToolExecutor>]);
1131
1132 let model = ScriptedModel::new(vec![
1133 GenerateOutput {
1134 content: vec![],
1135 tool_uses: vec![ToolUse {
1136 name: "fast".into(),
1137 input: json!({}),
1138 }],
1139 turn_end_reason: TurnEndReason::ToolUse,
1140 usage: Some(TokenUsage {
1141 input_tokens: 1_000,
1142 output_tokens: 500,
1143 cached_input_tokens: 800,
1144 }),
1145 },
1146 GenerateOutput {
1147 content: vec![Content::Text {
1148 text: "done".into(),
1149 }],
1150 tool_uses: vec![],
1151 turn_end_reason: TurnEndReason::EndTurn,
1152 usage: Some(TokenUsage {
1153 input_tokens: 1_600,
1154 output_tokens: 20,
1155 cached_input_tokens: 900,
1156 }),
1157 },
1158 ]);
1159
1160 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1161 interact(
1162 &mut agent,
1163 vec![Content::Text { text: "go".into() }],
1164 crate::CancelToken::new(),
1165 )
1166 .await
1167 .expect("interact");
1168
1169 let usage = agent.last_usage().expect("usage recorded");
1171 assert_eq!(usage.input_tokens, 1_600);
1172 assert_eq!(usage.cached_input_tokens, 900);
1173 assert_eq!(usage.output_tokens, 520);
1174 assert_eq!(usage.context_tokens(), 1_600);
1175 }
1176
1177 struct SlowStreamModel {
1179 delay: Duration,
1180 chunks: usize,
1181 }
1182
1183 impl GenerativeModel for SlowStreamModel {
1184 fn generate(&self, _input: &[Message]) -> myco_model::AsyncStream<GenerationEvent> {
1185 let delay = self.delay;
1186 let chunks = self.chunks;
1187 Box::pin(stream::unfold(0usize, move |step| {
1190 let delay = delay;
1191 async move {
1192 let last = chunks + 2;
1193 if step > last {
1194 return None;
1195 }
1196 let part = if step == 0 {
1197 MessagePart::MessageStart
1198 } else if step == 1 {
1199 MessagePart::ContentStart(generative_model::ContentStart::Text { index: 0 })
1200 } else if step <= chunks + 1 {
1201 tokio::time::sleep(delay).await;
1202 MessagePart::ContentDelta(ContentDelta::Text {
1203 index: 0,
1204 delta: format!("chunk{}", step - 2),
1205 })
1206 } else {
1207 MessagePart::TurnEndReason(TurnEndReason::EndTurn)
1208 };
1209 Some((GenerationEvent::Part(part), step + 1))
1210 }
1211 }))
1212 }
1213 }
1214
1215 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1216 async fn cancel_during_generate_returns_cancelled() {
1217 let tools = TestTools::new(vec![]);
1218 let model = Arc::new(SlowStreamModel {
1219 delay: Duration::from_millis(200),
1220 chunks: 20,
1221 });
1222 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1223 let cancel = crate::CancelToken::new();
1224 let cancel2 = cancel.clone();
1225 tokio::spawn(async move {
1226 tokio::time::sleep(Duration::from_millis(50)).await;
1227 cancel2.cancel();
1228 });
1229
1230 let t0 = Instant::now();
1231 let err = interact(
1232 &mut agent,
1233 vec![Content::Text { text: "go".into() }],
1234 cancel,
1235 )
1236 .await
1237 .expect_err("should cancel");
1238 let elapsed = t0.elapsed();
1239 assert!(
1240 matches!(err, AgentInteractionError::Cancelled),
1241 "got {err:?}"
1242 );
1243 assert!(
1244 elapsed < Duration::from_secs(2),
1246 "cancel should be prompt, took {elapsed:?}"
1247 );
1248 assert_eq!(agent.history().len(), 1);
1250 assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1251 }
1252
1253 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1254 async fn cancel_during_slow_tool_records_cancelled_result() {
1255 let starts = Arc::new(Mutex::new(Vec::new()));
1256 let ends = Arc::new(Mutex::new(Vec::new()));
1257 let slow = Arc::new(SlowService {
1258 name: "slow_a".into(),
1259 delay: Duration::from_secs(5),
1261 starts: starts.clone(),
1262 ends: ends.clone(),
1263 });
1264 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1265 let model = ScriptedModel::new(vec![GenerateOutput {
1266 content: vec![],
1267 tool_uses: vec![ToolUse {
1268 name: "slow_a".into(),
1269 input: json!({}),
1270 }],
1271 turn_end_reason: TurnEndReason::ToolUse,
1272 usage: None,
1273 }]);
1274 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1276 let cancel = crate::CancelToken::new();
1277 let cancel2 = cancel.clone();
1278 let starts_bg = starts.clone();
1279 tokio::spawn(async move {
1280 let deadline = Instant::now() + Duration::from_secs(2);
1282 loop {
1283 if !starts_bg.lock().unwrap().is_empty() {
1284 break;
1285 }
1286 if Instant::now() > deadline {
1287 break;
1288 }
1289 tokio::time::sleep(Duration::from_millis(5)).await;
1290 }
1291 cancel2.cancel();
1292 });
1293
1294 let t0 = Instant::now();
1295 let err = interact(
1296 &mut agent,
1297 vec![Content::Text { text: "run".into() }],
1298 cancel,
1299 )
1300 .await
1301 .expect_err("should cancel");
1302 let elapsed = t0.elapsed();
1303 assert!(matches!(err, AgentInteractionError::Cancelled));
1304 assert!(
1307 elapsed < Duration::from_secs(4),
1308 "should wait only the cancel grace, not the full tool delay, took {elapsed:?}"
1309 );
1310
1311 let history = agent.history();
1313 assert_eq!(history.len(), 3);
1314 match &history[2] {
1315 Message::ToolResults { tool_use_results } => {
1316 assert_eq!(tool_use_results.len(), 1);
1317 assert!(tool_use_results[0].is_error);
1318 let text = result_text(&tool_use_results[0]);
1319 assert!(text.contains("effects are unknown"), "{text}");
1320 }
1321 other => panic!("expected ToolResults, got {other:?}"),
1322 }
1323 }
1324
1325 #[tokio::test]
1326 async fn generate_error_after_tool_results_keeps_well_formed_history() {
1327 let slow = Arc::new(SlowService {
1328 name: "slow_a".into(),
1329 delay: Duration::from_millis(1),
1330 starts: Arc::new(Mutex::new(Vec::new())),
1331 ends: Arc::new(Mutex::new(Vec::new())),
1332 });
1333 let tools = TestTools::new(vec![slow as Arc<dyn ToolExecutor>]);
1334 let model = ScriptedModel::new(vec![GenerateOutput {
1335 content: vec![],
1336 tool_uses: vec![ToolUse {
1337 name: "slow_a".into(),
1338 input: json!({}),
1339 }],
1340 turn_end_reason: TurnEndReason::ToolUse,
1341 usage: None,
1342 }])
1343 .then_fail(GenerateError::ExecutionError(
1344 "provider 500 after tools".into(),
1345 ));
1346 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1347 let err = interact(
1348 &mut agent,
1349 vec![Content::Text {
1350 text: "run tool then fail".into(),
1351 }],
1352 crate::CancelToken::new(),
1353 )
1354 .await
1355 .expect_err("second generate should fail");
1356 assert!(
1357 matches!(err, AgentInteractionError::GenerateError(_)),
1358 "got {err:?}"
1359 );
1360
1361 let history = agent.history();
1363 assert_eq!(history.len(), 3, "history={history:?}");
1364 assert!(matches!(history[0], Message::UserMessage { .. }));
1365 match &history[1] {
1366 Message::AssistantMessage {
1367 tool_uses,
1368 turn_end_reason,
1369 ..
1370 } => {
1371 assert_eq!(tool_uses.len(), 1);
1372 assert_eq!(tool_uses[0].name, "slow_a");
1373 assert_eq!(*turn_end_reason, Some(TurnEndReason::ToolUse));
1374 }
1375 other => panic!("expected assistant tool_use, got {other:?}"),
1376 }
1377 match &history[2] {
1378 Message::ToolResults { tool_use_results } => {
1379 assert_eq!(tool_use_results.len(), 1);
1380 assert!(!tool_use_results[0].is_error);
1381 }
1382 other => panic!("expected ToolResults, got {other:?}"),
1383 }
1384 }
1385
1386 #[tokio::test]
1387 async fn generate_error_before_assistant_keeps_only_user() {
1388 let tools = TestTools::new(vec![]);
1389 let model = ScriptedModel::new(vec![]).then_fail(GenerateError::ExecutionError(
1390 "boom on first generate".into(),
1391 ));
1392 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1393 let err = interact(
1394 &mut agent,
1395 vec![Content::Text { text: "hi".into() }],
1396 crate::CancelToken::new(),
1397 )
1398 .await
1399 .expect_err("generate should fail");
1400 assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1401 assert_eq!(agent.history().len(), 1);
1402 assert!(matches!(agent.history()[0], Message::UserMessage { .. }));
1403 }
1404
1405 #[tokio::test]
1408 async fn tool_use_stop_with_zero_tool_uses_errors_not_loops() {
1409 let tools = TestTools::new(vec![]);
1410 let model = ScriptedModel::new(vec![GenerateOutput {
1411 content: vec![Content::Text { text: "hmm".into() }],
1412 tool_uses: vec![],
1413 turn_end_reason: TurnEndReason::ToolUse,
1414 usage: None,
1415 }]);
1416 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1417 let err = interact(
1418 &mut agent,
1419 vec![Content::Text { text: "hi".into() }],
1420 crate::CancelToken::new(),
1421 )
1422 .await
1423 .expect_err("malformed turn should error");
1424 assert!(matches!(err, AgentInteractionError::GenerateError(_)));
1425 assert_eq!(agent.history().len(), 2);
1427 assert!(matches!(
1428 agent.history()[1],
1429 Message::AssistantMessage { .. }
1430 ));
1431 }
1432
1433 #[tokio::test]
1436 async fn max_tokens_mid_tool_call_answers_the_dangling_tool_use_and_resumes() {
1437 let tools = TestTools::new(vec![]);
1438 let model = ScriptedModel::new(vec![
1439 GenerateOutput {
1440 content: vec![Content::Text {
1441 text: "let me check".into(),
1442 }],
1443 tool_uses: vec![ToolUse {
1444 name: "unavailable".into(),
1445 input: serde_json::json!({}),
1446 }],
1447 turn_end_reason: TurnEndReason::MaxTokens,
1448 usage: None,
1449 },
1450 GenerateOutput {
1451 content: vec![Content::Text { text: "ok".into() }],
1452 tool_uses: vec![],
1453 turn_end_reason: TurnEndReason::EndTurn,
1454 usage: None,
1455 },
1456 ]);
1457 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1458 let reply = interact(
1459 &mut agent,
1460 vec![Content::Text { text: "hi".into() }],
1461 crate::CancelToken::new(),
1462 )
1463 .await
1464 .expect("turn should resume through the truncation, not error");
1465
1466 assert!(matches!(&reply[0], Content::Text { text } if text == "ok"));
1469
1470 assert_eq!(agent.history().len(), 4);
1473 match &agent.history()[2] {
1474 Message::ToolResults { tool_use_results } => {
1475 assert_eq!(tool_use_results.len(), 1);
1476 assert!(tool_use_results[0].is_error);
1477 let text = result_text(&tool_use_results[0]);
1478 assert!(text.contains("unknown tool 'unavailable'"), "text={text}");
1479 }
1480 other => panic!("expected ToolResults, got {other:?}"),
1481 }
1482 }
1483
1484 #[tokio::test]
1489 async fn max_tokens_without_tool_calls_resumes_with_a_continue_turn() {
1490 let tools = TestTools::new(vec![]);
1491 let model = ScriptedModel::new(vec![
1492 GenerateOutput {
1493 content: vec![Content::Text {
1494 text: "half a sen".into(),
1495 }],
1496 tool_uses: vec![],
1497 turn_end_reason: TurnEndReason::MaxTokens,
1498 usage: None,
1499 },
1500 GenerateOutput {
1501 content: vec![Content::Text {
1502 text: "tence.".into(),
1503 }],
1504 tool_uses: vec![],
1505 turn_end_reason: TurnEndReason::EndTurn,
1506 usage: None,
1507 },
1508 ]);
1509 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1510 let reply = interact(
1511 &mut agent,
1512 vec![Content::Text { text: "hi".into() }],
1513 crate::CancelToken::new(),
1514 )
1515 .await
1516 .expect("turn should resume through the truncation");
1517
1518 assert!(matches!(&reply[0], Content::Text { text } if text == "tence."));
1519
1520 assert_eq!(agent.history().len(), 4);
1522 match &agent.history()[2] {
1523 Message::UserMessage { content } => match content.as_slice() {
1524 [Content::System { text, kind, .. }] => {
1525 assert_eq!(text, CONTINUE_PROMPT);
1526 assert_eq!(kind, "continuation");
1527 }
1528 other => panic!("expected one text block, got {other:?}"),
1529 },
1530 other => panic!("expected the continuation user turn, got {other:?}"),
1531 }
1532 }
1533
1534 #[tokio::test]
1538 async fn consecutive_max_tokens_resumes_are_bounded() {
1539 const CAP: u32 = 2;
1540 let tools = TestTools::new(vec![]);
1541 let truncated_with_tool_call = || GenerateOutput {
1542 content: vec![Content::Text {
1543 text: "still going".into(),
1544 }],
1545 tool_uses: vec![ToolUse {
1546 name: "bash".into(),
1547 input: serde_json::json!({}),
1548 }],
1549 turn_end_reason: TurnEndReason::MaxTokens,
1550 usage: None,
1551 };
1552 let scripts = (0..CAP + 2).map(|_| truncated_with_tool_call()).collect();
1555 let model = ScriptedModel::new(scripts);
1556 let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1557 agent.set_max_truncated_resumes(CAP);
1558 interact(
1559 &mut agent,
1560 vec![Content::Text { text: "hi".into() }],
1561 crate::CancelToken::new(),
1562 )
1563 .await
1564 .expect("turn should hand back once the cap is hit, not error");
1565
1566 assert_eq!(model.remaining() as u32, 1);
1568 }
1569
1570 #[tokio::test]
1573 async fn zero_max_truncated_resumes_hands_back_the_partial_answer() {
1574 let tools = TestTools::new(vec![]);
1575 let model = ScriptedModel::new(vec![
1576 GenerateOutput {
1577 content: vec![Content::Text {
1578 text: "half a sen".into(),
1579 }],
1580 tool_uses: vec![],
1581 turn_end_reason: TurnEndReason::MaxTokens,
1582 usage: None,
1583 },
1584 GenerateOutput {
1585 content: vec![Content::Text {
1586 text: "unused".into(),
1587 }],
1588 tool_uses: vec![],
1589 turn_end_reason: TurnEndReason::EndTurn,
1590 usage: None,
1591 },
1592 ]);
1593 let mut agent = Agent::new(model.clone(), tools, Arc::new(NullEventSink));
1594 agent.set_max_truncated_resumes(0);
1595 let reply = interact(
1596 &mut agent,
1597 vec![Content::Text { text: "hi".into() }],
1598 crate::CancelToken::new(),
1599 )
1600 .await
1601 .expect("turn should hand back the partial answer");
1602
1603 assert!(matches!(&reply[0], Content::Text { text } if text == "half a sen"));
1604 assert_eq!(agent.history().len(), 2);
1606 assert_eq!(model.remaining(), 1, "second script must stay unconsumed");
1607 }
1608
1609 #[tokio::test]
1611 async fn plain_end_turn_pushes_no_tool_results() {
1612 let tools = TestTools::new(vec![]);
1613 let model = ScriptedModel::new(vec![GenerateOutput {
1614 content: vec![Content::Text { text: "hi".into() }],
1615 tool_uses: vec![],
1616 turn_end_reason: TurnEndReason::EndTurn,
1617 usage: None,
1618 }]);
1619 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1620 interact(
1621 &mut agent,
1622 vec![Content::Text { text: "hi".into() }],
1623 crate::CancelToken::new(),
1624 )
1625 .await
1626 .expect("turn should succeed");
1627 assert_eq!(agent.history().len(), 2);
1628 }
1629
1630 #[tokio::test]
1635 async fn oversized_request_reports_omit_last_message() {
1636 let tools = TestTools::new(vec![]);
1637 let model = ScriptedModel::new(vec![])
1638 .then_fail(GenerateError::RequestTooLargeError("42 MiB".into()));
1639 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1640 let err = interact(
1641 &mut agent,
1642 vec![Content::Image {
1643 source: "data:image/png;base64,AAAA".into(),
1644 }],
1645 crate::CancelToken::new(),
1646 )
1647 .await
1648 .expect_err("oversized request should fail");
1649
1650 assert_eq!(err.recovery(), Recovery::OmitLastMessage);
1651 }
1652
1653 #[test]
1656 fn cancelled_interaction_is_retryable() {
1657 assert_eq!(AgentInteractionError::Cancelled.recovery(), Recovery::Retry);
1658 }
1659
1660 #[tokio::test]
1664 async fn truncating_context_drops_the_whole_turn_and_keeps_earlier_ones() {
1665 let tools = TestTools::new(vec![]);
1666 let model = ScriptedModel::new(vec![]);
1667 let mut agent = Agent::new(model, tools, Arc::new(NullEventSink));
1668 agent
1669 .replace_context(
1670 vec![
1671 user("first"),
1672 assistant("ok"),
1673 user("second"),
1674 assistant_tool(None, "noop", json!({})),
1675 tool_results(&["done"]),
1676 ],
1677 None,
1678 )
1679 .unwrap();
1680
1681 let dropped = agent.truncate_history(2).unwrap();
1682 assert!(
1683 matches!(&dropped[0], Message::UserMessage { content } if matches!(&content[0], Content::Text { text } if text == "second"))
1684 );
1685
1686 let history = agent.history();
1687 assert_eq!(history.len(), 2);
1688 assert!(matches!(history[0], Message::UserMessage { .. }));
1689 assert!(matches!(history[1], Message::AssistantMessage { .. }));
1690 }
1691
1692 #[tokio::test]
1694 async fn resume_after_tools_mid_turn_continues_cleanly() {
1695 let snapshot = vec![
1700 user("mid turn"),
1701 assistant_tool(None, "slow_a", json!({})),
1702 tool_results(&["done:slow_a"]),
1703 ];
1704
1705 let tools = TestTools::new(vec![]);
1707 let resume_model = ScriptedModel::new(vec![GenerateOutput {
1708 content: vec![Content::Text {
1709 text: "recovered".into(),
1710 }],
1711 tool_uses: vec![],
1712 turn_end_reason: TurnEndReason::EndTurn,
1713 usage: None,
1714 }]);
1715 let mut resumed = Agent::new(resume_model, tools, Arc::new(NullEventSink));
1716 resumed.replace_context(snapshot, None).unwrap();
1717
1718 let reply = interact(
1723 &mut resumed,
1724 vec![Content::Text {
1725 text: "continue".into(),
1726 }],
1727 crate::CancelToken::new(),
1728 )
1729 .await
1730 .expect("resume interact");
1731 assert_eq!(reply.len(), 1);
1732 match &reply[0] {
1733 Content::Text { text } => assert_eq!(text, "recovered"),
1734 other => panic!("expected text, got {other:?}"),
1735 }
1736
1737 let history = resumed.history();
1738 assert_eq!(history.len(), 5);
1740 assert!(matches!(history[0], Message::UserMessage { .. }));
1741 assert!(matches!(history[1], Message::AssistantMessage { .. }));
1742 assert!(matches!(history[2], Message::ToolResults { .. }));
1743 assert!(matches!(history[3], Message::UserMessage { .. }));
1744 assert!(matches!(history[4], Message::AssistantMessage { .. }));
1745 }
1746}