Skip to main content

myco_agent/
lib.rs

1//! Drive model context through generation and tool calls at replayable boundaries.
2//! Callers supply tool execution, event sinks, context, and persistence callbacks.
3//!
4//! # Embedding
5//!
6//! Construct [`Agent`] with a [`GenerativeModel`], [`ToolExecutor`], and [`EventSink`].
7//! Supply input with [`Agent::append_input`] or install history with
8//! [`Agent::replace_context`], then await [`Agent::run`]. The crate does not read
9//! Myco configuration or create sessions, prompts, hosts, or terminal output.
10//!
11//! # Ownership and persistence
12//!
13//! The executor owns live resources independently of agent history. Calls within
14//! a round run concurrently; results are recorded in call order. Checkpoints
15//! persist pending effects and their settled observations. Pending tool batches
16//! are durable intent, not replayable model context; recover them before reuse.
17//!
18//! Cancel a clone of the run's [`CancelToken`] and await the run future to let
19//! cleanup finish. Aborting the task bypasses that cooperative completion path.
20//! [`EventSink`] provides live observations, not a complete durable event log.
21//!
22//! See the [agent guide](https://tsnl.github.io/myco/developers/agents.html) and
23//! run `cargo run -p myco-agent --example headless` for an offline model/tool round.
24
25use 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
47/// Capabilities available to this execution. Ownership and routing belong to the caller.
48pub trait ToolExecutor: Send + Sync {
49    /// Schemas to pass into the model's configuration. Changing the executor
50    /// does not update schemas on a model that was already constructed.
51    fn tool_specs(&self) -> Vec<generative_model::ToolSpec>;
52    /// Execute a call, validating its input and returning failures as tool results.
53    /// Calls in a round may overlap. Observe cancellation and clean up owned work.
54    /// `background` requests an early result while preserving ongoing work,
55    /// when the tool supports it. It must never be treated as cancellation.
56    fn dispatch(
57        self: Arc<Self>,
58        tool: ToolUse,
59        cancel: CancelToken,
60        background: CancelToken,
61    ) -> Async<ToolResult>;
62}
63
64//
65// Event sink — live observability for agent / tool activity
66//
67
68/// Event attribution for frontends and evaluators. Session and thread are
69/// present when bound; depth lets displays distinguish nested workers.
70#[derive(Debug, Clone)]
71pub struct TraceContext {
72    /// Stable id for this agent instance (root or subagent).
73    pub agent_id: Uuid,
74    /// Nesting depth: root agent is 0; each nested agent is parent depth + 1.
75    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/// Live events emitted by the agent runtime.
103///
104/// All ongoing work is attributed via [`TraceContext::agent_id`].
105#[derive(Debug, Clone)]
106pub enum AgentEvent {
107    /// Begin a provisional response. A retry replaces the failed attempt's draft.
108    GenerationStarted {
109        context: TraceContext,
110    },
111    /// The complete response validated. Its tool calls have not executed yet.
112    GenerationFinished {
113        context: TraceContext,
114    },
115    /// A validated response was installed in history and its configured
116    /// checkpoint succeeded. Its tool calls have not executed yet. Emitted
117    /// only when a checkpoint callback is installed, including when a later
118    /// checkpoint repairs an earlier save failure.
119    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        /// Remaining elapsed recovery budget, if configured and recovery started.
128        recovery_remaining: Option<std::time::Duration>,
129        context: TraceContext,
130    },
131    /// Incremental assistant text (for streaming UX).
132    TextDelta {
133        text: String,
134        context: TraceContext,
135    },
136    /// Incremental thinking *summary* text (streamed for UI; also stored in history).
137    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
158/// Consumer of [`AgentEvent`]s (CLI, TUI, metrics, …).
159pub trait EventSink: Send + Sync {
160    /// Observe an event synchronously. Avoid blocking the execution task.
161    /// `GenerationFinished` validates a draft; `GenerationCommitted` also confirms
162    /// its history checkpoint. On a retrying failure, discard or clearly separate
163    /// that draft before displaying the next attempt.
164    fn emit(&self, event: AgentEvent);
165}
166
167/// No-op sink for tests and headless runs.
168#[derive(Debug, Default)]
169pub struct NullEventSink;
170
171impl EventSink for NullEventSink {
172    fn emit(&self, _event: AgentEvent) {}
173}
174
175//
176// Agent
177//
178
179/// Persist context and pending intent before effects begin, and completed
180/// observations before the next effect. Failure stops execution. A pending tool
181/// batch is a durable observation, not a valid model input or context fork.
182pub type Checkpoint = Box<dyn Fn(&AgentState) -> Result<(), String> + Send + Sync>;
183
184/// Supply a pending runtime notice before a generation step. The returned text
185/// is appended as internal system content to the latest user input or tool result
186/// and checkpointed before generation; this callback does not reload the system
187/// prompt. Retries reuse the same input. Consume the notice only when the future
188/// completes, since cancellation can drop the future.
189pub type BeforeGenerationNotice =
190    Box<dyn Fn(&TraceContext, &[Message]) -> Async<Option<String>> + Send + Sync>;
191
192/// How long a cancelled tool dispatch may keep running to do its own
193/// cleanup (process-group kill, buffer drain) before the agent abandons it
194/// and records an unknown outcome.
195const CANCEL_TOOL_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
196
197/// The user turn sent to resume a reply that `max_tokens` cut off mid-text.
198///
199/// A plain "Continue" invites the model to acknowledge the instruction or start
200/// the thought over; naming the requirement keeps the seam invisible in the
201/// finished answer.
202pub 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
205/// The user turn sent after `max_tokens` cut off a reply that held only thinking.
206///
207/// Providers receive no thinking from history, so such a turn reaches the next
208/// request as nothing: "continue where you stopped" makes the model redo the
209/// same reasoning until it hits the same cap. Name the loss and ask for visible
210/// progress early so the next truncation keeps something.
211pub 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
216/// Whether `message` is a `max_tokens` stop that produced no answer text and no
217/// tool call — its only output was thinking, which providers never receive back.
218pub 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/// A run ending at an output cap is distinct from a completed model turn.
227#[derive(Debug, Clone)]
228pub struct RunOutcome {
229    pub answer: Vec<Content>,
230    pub reason: TurnEndReason,
231    pub usage: Option<TokenUsage>,
232}
233
234/// A headless model/tool loop over caller-supplied context and capabilities.
235///
236/// Resource lifetime belongs to [`ToolExecutor`]; persistence belongs to the
237/// caller. The context-window setting is informational and does not enforce a
238/// token limit or perform automatic compaction.
239pub 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 for the active model (tokens).
247    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    /// A failed save must not discard the validated response's commit notification.
254    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    /// Replace dispatch capabilities without rebuilding the model's tool catalog.
290    /// Keep both in sync when adding or removing advertised tools.
291    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    /// Install the durable effect checkpoint (see [`Checkpoint`]).
300    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    /// Retry persistence without advancing or replaying an effect.
333    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    /// Replace model context and its usage estimate without changing live tool state.
342    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    /// Swap the generative model (e.g. mid-session `/effort` rebuild). History is kept.
361    pub fn set_model(&mut self, model: Arc<dyn GenerativeModel>) {
362        self.model = model;
363    }
364
365    /// Append input at a well-formed context boundary and checkpoint it.
366    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    /// Set the model context budget available to callers.
388    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    /// Set how many consecutive `max_tokens` truncations one turn resumes
397    /// through (the active model's `max_truncated_resumes`; `0` never resumes).
398    pub fn set_max_truncated_resumes(&mut self, resumes: u32) {
399        self.state.set_max_truncated_resumes(resumes);
400    }
401
402    /// Last observed prompt/context token usage (from the provider), if any.
403    pub fn last_usage(&self) -> Option<TokenUsage> {
404        self.state.last_usage()
405    }
406
407    /// Approximate current text/tool prompt size, including growth since the
408    /// last measured input. This is not provider-reported usage or a fit guarantee.
409    pub fn context_tokens_estimate(&self) -> u64 {
410        self.state.context_tokens_estimate()
411    }
412
413    /// Preserve an older model's sizing hint when its usage cannot be restored.
414    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    /// Drive the existing model context to completion. The caller supplies input separately.
423    /// The supplied tool executor determines the lifetime of live resources.
424    ///
425    /// Returns answer content from the final generation; intervening responses
426    /// and tool rounds remain in [`Self::history`]. Emits [`AgentEvent::TurnFinished`]
427    /// on success, error, or cooperative cancellation. Checkpoints persist both
428    /// pending effects and the final settled state.
429    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    /// Drive context and retain the stop reason and measured usage for evaluators.
439    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    /// Continue after a checkpoint failure without repeating completed effects.
456    /// A dropped in-flight effect requires explicit `recover_interrupted` first.
457    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    /// Execute one generation or tool batch and checkpoint its outcome. `None`
490    /// yields to the caller at the next effect, allowing compaction or inspection.
491    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    /// Execute one effect with at most one provider attempt. A caller that owns
499    /// recovery timing can retry failed generation without nesting backoff loops.
500    /// Tool dispatch, cancellation, and checkpoint boundaries match [`Self::step`].
501    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    /// Append and checkpoint dynamic context before a caller sizes the next
590    /// request. Idempotent for this operation; context replacement invalidates it.
591    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        // Race cancel vs tool — but on cancel, give the dispatch a short grace
654        // window instead of dropping it immediately. Cancel-aware tools use it
655        // to run their own cleanup and return an honest partial result: bash
656        // kills the exec's whole process group (kill_on_drop alone SIGKILLs
657        // only the leader, orphaning grandchildren), drains its capture tasks,
658        // and a mid-write bash session gets its taken ChildStdin back instead
659        // of having it dropped (which would close the session's stdin for
660        // good). Tools that ignore cancel are abandoned when the grace
661        // expires; for subprocess hosts that only abandons this waiter —
662        // the pipe demuxes by correlation id, so siblings are unaffected.
663        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    /// In-flight turn aborted (e.g. Ctrl-C). History is left well-formed when tools
689    /// had already started (unacknowledged cancellations record unknown effects).
690    #[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    /// Whether the failed turn can be resubmitted as-is, or the last user
713    /// message has to be rewound out of history first
714    /// by the caller before starting another run.
715    pub fn recovery(&self) -> Recovery {
716        match self {
717            AgentInteractionError::GenerateError(e) => e.recovery(),
718            // History is well-formed after a cancel; the same turn can be re-sent.
719            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    /// Sleeps, records start/end instants, returns the configured label.
958    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        // Long enough that serial execution is unambiguous even under CI load.
1006        let delay = Duration::from_millis(300);
1007
1008        // Two distinct tool names so the test executor can route both (same service type).
1009        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        // Reply is the final assistant text.
1069        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        // History: user, assistant(tool_use), tool_results, assistant(end).
1076        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                // Order matches the original tool_uses list, not completion order.
1082                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        // Both tools started before either finished → concurrent.
1091        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        // Overlap of starts/ends is the real concurrency signal. Wall clock is
1103        // only a coarse guard against fully serial execution; allow large slack
1104        // for CI / parallel suite load (scheduler jitter, other tests).
1105        assert!(
1106            wall < delay * 6 + Duration::from_secs(1),
1107            "expected concurrent wall time ~1 delay, got {wall:?} (delay={delay:?})"
1108        );
1109    }
1110
1111    /// Tool intent must be durable before dispatch, and its result durable
1112    /// before the following generation.
1113    #[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        // Input side tracks the latest request (the live context); output sums the turn.
1408        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    /// Slow generate stream: cancel mid-stream must return Cancelled quickly.
1416    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            // State machine: 0 = MessageStart, 1 = ContentStart, 2..chunks+1 = delayed
1426            // deltas, last = TurnEndReason.
1427            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            // Prompt under light load; allow CI / suite contention headroom.
1483            elapsed < Duration::from_secs(2),
1484            "cancel should be prompt, took {elapsed:?}"
1485        );
1486        // User message kept; no incomplete assistant pushed.
1487        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            // Long enough that a delayed cancel still hits mid-tool under load.
1498            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        // No EndTurn scripted — cancel during tools must stop without another generate.
1514        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            // Cancel only after the tool has started (not a fixed sleep race).
1520            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        // A cancel-ignoring tool is abandoned after CANCEL_TOOL_GRACE — the
1544        // turn must end well before the tool's own 5s delay.
1545        assert!(
1546            elapsed < Duration::from_secs(4),
1547            "should wait only the cancel grace, not the full tool delay, took {elapsed:?}"
1548        );
1549
1550        // user + assistant(tool_use) + tool_results (cancelled)
1551        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        // user + assistant(tool_use) + tool_results — no incomplete assistant.
1602        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    /// A tool_use stop with zero streamed tool uses is an invalid draft.
1646    #[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    /// A truncated call still needs a result before generation can continue,
1673    /// including when the executor rejects it.
1674    #[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        // The truncation is resumed inside the same turn, so the caller gets the
1707        // continuation rather than the partial answer that preceded the tool call.
1708        assert!(matches!(&reply[0], Content::Text { text } if text == "ok"));
1709
1710        // user + assistant(tool_use) + tool_results + assistant — the tool_use is
1711        // answered, and the resumed generate appended its reply to the same turn.
1712        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    /// A `max_tokens` stop with no tool calls leaves history on the assistant's
1725    /// cut-off message, which providers reject as a prefill. The turn resumes by
1726    /// asking for the rest in a user turn — the one continuation every provider
1727    /// accepts — so a truncated sentence finishes instead of dead-ending.
1728    #[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        // user + assistant(truncated) + user(continue) + assistant(rest).
1761        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    /// A model whose output cap is too low truncates every turn. Resuming is
1775    /// capped so an unattended run stops instead of spending the night
1776    /// re-truncating; the turn still ends cleanly rather than erroring.
1777    #[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        // One more script than the cap can consume, so the surplus proves the
1794        // loop stopped on the cap rather than on an exhausted script list.
1795        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        // The initial generate plus CAP resumes.
1808        assert_eq!(model.remaining() as u32, 1);
1809    }
1810
1811    /// `max_truncated_resumes = 0` is the opt-out: the turn hands back the
1812    /// partial answer, exactly as it did before resuming existed.
1813    #[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        // user + assistant only: no continuation turn, no second generate.
1846        assert_eq!(agent.history().len(), 2);
1847        assert_eq!(model.remaining(), 1, "second script must stay unconsumed");
1848    }
1849
1850    /// A turn that ends cleanly with no tool calls gains no ToolResults message.
1851    #[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    /// An oversized request is a property of the history, so the top-level
1872    /// error must say the last message has to come out — not "try again".
1873    /// (The rewind contract itself is proven by
1874    /// `rewind_drops_the_whole_turn_and_keeps_earlier_ones`.)
1875    #[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    /// History is well-formed after a cancel, so the same turn can be re-sent.
1895    /// (Generate-error recovery mapping is proven in `generative_model` tests.)
1896    #[test]
1897    fn cancelled_interaction_is_retryable() {
1898        assert_eq!(AgentInteractionError::Cancelled.recovery(), Recovery::Retry);
1899    }
1900
1901    /// Rewinding mid-turn drops the assistant/tool messages that followed the
1902    /// user message too — the remaining prefix must end where the *previous*
1903    /// turn ended, or the next request is malformed.
1904    #[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    /// Simulate crash after tools: persist history, new agent + model resumes and ends turn.
1934    #[tokio::test]
1935    async fn resume_after_tools_mid_turn_continues_cleanly() {
1936        // The well-formed mid-turn snapshot a checkpoint would have persisted
1937        // before the crash: user + assistant(tool_use) + matching tool_results
1938        // (the shape `generate_error_after_tool_results_keeps_well_formed_history`
1939        // proves the agent leaves behind).
1940        let snapshot = vec![
1941            user("mid turn"),
1942            assistant_tool(None, "slow_a", json!({})),
1943            tool_results(&["done:slow_a"]),
1944        ];
1945
1946        // "Resume": new agent, same well-formed history, model only needs EndTurn.
1947        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        // Continue by interacting with a follow-up user message (CLI would re-prompt);
1960        // history already has tool_results so a fresh user turn is the normal path.
1961        // Also verify the restored context is well-formed for provider requests by
1962        // checking the model can complete a new turn on top.
1963        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        // prior 3 + new user + new assistant
1980        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}