Skip to main content

runifold_agent/agent/
execution.rs

1//! Canonical Agent execution engine and its private runtime helpers.
2
3use super::checkpointing::{
4    AgentProgress, save_checkpoint, validate_exact_usage, validate_usage_floor,
5};
6use super::completion::TerminalCompletionContext;
7use super::observability::{consume_budget, emit_usage, record_domain, terminal_event};
8use super::{
9    Agent, AgentCheckpoint, AgentCheckpointPhase, AgentCheckpointState, AgentError,
10    AgentEventStream, AgentFuture, AgentObserver, AgentOutcome, AgentStreamEvent, Arc,
11    BufferedObserver, CheckpointCursor, ContentPart, DurableConversationCheckpoint, Either,
12    EventId, Instant, InvocationId, LifecycleEvent, Message, ModelCallContext, ModelError,
13    ModelErrorKind, ModelRequest, ModelResponse, ModelStreamAccumulator, NoopObserver,
14    ResumePolicy, Role, RunContext, RunEventKind, StreamExt, TOOL_RESULT_EXECUTION_ID_METADATA,
15    ToolCall, ToolChoice, Usage, emit_agent_event, select,
16};
17use crate::conversation::{
18    AgentConversationError, AgentConversationOutcome, AutomaticConversationSummary,
19    ConversationAppend, ConversationContextPolicy, ConversationId, ConversationStore,
20    ConversationSummaryCommit, ConversationSummaryRequest, DurableConversationCommit,
21    DurableConversationRequest, DurableConversationStore, MemoryNamespace, SemanticMemoryQuery,
22    is_transient_context, semantic_memory_message, summary_message,
23};
24use runifold_core::{CheckpointId, CheckpointStore};
25use runifold_retrieval::RetrievalContext;
26
27impl Agent {
28    /// Runs a user text turn with a default root context.
29    ///
30    /// This is the ergonomic surface for one-off prompts. It grants only
31    /// callables registered on this Agent and applies no hard budget limit.
32    /// Use [`Self::run`] when the caller must provide explicit authority,
33    /// budget, deadline, observability, or run-tree identity.
34    pub fn prompt<'a>(
35        &'a self,
36        input: impl Into<String> + Send + 'a,
37    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
38        let input = input.into();
39        Box::pin(async move {
40            let run = self.default_run_context();
41            self.run(input, &run).await
42        })
43    }
44
45    /// Runs an ergonomic prompt and returns only model-visible text.
46    ///
47    /// Rich content, usage, warnings, the canonical transcript, and provider
48    /// events are intentionally discarded. Use [`Self::prompt`] when that
49    /// information matters.
50    pub fn prompt_text<'a>(
51        &'a self,
52        input: impl Into<String> + Send + 'a,
53    ) -> AgentFuture<'a, Result<String, AgentError>> {
54        let input = input.into();
55        Box::pin(async move { self.prompt(input).await.map(AgentOutcome::into_text) })
56    }
57
58    /// Runs a user text turn inside an existing runtime context.
59    pub fn run<'a>(
60        &'a self,
61        input: impl Into<String> + Send + 'a,
62        run: &'a RunContext,
63    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
64        let input = input.into();
65        let state = self.initial_state(input, InvocationId::new().to_string());
66        Box::pin(async move {
67            self.execute_state(state, run, None, Arc::new(NoopObserver), true, true)
68                .await
69        })
70    }
71
72    /// Runs and atomically commits one bounded multi-turn conversation.
73    ///
74    /// Transcript messages remain append-only. Execution-journal events stay
75    /// in [`runifold_core::Journal`], summaries remain lossy derived views,
76    /// and semantic memory is injected only as explicitly untrusted context.
77    pub fn run_conversation<'a>(
78        &'a self,
79        input: impl Into<String> + Send + 'a,
80        run: &'a RunContext,
81        store: &'a dyn ConversationStore,
82        conversation_id: ConversationId,
83        namespace: MemoryNamespace,
84        policy: ConversationContextPolicy,
85    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
86        let input = input.into();
87        Box::pin(async move {
88            store.create(conversation_id, namespace.clone()).await?;
89            let view = store
90                .load_view(
91                    conversation_id,
92                    namespace.clone(),
93                    policy.window,
94                    policy.summary_batch,
95                )
96                .await?;
97            if view.requires_summary() {
98                return Err(AgentConversationError::SummaryRequired {
99                    conversation_id,
100                    buffered_entries: u64::try_from(view.summary_buffer.len())
101                        .unwrap_or(u64::MAX)
102                        .saturating_add(view.summary_backlog),
103                });
104            }
105            let mut transcript = self.instructions.clone();
106            if let Some(summary) = &view.summary {
107                transcript.push(summary_message(summary));
108            }
109            if let Some(limit) = policy.semantic_memory_limit {
110                let query =
111                    SemanticMemoryQuery::new(namespace.clone(), input.clone(), limit.get())?;
112                let search = store
113                    .search_memory_scoped(query, RetrievalContext::for_run(run))
114                    .await?;
115                if search.usage != Usage::default() {
116                    consume_budget(run, search.usage, None).map_err(AgentConversationError::Run)?;
117                }
118                if let Some(message) = semantic_memory_message(&search.memories) {
119                    transcript.push(message);
120                }
121            }
122            transcript.extend(view.window.iter().map(|entry| entry.message.clone()));
123            let persisted_prefix_len = transcript.len();
124            transcript.push(Message::user(input));
125            let state =
126                self.initial_state_from_transcript(transcript, InvocationId::new().to_string());
127            let outcome = self
128                .execute_state(state, run, None, Arc::new(NoopObserver), true, true)
129                .await
130                .map_err(AgentConversationError::Run)?;
131            let messages = outcome
132                .transcript
133                .iter()
134                .skip(persisted_prefix_len)
135                .filter(|message| !is_transient_context(message))
136                .cloned()
137                .collect();
138            let append = ConversationAppend {
139                conversation_id,
140                expected_version: view.version,
141                messages,
142            };
143            match store.append(namespace, append).await {
144                Ok(conversation_version) => Ok(AgentConversationOutcome {
145                    outcome,
146                    conversation_version,
147                }),
148                Err(source) => Err(AgentConversationError::Commit {
149                    source,
150                    outcome: Box::new(outcome),
151                }),
152            }
153        })
154    }
155
156    /// Summarizes an overflowing prefix before running a conversational turn.
157    ///
158    /// Summary generation uses the supplied [`AutomaticConversationSummary`]
159    /// and the same [`RunContext`], preserving cancellation, deadline, budget,
160    /// and journal behavior. The immutable transcript is never rewritten.
161    pub fn run_conversation_with_summary<'a>(
162        &'a self,
163        input: impl Into<String> + Send + 'a,
164        run: &'a RunContext,
165        store: &'a dyn ConversationStore,
166        conversation_id: ConversationId,
167        namespace: MemoryNamespace,
168        automatic_summary: AutomaticConversationSummary<'a>,
169    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
170        let input = input.into();
171        Box::pin(async move {
172            let policy = automatic_summary.context;
173            store.create(conversation_id, namespace.clone()).await?;
174            for pass in 0..automatic_summary.max_passes.get() {
175                let view = store
176                    .load_view(
177                        conversation_id,
178                        namespace.clone(),
179                        policy.window,
180                        policy.summary_batch,
181                    )
182                    .await?;
183                let Some(through_sequence) = view.summary_buffer.last().map(|entry| entry.sequence)
184                else {
185                    break;
186                };
187                let summary_backlog = view.summary_backlog;
188                let summary = automatic_summary
189                    .summarizer
190                    .summarize(
191                        ConversationSummaryRequest {
192                            transcript_version: view.version,
193                            previous_summary: view.summary,
194                            entries: view.summary_buffer,
195                        },
196                        run,
197                    )
198                    .await?;
199                store
200                    .commit_summary(
201                        namespace.clone(),
202                        ConversationSummaryCommit {
203                            conversation_id,
204                            expected_version: view.version,
205                            through_sequence,
206                            content: summary,
207                        },
208                    )
209                    .await?;
210                if summary_backlog == 0 {
211                    break;
212                }
213                if pass + 1 == automatic_summary.max_passes.get() {
214                    return Err(AgentConversationError::SummaryPassLimitExceeded {
215                        conversation_id,
216                        remaining_entries: summary_backlog,
217                    });
218                }
219            }
220            self.run_conversation(input, run, store, conversation_id, namespace, policy)
221                .await
222        })
223    }
224
225    /// Streams real-time events while driving the canonical Agent loop.
226    pub fn stream<'a>(
227        &'a self,
228        input: impl Into<String> + Send + 'a,
229        run: &'a RunContext,
230    ) -> AgentEventStream<'a> {
231        let state = self.initial_state(input.into(), InvocationId::new().to_string());
232        let observer = BufferedObserver::default();
233        let events = observer.events();
234        let execution =
235            Box::pin(self.execute_state(state, run, None, Arc::new(observer), true, true));
236        AgentEventStream::new(execution, events)
237    }
238
239    /// Runs one conversational turn with atomic transcript and checkpoint commit.
240    ///
241    /// Intermediate checkpoints are written ahead of model and callable work.
242    /// The terminal checkpoint and transcript append are committed together by
243    /// [`DurableConversationStore`].
244    pub fn run_durable_conversation<'a>(
245        &'a self,
246        input: impl Into<String> + Send + 'a,
247        run: &'a RunContext,
248        store: Arc<dyn DurableConversationStore>,
249        request: DurableConversationRequest,
250    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
251        self.run_durable_conversation_observed(
252            input.into(),
253            run,
254            store,
255            request,
256            Arc::new(NoopObserver),
257        )
258    }
259
260    /// Streams a durable turn, reporting success only after its atomic commit.
261    /// Polling drives execution; dropping the stream stops local work.
262    pub fn stream_durable_conversation<'a>(
263        &'a self,
264        input: impl Into<String> + Send + 'a,
265        run: &'a RunContext,
266        store: Arc<dyn DurableConversationStore>,
267        request: DurableConversationRequest,
268    ) -> crate::DurableConversationEventStream<'a> {
269        let observer = Arc::new(BufferedObserver::durable());
270        let events = observer.events();
271        let execution = self.run_durable_conversation_observed(
272            input.into(),
273            run,
274            store,
275            request,
276            observer.clone(),
277        );
278        Self::durable_event_stream(execution, observer, events)
279    }
280
281    pub(crate) fn run_durable_conversation_observed<'a>(
282        &'a self,
283        input: String,
284        run: &'a RunContext,
285        store: Arc<dyn DurableConversationStore>,
286        request: DurableConversationRequest,
287        observer: Arc<dyn AgentObserver>,
288    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
289        Box::pin(async move {
290            let DurableConversationRequest {
291                checkpoint_id,
292                conversation_id,
293                namespace,
294                policy,
295            } = request;
296            store.create(conversation_id, namespace.clone()).await?;
297            let view = store
298                .load_view(
299                    conversation_id,
300                    namespace.clone(),
301                    policy.window,
302                    policy.summary_batch,
303                )
304                .await?;
305            if view.requires_summary() {
306                return Err(AgentConversationError::SummaryRequired {
307                    conversation_id,
308                    buffered_entries: u64::try_from(view.summary_buffer.len())
309                        .unwrap_or(u64::MAX)
310                        .saturating_add(view.summary_backlog),
311                });
312            }
313            let mut transcript = self.instructions.clone();
314            if let Some(summary) = &view.summary {
315                transcript.push(summary_message(summary));
316            }
317            if let Some(limit) = policy.semantic_memory_limit {
318                let query =
319                    SemanticMemoryQuery::new(namespace.clone(), input.clone(), limit.get())?;
320                let search = store
321                    .search_memory_scoped(query, RetrievalContext::for_run(run))
322                    .await?;
323                if search.usage != Usage::default() {
324                    consume_budget(run, search.usage, None).map_err(AgentConversationError::Run)?;
325                }
326                if let Some(message) = semantic_memory_message(&search.memories) {
327                    transcript.push(message);
328                }
329            }
330            transcript.extend(view.window.iter().map(|entry| entry.message.clone()));
331            let persisted_prefix_len = u64::try_from(transcript.len()).map_err(|_| {
332                AgentConversationError::Run(checkpoint_payload_error(
333                    "conversation context length exceeds durable checkpoint range",
334                ))
335            })?;
336            transcript.push(Message::user(input));
337            let durable = DurableConversationCheckpoint {
338                conversation_id,
339                namespace,
340                expected_version: view.version,
341                persisted_prefix_len,
342            };
343            let mut state =
344                self.initial_state_from_transcript(transcript, checkpoint_id.to_string());
345            state.durable_conversation = Some(durable.clone());
346            state.usage = run.budget().usage();
347            let checkpoint_store: Arc<dyn CheckpointStore> = store.clone();
348            let checkpoint = AgentCheckpoint::existing(checkpoint_id, checkpoint_store);
349            let mut cursor = CheckpointCursor::create(&checkpoint, run, &state)
350                .map_err(AgentConversationError::Run)?;
351            let outcome = self
352                .execute_state(state, run, Some(&mut cursor), observer, true, false)
353                .await
354                .map_err(AgentConversationError::Run)?;
355            self.commit_durable_outcome(store.as_ref(), run, &cursor, durable, outcome)
356                .await
357        })
358    }
359
360    /// Resumes a durable conversational turn from its write-ahead checkpoint.
361    pub fn resume_durable_conversation<'a>(
362        &'a self,
363        store: Arc<dyn DurableConversationStore>,
364        checkpoint_id: CheckpointId,
365        run: &'a RunContext,
366        policy: ResumePolicy,
367    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
368        self.resume_durable_conversation_observed(
369            store,
370            checkpoint_id,
371            run,
372            policy,
373            Arc::new(NoopObserver),
374        )
375    }
376
377    /// Streams checkpoint recovery, including an already committed result without rerunning it.
378    pub fn stream_resume_durable_conversation<'a>(
379        &'a self,
380        store: Arc<dyn DurableConversationStore>,
381        checkpoint_id: CheckpointId,
382        run: &'a RunContext,
383        policy: ResumePolicy,
384    ) -> crate::DurableConversationEventStream<'a> {
385        let observer = Arc::new(BufferedObserver::durable());
386        let events = observer.events();
387        let execution = self.resume_durable_conversation_observed(
388            store,
389            checkpoint_id,
390            run,
391            policy,
392            observer.clone(),
393        );
394        Self::durable_event_stream(execution, observer, events)
395    }
396
397    fn durable_event_stream(
398        execution: AgentFuture<'_, Result<AgentConversationOutcome, AgentConversationError>>,
399        observer: Arc<BufferedObserver>,
400        events: Arc<std::sync::Mutex<std::collections::VecDeque<AgentStreamEvent>>>,
401    ) -> crate::DurableConversationEventStream<'_> {
402        AgentEventStream::new(
403            Box::pin(async move {
404                let committed = execution.await?;
405                observer.emit(AgentStreamEvent::ConversationCommitted {
406                    outcome: committed.outcome.clone(),
407                    conversation_version: committed.conversation_version,
408                });
409                Ok(committed.outcome)
410            }),
411            events,
412        )
413    }
414
415    pub(crate) fn resume_durable_conversation_observed<'a>(
416        &'a self,
417        store: Arc<dyn DurableConversationStore>,
418        checkpoint_id: CheckpointId,
419        run: &'a RunContext,
420        policy: ResumePolicy,
421        observer: Arc<dyn AgentObserver>,
422    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
423        Box::pin(async move {
424            let checkpoint_store: Arc<dyn CheckpointStore> = store.clone();
425            let checkpoint = AgentCheckpoint::existing(checkpoint_id, checkpoint_store);
426            let (envelope, mut state) = checkpoint
427                .load()
428                .map_err(AgentError::from)
429                .map_err(AgentConversationError::Run)?;
430            self.validate_checkpoint_identity(&state)
431                .map_err(AgentConversationError::Run)?;
432            let durable = state.durable_conversation.clone().ok_or_else(|| {
433                AgentConversationError::Run(checkpoint_payload_error(
434                    "checkpoint is not a durable conversation turn",
435                ))
436            })?;
437            if let Some(outcome) = state.outcome() {
438                let conversation_version = durable
439                    .expected_version
440                    .get()
441                    .checked_add(1)
442                    .map(crate::ConversationVersion::new)
443                    .ok_or_else(|| {
444                        AgentConversationError::Run(checkpoint_payload_error(
445                            "durable conversation version overflow",
446                        ))
447                    })?;
448                return Ok(AgentConversationOutcome {
449                    outcome,
450                    conversation_version,
451                });
452            }
453            if let Some(error) = state.terminal_failure() {
454                validate_exact_usage(state.usage, run.budget().usage())
455                    .map_err(AgentConversationError::Run)?;
456                return Err(AgentConversationError::Run(error));
457            }
458            Self::prepare_resume_state(&mut state, run, policy)
459                .map_err(AgentConversationError::Run)?;
460            let mut cursor = CheckpointCursor::loaded(&checkpoint, envelope);
461            let outcome = self
462                .execute_state(state, run, Some(&mut cursor), observer, false, false)
463                .await
464                .map_err(AgentConversationError::Run)?;
465            self.commit_durable_outcome(store.as_ref(), run, &cursor, durable, outcome)
466                .await
467        })
468    }
469
470    /// Runs with write-ahead checkpoint persistence.
471    pub fn run_checkpointed<'a>(
472        &'a self,
473        input: impl Into<String> + Send + 'a,
474        run: &'a RunContext,
475        checkpoint: &'a AgentCheckpoint,
476    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
477        let input = input.into();
478        Box::pin(async move {
479            let mut state = self.initial_state(input, checkpoint.id().to_string());
480            state.usage = run.budget().usage();
481            let mut cursor = CheckpointCursor::create(checkpoint, run, &state)?;
482            self.execute_state(
483                state,
484                run,
485                Some(&mut cursor),
486                Arc::new(NoopObserver),
487                true,
488                true,
489            )
490            .await
491        })
492    }
493
494    /// Resumes a persisted Agent execution.
495    pub fn resume<'a>(
496        &'a self,
497        checkpoint: &'a AgentCheckpoint,
498        run: &'a RunContext,
499        policy: ResumePolicy,
500    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
501        Box::pin(async move {
502            let (envelope, mut state) = checkpoint.load()?;
503            self.validate_checkpoint_identity(&state)?;
504            if let Some(outcome) = state.outcome() {
505                validate_exact_usage(state.usage, run.budget().usage())?;
506                return Ok(outcome);
507            }
508            if let Some(error) = state.terminal_failure() {
509                validate_exact_usage(state.usage, run.budget().usage())?;
510                return Err(error);
511            }
512            Self::prepare_resume_state(&mut state, run, policy)?;
513            let mut cursor = CheckpointCursor::loaded(checkpoint, envelope);
514            self.execute_state(
515                state,
516                run,
517                Some(&mut cursor),
518                Arc::new(NoopObserver),
519                false,
520                true,
521            )
522            .await
523        })
524    }
525
526    fn initial_state(&self, input: String, execution_id: String) -> AgentCheckpointState {
527        let mut transcript = self.instructions.clone();
528        transcript.push(Message::user(input));
529        self.initial_state_from_transcript(transcript, execution_id)
530    }
531
532    fn initial_state_from_transcript(
533        &self,
534        transcript: Vec<Message>,
535        execution_id: String,
536    ) -> AgentCheckpointState {
537        AgentCheckpointState {
538            recovery_contract: Some(self.recovery_contract()),
539            execution_id,
540            agent: self.name.clone(),
541            model: self.model_ref.clone(),
542            transcript,
543            turns: 0,
544            tool_calls: 0,
545            delegations: 0,
546            usage: Usage::default(),
547            turn_reviewer: self
548                .turn_review
549                .as_ref()
550                .map(|review| review.descriptor.clone()),
551            turn_review_policy: self.turn_review.as_ref().map(|review| review.policy),
552            turn_reviewer_capabilities: self.turn_review.as_ref().map_or_else(Vec::new, |review| {
553                review
554                    .capabilities
555                    .iter()
556                    .map(|capability| capability.id)
557                    .collect()
558            }),
559            terminal_reviewer: self
560                .terminal_review
561                .as_ref()
562                .map(|review| review.descriptor.clone()),
563            terminal_review_policy: self.terminal_review.as_ref().map(|review| review.policy),
564            terminal_reviewer_capabilities: self.terminal_review.as_ref().map_or_else(
565                Vec::new,
566                |review| {
567                    review
568                        .capabilities
569                        .iter()
570                        .map(|capability| capability.id)
571                        .collect()
572                },
573            ),
574            phase: AgentCheckpointPhase::ReadyForTurn,
575            durable_conversation: None,
576        }
577    }
578
579    async fn execute_state(
580        &self,
581        state: AgentCheckpointState,
582        run: &RunContext,
583        mut checkpoint: Option<&mut CheckpointCursor>,
584        observer: Arc<dyn AgentObserver>,
585        retrieve_context: bool,
586        persist_terminal_checkpoint: bool,
587    ) -> Result<AgentOutcome, AgentError> {
588        let started = run
589            .record(
590                RunEventKind::Lifecycle(LifecycleEvent::Started),
591                run.caused_by(),
592            )?
593            .map(|event| event.meta.event_id);
594        emit_agent_event(
595            observer.as_ref(),
596            AgentStreamEvent::Started {
597                agent: self.name.clone(),
598            },
599        )
600        .await;
601        let result = async {
602            let has_context = !self.context.is_empty() || !self.dynamic_context.is_empty();
603            let state = if retrieve_context && has_context {
604                let mut prepared = self
605                    .prepare_context(state, run, started, observer.as_ref())
606                    .await?;
607                prepared.usage = run.budget().usage();
608                save_checkpoint(&mut checkpoint, &prepared)?;
609                prepared
610            } else {
611                state
612            };
613            self.run_loop(
614                state,
615                run,
616                started,
617                checkpoint,
618                observer.as_ref(),
619                persist_terminal_checkpoint,
620            )
621            .await
622        }
623        .await;
624        let terminal = terminal_event(&self.name, &result);
625        run.record(terminal, started)?;
626        if let Ok(outcome) = &result {
627            emit_agent_event(
628                observer.as_ref(),
629                AgentStreamEvent::Completed {
630                    outcome: outcome.clone(),
631                },
632            )
633            .await;
634        }
635        result
636    }
637
638    async fn run_loop(
639        &self,
640        state: AgentCheckpointState,
641        run: &RunContext,
642        caused_by: Option<EventId>,
643        mut checkpoint: Option<&mut CheckpointCursor>,
644        observer: &dyn AgentObserver,
645        persist_terminal_checkpoint: bool,
646    ) -> Result<AgentOutcome, AgentError> {
647        self.validate_config()?;
648        let completion_context = TerminalCompletionContext {
649            caused_by,
650            observer,
651            persist_terminal_checkpoint,
652        };
653        let (mut progress, resumed, mut approved_response) = self
654            .prepare_run_loop_progress(state, run, &mut checkpoint, &completion_context)
655            .await?;
656        if let Some(outcome) = resumed {
657            return Ok(outcome);
658        }
659
660        loop {
661            Self::check_lifecycle(run)?;
662            let Some((response, requires_tool)) = self
663                .next_reviewed_response(
664                    approved_response.take(),
665                    &mut progress,
666                    run,
667                    &mut checkpoint,
668                    &completion_context,
669                )
670                .await?
671            else {
672                continue;
673            };
674            let calls = tool_calls_from(&response.content);
675            if calls.is_empty() {
676                if self.continue_provider_turn(
677                    response.clone(),
678                    &mut progress,
679                    run,
680                    &mut checkpoint,
681                )? {
682                    continue;
683                }
684                if matches!(
685                    response.finish_reason,
686                    runifold_model::FinishReason::ToolCalls
687                ) && !response.content.is_empty()
688                {
689                    return Err(AgentError::Protocol(
690                        "model stopped for tool calls without emitting a tool call".into(),
691                    ));
692                }
693                if requires_tool {
694                    return Err(AgentError::ToolRequirementUnsatisfied {
695                        required: self.min_successful_tool_calls,
696                        successful: self.successful_local_tool_calls(&progress)?,
697                    });
698                }
699                if let Some(outcome) = self
700                    .complete_terminal_candidate(
701                        response,
702                        run,
703                        &mut progress,
704                        &mut checkpoint,
705                        TerminalCompletionContext {
706                            caused_by,
707                            observer,
708                            persist_terminal_checkpoint,
709                        },
710                    )
711                    .await?
712                {
713                    return Ok(outcome);
714                }
715                continue;
716            }
717
718            let assistant = Message::new(Role::Assistant, response.content.clone())
719                .map_err(|error| AgentError::Protocol(error.to_string()))?;
720            progress.transcript.push(assistant);
721
722            self.execute_calls(calls, run, caused_by, &mut progress, observer)
723                .await?;
724            save_checkpoint(
725                &mut checkpoint,
726                &self.checkpoint_state(&progress, run, AgentCheckpointPhase::ReadyForTurn),
727            )?;
728        }
729    }
730
731    async fn next_reviewed_response(
732        &self,
733        approved_response: Option<ModelResponse>,
734        progress: &mut AgentProgress,
735        run: &RunContext,
736        checkpoint: &mut Option<&mut CheckpointCursor>,
737        context: &TerminalCompletionContext<'_>,
738    ) -> Result<Option<(ModelResponse, bool)>, AgentError> {
739        let (response, requires_tool, already_reviewed) = if let Some(response) = approved_response
740        {
741            let requires_tool =
742                self.successful_local_tool_calls(progress)? < self.min_successful_tool_calls;
743            (response, requires_tool, true)
744        } else {
745            let tool_choice = self
746                .begin_turn(
747                    progress,
748                    run,
749                    checkpoint,
750                    context.caused_by,
751                    context.observer,
752                )
753                .await?;
754            let requires_tool = matches!(tool_choice, ToolChoice::Required);
755            let response = self
756                .invoke_model(
757                    &progress.transcript,
758                    run,
759                    progress.turns,
760                    tool_choice,
761                    context.caused_by,
762                    context.observer,
763                )
764                .await?;
765            (response, requires_tool, false)
766        };
767
768        let calls = tool_calls_from(&response.content);
769        validate_tool_call_completion(&calls, &response.finish_reason)?;
770        if already_reviewed
771            || !self
772                .turn_review
773                .as_ref()
774                .is_some_and(|review| review.policy.scope().includes(&response))
775        {
776            return Ok(Some((response, requires_tool)));
777        }
778
779        save_checkpoint(
780            checkpoint,
781            &self.checkpoint_state(
782                progress,
783                run,
784                AgentCheckpointPhase::TurnReviewReady {
785                    response: Box::new(response.clone()),
786                    turn: progress.turns,
787                },
788            ),
789        )?;
790        let response = self
791            .review_turn_candidate(response, run, progress, checkpoint, context)
792            .await?;
793        Ok(response.map(|response| (response, requires_tool)))
794    }
795
796    async fn begin_turn(
797        &self,
798        progress: &mut AgentProgress,
799        run: &RunContext,
800        checkpoint: &mut Option<&mut CheckpointCursor>,
801        caused_by: Option<EventId>,
802        observer: &dyn AgentObserver,
803    ) -> Result<ToolChoice, AgentError> {
804        let tool_choice = self.next_tool_choice(progress, run)?;
805        save_checkpoint(
806            checkpoint,
807            &self.checkpoint_state(
808                progress,
809                run,
810                AgentCheckpointPhase::TurnInFlight {
811                    turn: progress.turns + 1,
812                },
813            ),
814        )?;
815        consume_budget(
816            run,
817            Usage {
818                turns: 1,
819                ..Usage::default()
820            },
821            caused_by,
822        )?;
823        progress.turns += 1;
824        emit_agent_event(
825            observer,
826            AgentStreamEvent::TurnStarted {
827                turn: progress.turns,
828            },
829        )
830        .await;
831        emit_usage(observer, run).await;
832        self.record_turn_started(run, progress.turns, caused_by)?;
833        Ok(tool_choice)
834    }
835
836    fn record_turn_started(
837        &self,
838        run: &RunContext,
839        turn: u32,
840        caused_by: Option<EventId>,
841    ) -> Result<(), AgentError> {
842        record_domain(
843            run,
844            "turn.started",
845            serde_json::json!({"agent": self.name, "turn": turn}),
846            caused_by,
847        )
848    }
849
850    fn continue_provider_turn(
851        &self,
852        response: ModelResponse,
853        progress: &mut AgentProgress,
854        run: &RunContext,
855        checkpoint: &mut Option<&mut CheckpointCursor>,
856    ) -> Result<bool, AgentError> {
857        if !matches!(
858            &response.finish_reason,
859            runifold_model::FinishReason::Other(reason) if reason == "pause_turn"
860        ) {
861            return Ok(false);
862        }
863        let assistant = Message::new(Role::Assistant, response.content)
864            .map_err(|error| AgentError::Protocol(error.to_string()))?;
865        progress.transcript.push(assistant);
866        save_checkpoint(
867            checkpoint,
868            &self.checkpoint_state(progress, run, AgentCheckpointPhase::ReadyForTurn),
869        )?;
870        Ok(true)
871    }
872
873    async fn invoke_model(
874        &self,
875        transcript: &[Message],
876        run: &RunContext,
877        turn: u32,
878        tool_choice: ToolChoice,
879        caused_by: Option<EventId>,
880        observer: &dyn AgentObserver,
881    ) -> Result<ModelResponse, AgentError> {
882        record_domain(
883            run,
884            "model.started",
885            serde_json::json!({
886                "agent": self.name,
887                "turn": turn,
888                "provider": self.model_ref.provider,
889                "model": self.model_ref.name,
890            }),
891            caused_by,
892        )?;
893        let response = match self
894            .stream_model_response(self.request(transcript, tool_choice)?, run, turn, observer)
895            .await
896        {
897            Ok(response) => response,
898            Err(error) => {
899                record_domain(
900                    run,
901                    "model.failed",
902                    serde_json::json!({
903                        "agent": self.name,
904                        "turn": turn,
905                        "kind": format!("{:?}", error.kind),
906                    }),
907                    caused_by,
908                )?;
909                return Err(error.into());
910            }
911        };
912        record_domain(
913            run,
914            "model.completed",
915            serde_json::json!({
916                "agent": self.name,
917                "turn": turn,
918                "finish_reason": response.finish_reason,
919                "usage": response.usage,
920            }),
921            caused_by,
922        )?;
923        consume_budget(run, response.usage.into(), caused_by)?;
924        emit_usage(observer, run).await;
925        Ok(response)
926    }
927
928    async fn stream_model_response(
929        &self,
930        request: ModelRequest,
931        run: &RunContext,
932        turn: u32,
933        observer: &dyn AgentObserver,
934    ) -> Result<ModelResponse, ModelError> {
935        let context = ModelCallContext::for_run(run);
936        let cancellation = context.cancellation().clone();
937        let opening = self.model.stream(request, context);
938        let mut stream = match select(Box::pin(cancellation.cancelled()), Box::pin(opening)).await {
939            Either::Left(_) => return Err(cancelled_model_error()),
940            Either::Right((result, _)) => result?,
941        };
942        let mut accumulator = ModelStreamAccumulator::new();
943        loop {
944            let next = stream.next();
945            let event = match select(Box::pin(cancellation.cancelled()), Box::pin(next)).await {
946                Either::Left(_) => return Err(cancelled_model_error()),
947                Either::Right((Some(event), _)) => event?,
948                Either::Right((None, _)) => {
949                    return Err(ModelError::local(
950                        ModelErrorKind::Protocol,
951                        "model stream ended before a terminal response event",
952                    ));
953                }
954            };
955            let response = accumulator.push(event.clone())?;
956            emit_agent_event(observer, AgentStreamEvent::Model { turn, event }).await;
957            if let Some(response) = response {
958                return Ok(response);
959            }
960        }
961    }
962
963    fn validate_config(&self) -> Result<(), AgentError> {
964        if self.name.trim().is_empty() {
965            return Err(AgentError::InvalidConfig(
966                "agent name cannot be empty".into(),
967            ));
968        }
969        if self.config.max_turns == 0 {
970            return Err(AgentError::InvalidConfig(
971                "max_turns must be greater than zero".into(),
972            ));
973        }
974        if self.min_successful_tool_calls > 0 && self.tools.is_empty() {
975            return Err(AgentError::InvalidConfig(format!(
976                "min_successful_tool_calls={} requires at least one registered local Tool",
977                self.min_successful_tool_calls
978            )));
979        }
980        if let Some(collision) = self
981            .agents
982            .model_specs()
983            .into_iter()
984            .find(|spec| self.tools.contains(&spec.name))
985        {
986            return Err(AgentError::InvalidConfig(format!(
987                "callable name `{}` is registered as both a tool and an agent",
988                collision.name
989            )));
990        }
991        Ok(())
992    }
993
994    /// Returns the declarative contract checked before checkpoint recovery.
995    /// Custom tools and child agents must maintain stable IDs and versions.
996    pub fn recovery_contract(&self) -> crate::AgentRecoveryContract {
997        crate::AgentRecoveryContract {
998            instructions: self.instructions.clone(),
999            context: if self.context.is_empty() {
1000                Vec::new()
1001            } else {
1002                vec![super::retrieval::untrusted_context_message(&self.context)]
1003            },
1004            tools: self
1005                .tools
1006                .model_specs()
1007                .iter()
1008                .filter_map(|spec| self.tools.descriptor(&spec.name).cloned())
1009                .collect(),
1010            agents: self
1011                .agents
1012                .model_specs()
1013                .iter()
1014                .filter_map(|spec| self.agents.descriptor(&spec.name).cloned())
1015                .collect(),
1016            retrieval: self
1017                .dynamic_context
1018                .iter()
1019                .map(|source| (source.retriever.descriptor().capability(), source.limit))
1020                .collect(),
1021            generation: self.generation.clone(),
1022            output_format: self.output_format.clone(),
1023            response_mode: self.response_mode,
1024            provider_tools: self.provider_tools.clone(),
1025            provider_options: self.provider_options.clone(),
1026            config: self.config.clone(),
1027            tool_concurrency: self.tool_concurrency,
1028            min_successful_tool_calls: self.min_successful_tool_calls,
1029            completion: self.completion_requirement,
1030            retry_safe_effects: self.effect_recovery
1031                == runifold_effect::EffectRecoveryPolicy::RetrySafe,
1032        }
1033    }
1034
1035    fn validate_checkpoint_identity(&self, state: &AgentCheckpointState) -> Result<(), AgentError> {
1036        if state.recovery_contract.as_ref() != Some(&self.recovery_contract()) {
1037            return Err(checkpoint_payload_error(
1038                "checkpoint execution contract is missing or differs from the configured Agent",
1039            ));
1040        }
1041        let terminal_reviewer = self
1042            .terminal_review
1043            .as_ref()
1044            .map(|review| &review.descriptor);
1045        let turn_reviewer = self.turn_review.as_ref().map(|review| &review.descriptor);
1046        let terminal_review_policy = self.terminal_review.as_ref().map(|review| review.policy);
1047        let turn_review_policy = self.turn_review.as_ref().map(|review| review.policy);
1048        let terminal_reviewer_capabilities =
1049            self.terminal_review
1050                .as_ref()
1051                .map_or_else(Vec::new, |review| {
1052                    review
1053                        .capabilities
1054                        .iter()
1055                        .map(|capability| capability.id)
1056                        .collect()
1057                });
1058        let turn_reviewer_capabilities =
1059            self.turn_review.as_ref().map_or_else(Vec::new, |review| {
1060                review
1061                    .capabilities
1062                    .iter()
1063                    .map(|capability| capability.id)
1064                    .collect()
1065            });
1066        if state.agent != self.name
1067            || state.model != self.model_ref
1068            || state.terminal_reviewer.as_ref() != terminal_reviewer
1069            || state.turn_reviewer.as_ref() != turn_reviewer
1070            || state.terminal_review_policy != terminal_review_policy
1071            || state.turn_review_policy != turn_review_policy
1072            || state.terminal_reviewer_capabilities != terminal_reviewer_capabilities
1073            || state.turn_reviewer_capabilities != turn_reviewer_capabilities
1074        {
1075            return Err(runifold_core::CheckpointError::new(
1076                runifold_core::CheckpointErrorKind::InvalidPayload,
1077                "checkpoint Agent, model, reviewer identity, policy, or reviewer capabilities do not match",
1078            )
1079            .into());
1080        }
1081        Ok(())
1082    }
1083
1084    async fn prepare_run_loop_progress(
1085        &self,
1086        state: AgentCheckpointState,
1087        run: &RunContext,
1088        checkpoint: &mut Option<&mut CheckpointCursor>,
1089        context: &TerminalCompletionContext<'_>,
1090    ) -> Result<(AgentProgress, Option<AgentOutcome>, Option<ModelResponse>), AgentError> {
1091        enum Pending {
1092            Terminal(ModelResponse, u32),
1093            Turn(ModelResponse, u32),
1094            Approved(ModelResponse, u32),
1095        }
1096
1097        let pending = match &state.phase {
1098            AgentCheckpointPhase::TerminalReviewReady { response, attempt } => {
1099                Some(Pending::Terminal(response.as_ref().clone(), *attempt))
1100            }
1101            AgentCheckpointPhase::TurnReviewReady { response, turn } => {
1102                Some(Pending::Turn(response.as_ref().clone(), *turn))
1103            }
1104            AgentCheckpointPhase::TurnReviewApproved { response, turn } => {
1105                Some(Pending::Approved(response.as_ref().clone(), *turn))
1106            }
1107            AgentCheckpointPhase::ReadyForTurn => None,
1108            _ => {
1109                return Err(checkpoint_payload_error(
1110                    "checkpoint phase is not ready for Agent execution",
1111                ));
1112            }
1113        };
1114        let mut progress = AgentProgress::from(state);
1115        let mut outcome = None;
1116        let mut approved_response = None;
1117        match pending {
1118            Some(Pending::Terminal(response, attempt)) => {
1119                outcome = self
1120                    .review_terminal_candidate(
1121                        response,
1122                        attempt,
1123                        run,
1124                        &mut progress,
1125                        checkpoint,
1126                        context,
1127                    )
1128                    .await?;
1129            }
1130            Some(Pending::Turn(response, turn)) => {
1131                validate_review_turn(turn, progress.turns)?;
1132                approved_response = self
1133                    .review_turn_candidate(response, run, &mut progress, checkpoint, context)
1134                    .await?;
1135            }
1136            Some(Pending::Approved(response, turn)) => {
1137                validate_review_turn(turn, progress.turns)?;
1138                approved_response = Some(response);
1139            }
1140            None => {}
1141        }
1142        Ok((progress, outcome, approved_response))
1143    }
1144
1145    fn prepare_resume_state(
1146        state: &mut AgentCheckpointState,
1147        run: &RunContext,
1148        policy: ResumePolicy,
1149    ) -> Result<(), AgentError> {
1150        match state.phase.clone() {
1151            AgentCheckpointPhase::TurnInFlight { turn } => {
1152                if policy == ResumePolicy::RejectAmbiguous {
1153                    return Err(AgentError::AmbiguousCheckpoint { turn });
1154                }
1155                validate_usage_floor(state.usage, run.budget().usage())?;
1156                state.usage = run.budget().usage();
1157                state.phase = AgentCheckpointPhase::ReadyForTurn;
1158            }
1159            AgentCheckpointPhase::TerminalReviewInFlight { response, attempt } => {
1160                if policy == ResumePolicy::RejectAmbiguous {
1161                    return Err(AgentError::AmbiguousTerminalReview { attempt });
1162                }
1163                validate_usage_floor(state.usage, run.budget().usage())?;
1164                state.usage = run.budget().usage();
1165                state.phase = AgentCheckpointPhase::TerminalReviewReady { response, attempt };
1166            }
1167            AgentCheckpointPhase::TurnReviewInFlight { response, turn } => {
1168                if policy == ResumePolicy::RejectAmbiguous {
1169                    return Err(AgentError::AmbiguousTurnReview { turn });
1170                }
1171                validate_usage_floor(state.usage, run.budget().usage())?;
1172                state.usage = run.budget().usage();
1173                state.phase = AgentCheckpointPhase::TurnReviewReady { response, turn };
1174            }
1175            AgentCheckpointPhase::TurnReviewApproved { ref response, turn }
1176                if !tool_calls_from(&response.content).is_empty() =>
1177            {
1178                if policy == ResumePolicy::RejectAmbiguous {
1179                    return Err(AgentError::AmbiguousCheckpoint { turn });
1180                }
1181                validate_usage_floor(state.usage, run.budget().usage())?;
1182                state.usage = run.budget().usage();
1183            }
1184            _ => validate_exact_usage(state.usage, run.budget().usage())?,
1185        }
1186        Ok(())
1187    }
1188
1189    pub(super) fn checkpoint_state(
1190        &self,
1191        progress: &AgentProgress,
1192        run: &RunContext,
1193        phase: AgentCheckpointPhase,
1194    ) -> AgentCheckpointState {
1195        AgentCheckpointState {
1196            recovery_contract: Some(self.recovery_contract()),
1197            execution_id: progress.execution_id.clone(),
1198            agent: self.name.clone(),
1199            model: self.model_ref.clone(),
1200            transcript: progress.transcript.clone(),
1201            turns: progress.turns,
1202            tool_calls: progress.tool_calls,
1203            delegations: progress.delegations,
1204            usage: run.budget().usage(),
1205            turn_reviewer: self
1206                .turn_review
1207                .as_ref()
1208                .map(|review| review.descriptor.clone()),
1209            turn_review_policy: self.turn_review.as_ref().map(|review| review.policy),
1210            turn_reviewer_capabilities: self.turn_review.as_ref().map_or_else(Vec::new, |review| {
1211                review
1212                    .capabilities
1213                    .iter()
1214                    .map(|capability| capability.id)
1215                    .collect()
1216            }),
1217            terminal_reviewer: self
1218                .terminal_review
1219                .as_ref()
1220                .map(|review| review.descriptor.clone()),
1221            terminal_review_policy: self.terminal_review.as_ref().map(|review| review.policy),
1222            terminal_reviewer_capabilities: self.terminal_review.as_ref().map_or_else(
1223                Vec::new,
1224                |review| {
1225                    review
1226                        .capabilities
1227                        .iter()
1228                        .map(|capability| capability.id)
1229                        .collect()
1230                },
1231            ),
1232            phase,
1233            durable_conversation: progress.durable_conversation.clone(),
1234        }
1235    }
1236
1237    async fn commit_durable_outcome(
1238        &self,
1239        store: &dyn DurableConversationStore,
1240        run: &RunContext,
1241        cursor: &CheckpointCursor,
1242        durable: DurableConversationCheckpoint,
1243        outcome: AgentOutcome,
1244    ) -> Result<AgentConversationOutcome, AgentConversationError> {
1245        let persisted_prefix_len = usize::try_from(durable.persisted_prefix_len).map_err(|_| {
1246            AgentConversationError::Run(checkpoint_payload_error(
1247                "durable conversation prefix does not fit this platform",
1248            ))
1249        })?;
1250        if persisted_prefix_len >= outcome.transcript.len() {
1251            return Err(AgentConversationError::Run(checkpoint_payload_error(
1252                "durable conversation checkpoint has an invalid transcript prefix",
1253            )));
1254        }
1255        let messages = outcome
1256            .transcript
1257            .iter()
1258            .skip(persisted_prefix_len)
1259            .filter(|message| !is_transient_context(message))
1260            .cloned()
1261            .collect();
1262        let state = AgentCheckpointState {
1263            recovery_contract: Some(self.recovery_contract()),
1264            execution_id: cursor.id().to_string(),
1265            agent: self.name.clone(),
1266            model: self.model_ref.clone(),
1267            transcript: outcome.transcript.clone(),
1268            turns: outcome.turns,
1269            tool_calls: outcome.tool_calls,
1270            delegations: outcome.delegations,
1271            usage: run.budget().usage(),
1272            turn_reviewer: self
1273                .turn_review
1274                .as_ref()
1275                .map(|review| review.descriptor.clone()),
1276            turn_review_policy: self.turn_review.as_ref().map(|review| review.policy),
1277            turn_reviewer_capabilities: self.turn_review.as_ref().map_or_else(Vec::new, |review| {
1278                review
1279                    .capabilities
1280                    .iter()
1281                    .map(|capability| capability.id)
1282                    .collect()
1283            }),
1284            terminal_reviewer: self
1285                .terminal_review
1286                .as_ref()
1287                .map(|review| review.descriptor.clone()),
1288            terminal_review_policy: self.terminal_review.as_ref().map(|review| review.policy),
1289            terminal_reviewer_capabilities: self.terminal_review.as_ref().map_or_else(
1290                Vec::new,
1291                |review| {
1292                    review
1293                        .capabilities
1294                        .iter()
1295                        .map(|capability| capability.id)
1296                        .collect()
1297                },
1298            ),
1299            phase: AgentCheckpointPhase::Completed {
1300                response: Box::new(outcome.response.clone()),
1301            },
1302            durable_conversation: Some(durable.clone()),
1303        };
1304        let checkpoint = cursor.next(&state).map_err(AgentConversationError::Run)?;
1305        let command = DurableConversationCommit {
1306            namespace: durable.namespace,
1307            append: ConversationAppend {
1308                conversation_id: durable.conversation_id,
1309                expected_version: durable.expected_version,
1310                messages,
1311            },
1312            checkpoint,
1313            expected_checkpoint_revision: cursor.revision(),
1314        };
1315        match store.commit_durable_turn(command).await {
1316            Ok(conversation_version) => Ok(AgentConversationOutcome {
1317                outcome,
1318                conversation_version,
1319            }),
1320            Err(source) => Err(AgentConversationError::Commit {
1321                source,
1322                outcome: Box::new(outcome),
1323            }),
1324        }
1325    }
1326
1327    pub(super) fn check_lifecycle(run: &RunContext) -> Result<(), AgentError> {
1328        let error = if run.cancellation().is_cancelled() {
1329            Some((
1330                runifold_model::ModelErrorKind::Cancelled,
1331                "agent run was cancelled",
1332            ))
1333        } else if run
1334            .deadline()
1335            .is_some_and(|deadline| deadline <= Instant::now())
1336        {
1337            Some((
1338                runifold_model::ModelErrorKind::DeadlineExceeded,
1339                "agent run deadline elapsed",
1340            ))
1341        } else {
1342            None
1343        };
1344        if let Some((kind, message)) = error {
1345            return Err(runifold_model::ModelError::local(kind, message).into());
1346        }
1347        Ok(())
1348    }
1349
1350    fn request(
1351        &self,
1352        transcript: &[Message],
1353        tool_choice: ToolChoice,
1354    ) -> Result<ModelRequest, AgentError> {
1355        let (first, rest) = transcript
1356            .split_first()
1357            .ok_or_else(|| AgentError::Protocol("agent transcript is empty".into()))?;
1358        let mut request = ModelRequest::new(self.model_ref.clone(), first.clone());
1359        request.messages.extend_from_slice(rest);
1360        request.tools = self.tools.model_specs();
1361        request.tools.extend(self.agents.model_specs());
1362        request.tool_choice = tool_choice;
1363        for tool in &self.provider_tools {
1364            request = request.provider_tool(tool.clone());
1365        }
1366        request.generation.clone_from(&self.generation);
1367        request = request.response_mode(self.response_mode);
1368        request.provider_options.clone_from(&self.provider_options);
1369        request.feature_policy = self.config.feature_policy;
1370        request.output_format.clone_from(&self.output_format);
1371        Ok(request)
1372    }
1373
1374    fn successful_local_tool_calls(&self, progress: &AgentProgress) -> Result<u32, AgentError> {
1375        let count = progress
1376            .transcript
1377            .iter()
1378            .filter(|message| {
1379                message
1380                    .metadata
1381                    .get(TOOL_RESULT_EXECUTION_ID_METADATA)
1382                    .and_then(serde_json::Value::as_str)
1383                    == Some(progress.execution_id.as_str())
1384            })
1385            .flat_map(|message| &message.content)
1386            .filter(|part| {
1387                matches!(
1388                    part,
1389                    ContentPart::ToolResult(result)
1390                        if !result.is_error
1391                            && result
1392                                .name
1393                                .as_deref()
1394                                .is_some_and(|name| self.tools.contains(name))
1395                )
1396            })
1397            .count();
1398        u32::try_from(count)
1399            .map_err(|_| AgentError::Protocol("successful Tool-call counter overflow".into()))
1400    }
1401
1402    fn next_tool_choice(
1403        &self,
1404        progress: &AgentProgress,
1405        run: &RunContext,
1406    ) -> Result<ToolChoice, AgentError> {
1407        let successful = self.successful_local_tool_calls(progress)?;
1408        let remaining_required = self.min_successful_tool_calls.saturating_sub(successful);
1409        Self::validate_tool_requirement_budget(remaining_required, run)?;
1410        if progress.turns >= self.config.max_turns {
1411            if remaining_required > 0 {
1412                return Err(AgentError::ToolRequirementUnsatisfied {
1413                    required: self.min_successful_tool_calls,
1414                    successful,
1415                });
1416            }
1417            return Err(AgentError::MaxTurns {
1418                max_turns: self.config.max_turns,
1419            });
1420        }
1421        Ok(if remaining_required > 0 {
1422            ToolChoice::Required
1423        } else {
1424            ToolChoice::Auto
1425        })
1426    }
1427
1428    fn validate_tool_requirement_budget(
1429        remaining_required: u32,
1430        run: &RunContext,
1431    ) -> Result<(), AgentError> {
1432        let Some(limit) = run.budget().limit().tool_calls else {
1433            return Ok(());
1434        };
1435        let remaining = limit.saturating_sub(run.budget().usage().tool_calls);
1436        if u64::from(remaining_required) > remaining {
1437            return Err(AgentError::ToolRequirementExceedsBudget {
1438                required: remaining_required,
1439                remaining,
1440            });
1441        }
1442        Ok(())
1443    }
1444}
1445
1446fn validate_tool_call_completion(
1447    calls: &[ToolCall],
1448    finish_reason: &runifold_model::FinishReason,
1449) -> Result<(), AgentError> {
1450    if !calls.is_empty() && !matches!(finish_reason, runifold_model::FinishReason::ToolCalls) {
1451        return Err(AgentError::Protocol(format!(
1452            "refusing to execute tool calls from a {finish_reason:?} model response"
1453        )));
1454    }
1455    Ok(())
1456}
1457
1458fn checkpoint_payload_error(message: &str) -> AgentError {
1459    runifold_core::CheckpointError::new(runifold_core::CheckpointErrorKind::InvalidPayload, message)
1460        .into()
1461}
1462
1463fn validate_review_turn(expected: u32, actual: u32) -> Result<(), AgentError> {
1464    if expected != actual {
1465        return Err(checkpoint_payload_error(
1466            "turn review checkpoint does not match the completed model turn count",
1467        ));
1468    }
1469    Ok(())
1470}
1471
1472fn cancelled_model_error() -> ModelError {
1473    ModelError::local(ModelErrorKind::Cancelled, "model invocation was cancelled")
1474}
1475
1476fn tool_calls_from(content: &[ContentPart]) -> Vec<ToolCall> {
1477    content
1478        .iter()
1479        .filter_map(|part| match part {
1480            ContentPart::ToolCall(call) => Some(call.clone()),
1481            _ => None,
1482        })
1483        .collect()
1484}