1use 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 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 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 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 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 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 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 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 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 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 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 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 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 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}