1use super::checkpointing::{
4 AgentProgress, save_checkpoint, validate_exact_usage, validate_usage_floor,
5};
6use super::observability::{consume_budget, emit_usage, record_domain, terminal_event};
7use super::{
8 Agent, AgentCheckpoint, AgentCheckpointPhase, AgentCheckpointState, AgentError,
9 AgentEventStream, AgentFuture, AgentObserver, AgentOutcome, AgentStreamEvent, Arc,
10 BufferedObserver, CheckpointCursor, ContentPart, DurableConversationCheckpoint, Either,
11 EventId, Instant, LifecycleEvent, Message, ModelCallContext, ModelError, ModelErrorKind,
12 ModelRequest, ModelResponse, ModelStreamAccumulator, NoopObserver, ResumePolicy, Role,
13 RunContext, RunEventKind, StreamExt, ToolCall, Usage, emit_agent_event, select,
14};
15use crate::conversation::{
16 AgentConversationError, AgentConversationOutcome, AutomaticConversationSummary,
17 ConversationAppend, ConversationContextPolicy, ConversationId, ConversationStore,
18 ConversationSummaryCommit, ConversationSummaryRequest, DurableConversationCommit,
19 DurableConversationRequest, DurableConversationStore, MemoryNamespace, SemanticMemoryQuery,
20 is_transient_context, semantic_memory_message, summary_message,
21};
22use runifold_core::{CheckpointId, CheckpointStore};
23use runifold_retrieval::RetrievalContext;
24
25impl Agent {
26 pub fn prompt<'a>(
33 &'a self,
34 input: impl Into<String> + Send + 'a,
35 ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
36 let input = input.into();
37 Box::pin(async move {
38 let run = self.default_run_context();
39 self.run(input, &run).await
40 })
41 }
42
43 pub fn prompt_text<'a>(
49 &'a self,
50 input: impl Into<String> + Send + 'a,
51 ) -> AgentFuture<'a, Result<String, AgentError>> {
52 let input = input.into();
53 Box::pin(async move { self.prompt(input).await.map(AgentOutcome::into_text) })
54 }
55
56 pub fn run<'a>(
58 &'a self,
59 input: impl Into<String> + Send + 'a,
60 run: &'a RunContext,
61 ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
62 let input = input.into();
63 let state = self.initial_state(input, run.root_run_id().to_string());
64 Box::pin(async move {
65 self.execute_state(state, run, None, Arc::new(NoopObserver), true, true)
66 .await
67 })
68 }
69
70 pub fn run_conversation<'a>(
76 &'a self,
77 input: impl Into<String> + Send + 'a,
78 run: &'a RunContext,
79 store: &'a dyn ConversationStore,
80 conversation_id: ConversationId,
81 namespace: MemoryNamespace,
82 policy: ConversationContextPolicy,
83 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
84 let input = input.into();
85 Box::pin(async move {
86 store.create(conversation_id, namespace.clone()).await?;
87 let view = store
88 .load_view(
89 conversation_id,
90 namespace.clone(),
91 policy.window,
92 policy.summary_batch,
93 )
94 .await?;
95 if view.requires_summary() {
96 return Err(AgentConversationError::SummaryRequired {
97 conversation_id,
98 buffered_entries: u64::try_from(view.summary_buffer.len())
99 .unwrap_or(u64::MAX)
100 .saturating_add(view.summary_backlog),
101 });
102 }
103 let mut transcript = self.instructions.clone();
104 if let Some(summary) = &view.summary {
105 transcript.push(summary_message(summary));
106 }
107 if let Some(limit) = policy.semantic_memory_limit {
108 let query =
109 SemanticMemoryQuery::new(namespace.clone(), input.clone(), limit.get())?;
110 let search = store
111 .search_memory_scoped(query, RetrievalContext::for_run(run))
112 .await?;
113 if search.usage != Usage::default() {
114 consume_budget(run, search.usage, None).map_err(AgentConversationError::Run)?;
115 }
116 if let Some(message) = semantic_memory_message(&search.memories) {
117 transcript.push(message);
118 }
119 }
120 transcript.extend(view.window.iter().map(|entry| entry.message.clone()));
121 let persisted_prefix_len = transcript.len();
122 transcript.push(Message::user(input));
123 let state =
124 self.initial_state_from_transcript(transcript, run.root_run_id().to_string());
125 let outcome = self
126 .execute_state(state, run, None, Arc::new(NoopObserver), true, true)
127 .await
128 .map_err(AgentConversationError::Run)?;
129 let messages = outcome
130 .transcript
131 .iter()
132 .skip(persisted_prefix_len)
133 .filter(|message| !is_transient_context(message))
134 .cloned()
135 .collect();
136 let append = ConversationAppend {
137 conversation_id,
138 expected_version: view.version,
139 messages,
140 };
141 match store.append(namespace, append).await {
142 Ok(conversation_version) => Ok(AgentConversationOutcome {
143 outcome,
144 conversation_version,
145 }),
146 Err(source) => Err(AgentConversationError::Commit {
147 source,
148 outcome: Box::new(outcome),
149 }),
150 }
151 })
152 }
153
154 pub fn run_conversation_with_summary<'a>(
160 &'a self,
161 input: impl Into<String> + Send + 'a,
162 run: &'a RunContext,
163 store: &'a dyn ConversationStore,
164 conversation_id: ConversationId,
165 namespace: MemoryNamespace,
166 automatic_summary: AutomaticConversationSummary<'a>,
167 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
168 let input = input.into();
169 Box::pin(async move {
170 let policy = automatic_summary.context;
171 store.create(conversation_id, namespace.clone()).await?;
172 for pass in 0..automatic_summary.max_passes.get() {
173 let view = store
174 .load_view(
175 conversation_id,
176 namespace.clone(),
177 policy.window,
178 policy.summary_batch,
179 )
180 .await?;
181 let Some(through_sequence) = view.summary_buffer.last().map(|entry| entry.sequence)
182 else {
183 break;
184 };
185 let summary_backlog = view.summary_backlog;
186 let summary = automatic_summary
187 .summarizer
188 .summarize(
189 ConversationSummaryRequest {
190 transcript_version: view.version,
191 previous_summary: view.summary,
192 entries: view.summary_buffer,
193 },
194 run,
195 )
196 .await?;
197 store
198 .commit_summary(
199 namespace.clone(),
200 ConversationSummaryCommit {
201 conversation_id,
202 expected_version: view.version,
203 through_sequence,
204 content: summary,
205 },
206 )
207 .await?;
208 if summary_backlog == 0 {
209 break;
210 }
211 if pass + 1 == automatic_summary.max_passes.get() {
212 return Err(AgentConversationError::SummaryPassLimitExceeded {
213 conversation_id,
214 remaining_entries: summary_backlog,
215 });
216 }
217 }
218 self.run_conversation(input, run, store, conversation_id, namespace, policy)
219 .await
220 })
221 }
222
223 pub fn stream<'a>(
225 &'a self,
226 input: impl Into<String> + Send + 'a,
227 run: &'a RunContext,
228 ) -> AgentEventStream<'a> {
229 let state = self.initial_state(input.into(), run.root_run_id().to_string());
230 let observer = BufferedObserver::default();
231 let events = observer.events();
232 let execution =
233 Box::pin(self.execute_state(state, run, None, Arc::new(observer), true, true));
234 AgentEventStream::new(execution, events)
235 }
236
237 pub fn run_durable_conversation<'a>(
243 &'a self,
244 input: impl Into<String> + Send + 'a,
245 run: &'a RunContext,
246 store: Arc<dyn DurableConversationStore>,
247 request: DurableConversationRequest,
248 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
249 let input = input.into();
250 Box::pin(async move {
251 let DurableConversationRequest {
252 checkpoint_id,
253 conversation_id,
254 namespace,
255 policy,
256 } = request;
257 store.create(conversation_id, namespace.clone()).await?;
258 let view = store
259 .load_view(
260 conversation_id,
261 namespace.clone(),
262 policy.window,
263 policy.summary_batch,
264 )
265 .await?;
266 if view.requires_summary() {
267 return Err(AgentConversationError::SummaryRequired {
268 conversation_id,
269 buffered_entries: u64::try_from(view.summary_buffer.len())
270 .unwrap_or(u64::MAX)
271 .saturating_add(view.summary_backlog),
272 });
273 }
274 let mut transcript = self.instructions.clone();
275 if let Some(summary) = &view.summary {
276 transcript.push(summary_message(summary));
277 }
278 if let Some(limit) = policy.semantic_memory_limit {
279 let query =
280 SemanticMemoryQuery::new(namespace.clone(), input.clone(), limit.get())?;
281 let search = store
282 .search_memory_scoped(query, RetrievalContext::for_run(run))
283 .await?;
284 if search.usage != Usage::default() {
285 consume_budget(run, search.usage, None).map_err(AgentConversationError::Run)?;
286 }
287 if let Some(message) = semantic_memory_message(&search.memories) {
288 transcript.push(message);
289 }
290 }
291 transcript.extend(view.window.iter().map(|entry| entry.message.clone()));
292 let persisted_prefix_len = u64::try_from(transcript.len()).map_err(|_| {
293 AgentConversationError::Run(checkpoint_payload_error(
294 "conversation context length exceeds durable checkpoint range",
295 ))
296 })?;
297 transcript.push(Message::user(input));
298 let durable = DurableConversationCheckpoint {
299 conversation_id,
300 namespace,
301 expected_version: view.version,
302 persisted_prefix_len,
303 };
304 let mut state =
305 self.initial_state_from_transcript(transcript, checkpoint_id.to_string());
306 state.durable_conversation = Some(durable.clone());
307 state.usage = run.budget().usage();
308 let checkpoint_store: Arc<dyn CheckpointStore> = store.clone();
309 let checkpoint = AgentCheckpoint::existing(checkpoint_id, checkpoint_store);
310 let mut cursor = CheckpointCursor::create(&checkpoint, run, &state)
311 .map_err(AgentConversationError::Run)?;
312 let outcome = self
313 .execute_state(
314 state,
315 run,
316 Some(&mut cursor),
317 Arc::new(NoopObserver),
318 true,
319 false,
320 )
321 .await
322 .map_err(AgentConversationError::Run)?;
323 self.commit_durable_outcome(store.as_ref(), run, &cursor, durable, outcome)
324 .await
325 })
326 }
327
328 pub fn resume_durable_conversation<'a>(
330 &'a self,
331 store: Arc<dyn DurableConversationStore>,
332 checkpoint_id: CheckpointId,
333 run: &'a RunContext,
334 policy: ResumePolicy,
335 ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
336 Box::pin(async move {
337 let checkpoint_store: Arc<dyn CheckpointStore> = store.clone();
338 let checkpoint = AgentCheckpoint::existing(checkpoint_id, checkpoint_store);
339 let (envelope, mut state) = checkpoint
340 .load()
341 .map_err(AgentError::from)
342 .map_err(AgentConversationError::Run)?;
343 self.validate_checkpoint_identity(&state)
344 .map_err(AgentConversationError::Run)?;
345 let durable = state.durable_conversation.clone().ok_or_else(|| {
346 AgentConversationError::Run(checkpoint_payload_error(
347 "checkpoint is not a durable conversation turn",
348 ))
349 })?;
350 if let Some(outcome) = state.outcome() {
351 let conversation_version = durable
352 .expected_version
353 .get()
354 .checked_add(1)
355 .map(crate::ConversationVersion::new)
356 .ok_or_else(|| {
357 AgentConversationError::Run(checkpoint_payload_error(
358 "durable conversation version overflow",
359 ))
360 })?;
361 return Ok(AgentConversationOutcome {
362 outcome,
363 conversation_version,
364 });
365 }
366 if let AgentCheckpointPhase::TurnInFlight { turn } = state.phase {
367 if policy == ResumePolicy::RejectAmbiguous {
368 return Err(AgentConversationError::Run(
369 AgentError::AmbiguousCheckpoint { turn },
370 ));
371 }
372 validate_usage_floor(state.usage, run.budget().usage())
373 .map_err(AgentConversationError::Run)?;
374 state.usage = run.budget().usage();
375 state.phase = AgentCheckpointPhase::ReadyForTurn;
376 } else {
377 validate_exact_usage(state.usage, run.budget().usage())
378 .map_err(AgentConversationError::Run)?;
379 }
380 let mut cursor = CheckpointCursor::loaded(&checkpoint, envelope);
381 let outcome = self
382 .execute_state(
383 state,
384 run,
385 Some(&mut cursor),
386 Arc::new(NoopObserver),
387 false,
388 false,
389 )
390 .await
391 .map_err(AgentConversationError::Run)?;
392 self.commit_durable_outcome(store.as_ref(), run, &cursor, durable, outcome)
393 .await
394 })
395 }
396
397 pub fn run_checkpointed<'a>(
399 &'a self,
400 input: impl Into<String> + Send + 'a,
401 run: &'a RunContext,
402 checkpoint: &'a AgentCheckpoint,
403 ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
404 let input = input.into();
405 Box::pin(async move {
406 let mut state = self.initial_state(input, checkpoint.id().to_string());
407 state.usage = run.budget().usage();
408 let mut cursor = CheckpointCursor::create(checkpoint, run, &state)?;
409 self.execute_state(
410 state,
411 run,
412 Some(&mut cursor),
413 Arc::new(NoopObserver),
414 true,
415 true,
416 )
417 .await
418 })
419 }
420
421 pub fn resume<'a>(
423 &'a self,
424 checkpoint: &'a AgentCheckpoint,
425 run: &'a RunContext,
426 policy: ResumePolicy,
427 ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
428 Box::pin(async move {
429 let (envelope, mut state) = checkpoint.load()?;
430 self.validate_checkpoint_identity(&state)?;
431 if let Some(outcome) = state.outcome() {
432 validate_exact_usage(state.usage, run.budget().usage())?;
433 return Ok(outcome);
434 }
435 if let AgentCheckpointPhase::TurnInFlight { turn } = state.phase {
436 if policy == ResumePolicy::RejectAmbiguous {
437 return Err(AgentError::AmbiguousCheckpoint { turn });
438 }
439 validate_usage_floor(state.usage, run.budget().usage())?;
440 state.usage = run.budget().usage();
441 state.phase = AgentCheckpointPhase::ReadyForTurn;
442 } else {
443 validate_exact_usage(state.usage, run.budget().usage())?;
444 }
445 let mut cursor = CheckpointCursor::loaded(checkpoint, envelope);
446 self.execute_state(
447 state,
448 run,
449 Some(&mut cursor),
450 Arc::new(NoopObserver),
451 false,
452 true,
453 )
454 .await
455 })
456 }
457
458 fn initial_state(&self, input: String, execution_id: String) -> AgentCheckpointState {
459 let mut transcript = self.instructions.clone();
460 transcript.push(Message::user(input));
461 self.initial_state_from_transcript(transcript, execution_id)
462 }
463
464 fn initial_state_from_transcript(
465 &self,
466 transcript: Vec<Message>,
467 execution_id: String,
468 ) -> AgentCheckpointState {
469 AgentCheckpointState {
470 execution_id,
471 agent: self.name.clone(),
472 model: self.model_ref.clone(),
473 transcript,
474 turns: 0,
475 tool_calls: 0,
476 delegations: 0,
477 usage: Usage::default(),
478 phase: AgentCheckpointPhase::ReadyForTurn,
479 durable_conversation: None,
480 }
481 }
482
483 async fn execute_state(
484 &self,
485 state: AgentCheckpointState,
486 run: &RunContext,
487 mut checkpoint: Option<&mut CheckpointCursor>,
488 observer: Arc<dyn AgentObserver>,
489 retrieve_context: bool,
490 persist_terminal_checkpoint: bool,
491 ) -> Result<AgentOutcome, AgentError> {
492 let started = run
493 .record(
494 RunEventKind::Lifecycle(LifecycleEvent::Started),
495 run.caused_by(),
496 )?
497 .map(|event| event.meta.event_id);
498 emit_agent_event(
499 observer.as_ref(),
500 AgentStreamEvent::Started {
501 agent: self.name.clone(),
502 },
503 )
504 .await;
505 let result = async {
506 let has_context = !self.context.is_empty() || !self.dynamic_context.is_empty();
507 let state = if retrieve_context && has_context {
508 let mut prepared = self
509 .prepare_context(state, run, started, observer.as_ref())
510 .await?;
511 prepared.usage = run.budget().usage();
512 save_checkpoint(&mut checkpoint, &prepared)?;
513 prepared
514 } else {
515 state
516 };
517 self.run_loop(
518 state,
519 run,
520 started,
521 checkpoint,
522 observer.as_ref(),
523 persist_terminal_checkpoint,
524 )
525 .await
526 }
527 .await;
528 let terminal = terminal_event(&self.name, &result);
529 run.record(terminal, started)?;
530 if let Ok(outcome) = &result {
531 emit_agent_event(
532 observer.as_ref(),
533 AgentStreamEvent::Completed {
534 outcome: outcome.clone(),
535 },
536 )
537 .await;
538 }
539 result
540 }
541
542 async fn run_loop(
543 &self,
544 state: AgentCheckpointState,
545 run: &RunContext,
546 caused_by: Option<EventId>,
547 mut checkpoint: Option<&mut CheckpointCursor>,
548 observer: &dyn AgentObserver,
549 persist_terminal_checkpoint: bool,
550 ) -> Result<AgentOutcome, AgentError> {
551 self.validate_config()?;
552 let mut progress = AgentProgress::from(state);
553
554 loop {
555 Self::check_lifecycle(run)?;
556 if progress.turns >= self.config.max_turns {
557 return Err(AgentError::MaxTurns {
558 max_turns: self.config.max_turns,
559 });
560 }
561 save_checkpoint(
562 &mut checkpoint,
563 &self.checkpoint_state(
564 &progress,
565 run,
566 AgentCheckpointPhase::TurnInFlight {
567 turn: progress.turns + 1,
568 },
569 ),
570 )?;
571 consume_budget(
572 run,
573 Usage {
574 turns: 1,
575 ..Usage::default()
576 },
577 caused_by,
578 )?;
579 progress.turns += 1;
580 emit_agent_event(
581 observer,
582 AgentStreamEvent::TurnStarted {
583 turn: progress.turns,
584 },
585 )
586 .await;
587 emit_usage(observer, run).await;
588 record_domain(
589 run,
590 "turn.started",
591 serde_json::json!({"agent": self.name, "turn": progress.turns}),
592 caused_by,
593 )?;
594
595 let response = self
596 .invoke_model(
597 &progress.transcript,
598 run,
599 progress.turns,
600 caused_by,
601 observer,
602 )
603 .await?;
604
605 let calls = tool_calls_from(&response.content);
606 let assistant = Message::new(Role::Assistant, response.content.clone())
607 .map_err(|error| AgentError::Protocol(error.to_string()))?;
608 progress.transcript.push(assistant);
609
610 if calls.is_empty() {
611 if matches!(
612 response.finish_reason,
613 runifold_model::FinishReason::ToolCalls
614 ) {
615 return Err(AgentError::Protocol(
616 "model stopped for tool calls without emitting a tool call".into(),
617 ));
618 }
619 if persist_terminal_checkpoint {
620 save_checkpoint(
621 &mut checkpoint,
622 &self.checkpoint_state(
623 &progress,
624 run,
625 AgentCheckpointPhase::Completed {
626 response: Box::new(response.clone()),
627 },
628 ),
629 )?;
630 }
631 return Ok(progress.outcome(response, run.budget().usage()));
632 }
633
634 self.execute_calls(calls, run, caused_by, &mut progress, observer)
635 .await?;
636 save_checkpoint(
637 &mut checkpoint,
638 &self.checkpoint_state(&progress, run, AgentCheckpointPhase::ReadyForTurn),
639 )?;
640 }
641 }
642
643 async fn invoke_model(
644 &self,
645 transcript: &[Message],
646 run: &RunContext,
647 turn: u32,
648 caused_by: Option<EventId>,
649 observer: &dyn AgentObserver,
650 ) -> Result<ModelResponse, AgentError> {
651 record_domain(
652 run,
653 "model.started",
654 serde_json::json!({
655 "agent": self.name,
656 "turn": turn,
657 "provider": self.model_ref.provider,
658 "model": self.model_ref.name,
659 }),
660 caused_by,
661 )?;
662 let response = match self
663 .stream_model_response(self.request(transcript)?, run, turn, observer)
664 .await
665 {
666 Ok(response) => response,
667 Err(error) => {
668 record_domain(
669 run,
670 "model.failed",
671 serde_json::json!({
672 "agent": self.name,
673 "turn": turn,
674 "kind": format!("{:?}", error.kind),
675 }),
676 caused_by,
677 )?;
678 return Err(error.into());
679 }
680 };
681 record_domain(
682 run,
683 "model.completed",
684 serde_json::json!({
685 "agent": self.name,
686 "turn": turn,
687 "finish_reason": response.finish_reason,
688 "usage": response.usage,
689 }),
690 caused_by,
691 )?;
692 consume_budget(run, response.usage.into(), caused_by)?;
693 emit_usage(observer, run).await;
694 Ok(response)
695 }
696
697 async fn stream_model_response(
698 &self,
699 request: ModelRequest,
700 run: &RunContext,
701 turn: u32,
702 observer: &dyn AgentObserver,
703 ) -> Result<ModelResponse, ModelError> {
704 let context = ModelCallContext::for_run(run);
705 let cancellation = context.cancellation().clone();
706 let opening = self.model.stream(request, context);
707 let mut stream = match select(Box::pin(cancellation.cancelled()), Box::pin(opening)).await {
708 Either::Left(_) => return Err(cancelled_model_error()),
709 Either::Right((result, _)) => result?,
710 };
711 let mut accumulator = ModelStreamAccumulator::new();
712 loop {
713 let next = stream.next();
714 let event = match select(Box::pin(cancellation.cancelled()), Box::pin(next)).await {
715 Either::Left(_) => return Err(cancelled_model_error()),
716 Either::Right((Some(event), _)) => event?,
717 Either::Right((None, _)) => {
718 return Err(ModelError::local(
719 ModelErrorKind::Protocol,
720 "model stream ended before a terminal response event",
721 ));
722 }
723 };
724 let response = accumulator.push(event.clone())?;
725 emit_agent_event(observer, AgentStreamEvent::Model { turn, event }).await;
726 if let Some(response) = response {
727 return Ok(response);
728 }
729 }
730 }
731
732 fn validate_config(&self) -> Result<(), AgentError> {
733 if self.name.trim().is_empty() {
734 return Err(AgentError::InvalidConfig(
735 "agent name cannot be empty".into(),
736 ));
737 }
738 if self.config.max_turns == 0 {
739 return Err(AgentError::InvalidConfig(
740 "max_turns must be greater than zero".into(),
741 ));
742 }
743 if let Some(collision) = self
744 .agents
745 .model_specs()
746 .into_iter()
747 .find(|spec| self.tools.contains(&spec.name))
748 {
749 return Err(AgentError::InvalidConfig(format!(
750 "callable name `{}` is registered as both a tool and an agent",
751 collision.name
752 )));
753 }
754 Ok(())
755 }
756
757 fn validate_checkpoint_identity(&self, state: &AgentCheckpointState) -> Result<(), AgentError> {
758 if state.agent != self.name || state.model != self.model_ref {
759 return Err(runifold_core::CheckpointError::new(
760 runifold_core::CheckpointErrorKind::InvalidPayload,
761 "checkpoint Agent or model identity does not match",
762 )
763 .into());
764 }
765 Ok(())
766 }
767
768 fn checkpoint_state(
769 &self,
770 progress: &AgentProgress,
771 run: &RunContext,
772 phase: AgentCheckpointPhase,
773 ) -> AgentCheckpointState {
774 AgentCheckpointState {
775 execution_id: progress.execution_id.clone(),
776 agent: self.name.clone(),
777 model: self.model_ref.clone(),
778 transcript: progress.transcript.clone(),
779 turns: progress.turns,
780 tool_calls: progress.tool_calls,
781 delegations: progress.delegations,
782 usage: run.budget().usage(),
783 phase,
784 durable_conversation: progress.durable_conversation.clone(),
785 }
786 }
787
788 async fn commit_durable_outcome(
789 &self,
790 store: &dyn DurableConversationStore,
791 run: &RunContext,
792 cursor: &CheckpointCursor,
793 durable: DurableConversationCheckpoint,
794 outcome: AgentOutcome,
795 ) -> Result<AgentConversationOutcome, AgentConversationError> {
796 let persisted_prefix_len = usize::try_from(durable.persisted_prefix_len).map_err(|_| {
797 AgentConversationError::Run(checkpoint_payload_error(
798 "durable conversation prefix does not fit this platform",
799 ))
800 })?;
801 if persisted_prefix_len >= outcome.transcript.len() {
802 return Err(AgentConversationError::Run(checkpoint_payload_error(
803 "durable conversation checkpoint has an invalid transcript prefix",
804 )));
805 }
806 let messages = outcome
807 .transcript
808 .iter()
809 .skip(persisted_prefix_len)
810 .filter(|message| !is_transient_context(message))
811 .cloned()
812 .collect();
813 let state = AgentCheckpointState {
814 execution_id: cursor.id().to_string(),
815 agent: self.name.clone(),
816 model: self.model_ref.clone(),
817 transcript: outcome.transcript.clone(),
818 turns: outcome.turns,
819 tool_calls: outcome.tool_calls,
820 delegations: outcome.delegations,
821 usage: run.budget().usage(),
822 phase: AgentCheckpointPhase::Completed {
823 response: Box::new(outcome.response.clone()),
824 },
825 durable_conversation: Some(durable.clone()),
826 };
827 let checkpoint = cursor.next(&state).map_err(AgentConversationError::Run)?;
828 let command = DurableConversationCommit {
829 namespace: durable.namespace,
830 append: ConversationAppend {
831 conversation_id: durable.conversation_id,
832 expected_version: durable.expected_version,
833 messages,
834 },
835 checkpoint,
836 expected_checkpoint_revision: cursor.revision(),
837 };
838 match store.commit_durable_turn(command).await {
839 Ok(conversation_version) => Ok(AgentConversationOutcome {
840 outcome,
841 conversation_version,
842 }),
843 Err(source) => Err(AgentConversationError::Commit {
844 source,
845 outcome: Box::new(outcome),
846 }),
847 }
848 }
849
850 pub(super) fn check_lifecycle(run: &RunContext) -> Result<(), AgentError> {
851 let error = if run.cancellation().is_cancelled() {
852 Some((
853 runifold_model::ModelErrorKind::Cancelled,
854 "agent run was cancelled",
855 ))
856 } else if run
857 .deadline()
858 .is_some_and(|deadline| deadline <= Instant::now())
859 {
860 Some((
861 runifold_model::ModelErrorKind::DeadlineExceeded,
862 "agent run deadline elapsed",
863 ))
864 } else {
865 None
866 };
867 if let Some((kind, message)) = error {
868 return Err(runifold_model::ModelError::local(kind, message).into());
869 }
870 Ok(())
871 }
872
873 fn request(&self, transcript: &[Message]) -> Result<ModelRequest, AgentError> {
874 let (first, rest) = transcript
875 .split_first()
876 .ok_or_else(|| AgentError::Protocol("agent transcript is empty".into()))?;
877 let mut request = ModelRequest::new(self.model_ref.clone(), first.clone());
878 request.messages.extend_from_slice(rest);
879 request.tools = self.tools.model_specs();
880 request.tools.extend(self.agents.model_specs());
881 for tool in &self.provider_tools {
882 request = request.provider_tool(tool.clone());
883 }
884 request.generation.clone_from(&self.generation);
885 request = request.response_mode(self.response_mode);
886 request.provider_options.clone_from(&self.provider_options);
887 request.feature_policy = self.config.feature_policy;
888 request.output_format.clone_from(&self.output_format);
889 Ok(request)
890 }
891}
892
893fn checkpoint_payload_error(message: &str) -> AgentError {
894 runifold_core::CheckpointError::new(runifold_core::CheckpointErrorKind::InvalidPayload, message)
895 .into()
896}
897
898fn cancelled_model_error() -> ModelError {
899 ModelError::local(ModelErrorKind::Cancelled, "model invocation was cancelled")
900}
901
902fn tool_calls_from(content: &[ContentPart]) -> Vec<ToolCall> {
903 content
904 .iter()
905 .filter_map(|part| match part {
906 ContentPart::ToolCall(call) => Some(call.clone()),
907 _ => None,
908 })
909 .collect()
910}