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::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, Either, EventId, Instant, LifecycleEvent,
11    Message, ModelCallContext, ModelError, ModelErrorKind, ModelRequest, ModelResponse,
12    ModelStreamAccumulator, NoopObserver, ResumePolicy, Role, RunContext, RunEventKind, StreamExt,
13    ToolCall, Usage, emit_agent_event, select,
14};
15use crate::conversation::{
16    AgentConversationError, AgentConversationOutcome, AutomaticConversationSummary,
17    ConversationAppend, ConversationContextPolicy, ConversationId, ConversationStore,
18    ConversationSummaryCommit, ConversationSummaryRequest, MemoryNamespace, SemanticMemoryQuery,
19    is_transient_context, semantic_memory_message, summary_message,
20};
21use runifold_retrieval::RetrievalContext;
22
23impl Agent {
24    /// Runs a user text turn with a default root context.
25    ///
26    /// This is the ergonomic surface for one-off prompts. It grants only
27    /// callables registered on this Agent and applies no hard budget limit.
28    /// Use [`Self::run`] when the caller must provide explicit authority,
29    /// budget, deadline, observability, or run-tree identity.
30    pub fn prompt<'a>(
31        &'a self,
32        input: impl Into<String> + Send + 'a,
33    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
34        let input = input.into();
35        Box::pin(async move {
36            let run = self.default_run_context();
37            self.run(input, &run).await
38        })
39    }
40
41    /// Runs an ergonomic prompt and returns only model-visible text.
42    ///
43    /// Rich content, usage, warnings, the canonical transcript, and provider
44    /// events are intentionally discarded. Use [`Self::prompt`] when that
45    /// information matters.
46    pub fn prompt_text<'a>(
47        &'a self,
48        input: impl Into<String> + Send + 'a,
49    ) -> AgentFuture<'a, Result<String, AgentError>> {
50        let input = input.into();
51        Box::pin(async move { self.prompt(input).await.map(AgentOutcome::into_text) })
52    }
53
54    /// Runs a user text turn inside an existing runtime context.
55    pub fn run<'a>(
56        &'a self,
57        input: impl Into<String> + Send + 'a,
58        run: &'a RunContext,
59    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
60        let input = input.into();
61        let state = self.initial_state(input, run.root_run_id().to_string());
62        Box::pin(async move {
63            self.execute_state(state, run, None, Arc::new(NoopObserver), true)
64                .await
65        })
66    }
67
68    /// Runs and atomically commits one bounded multi-turn conversation.
69    ///
70    /// Transcript messages remain append-only. Execution-journal events stay
71    /// in [`runifold_core::Journal`], summaries remain lossy derived views,
72    /// and semantic memory is injected only as explicitly untrusted context.
73    pub fn run_conversation<'a>(
74        &'a self,
75        input: impl Into<String> + Send + 'a,
76        run: &'a RunContext,
77        store: &'a dyn ConversationStore,
78        conversation_id: ConversationId,
79        namespace: MemoryNamespace,
80        policy: ConversationContextPolicy,
81    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
82        let input = input.into();
83        Box::pin(async move {
84            store.create(conversation_id, namespace.clone()).await?;
85            let view = store
86                .load_view(
87                    conversation_id,
88                    namespace.clone(),
89                    policy.window,
90                    policy.summary_batch,
91                )
92                .await?;
93            if view.requires_summary() {
94                return Err(AgentConversationError::SummaryRequired {
95                    conversation_id,
96                    buffered_entries: u64::try_from(view.summary_buffer.len())
97                        .unwrap_or(u64::MAX)
98                        .saturating_add(view.summary_backlog),
99                });
100            }
101            let mut transcript = self.instructions.clone();
102            if let Some(summary) = &view.summary {
103                transcript.push(summary_message(summary));
104            }
105            if let Some(limit) = policy.semantic_memory_limit {
106                let query =
107                    SemanticMemoryQuery::new(namespace.clone(), input.clone(), limit.get())?;
108                let search = store
109                    .search_memory_scoped(query, RetrievalContext::for_run(run))
110                    .await?;
111                if search.usage != Usage::default() {
112                    consume_budget(run, search.usage, None).map_err(AgentConversationError::Run)?;
113                }
114                if let Some(message) = semantic_memory_message(&search.memories) {
115                    transcript.push(message);
116                }
117            }
118            transcript.extend(view.window.iter().map(|entry| entry.message.clone()));
119            let persisted_prefix_len = transcript.len();
120            transcript.push(Message::user(input));
121            let state =
122                self.initial_state_from_transcript(transcript, run.root_run_id().to_string());
123            let outcome = self
124                .execute_state(state, run, None, Arc::new(NoopObserver), true)
125                .await
126                .map_err(AgentConversationError::Run)?;
127            let messages = outcome
128                .transcript
129                .iter()
130                .skip(persisted_prefix_len)
131                .filter(|message| !is_transient_context(message))
132                .cloned()
133                .collect();
134            let append = ConversationAppend {
135                conversation_id,
136                expected_version: view.version,
137                messages,
138            };
139            match store.append(namespace, append).await {
140                Ok(conversation_version) => Ok(AgentConversationOutcome {
141                    outcome,
142                    conversation_version,
143                }),
144                Err(source) => Err(AgentConversationError::Commit {
145                    source,
146                    outcome: Box::new(outcome),
147                }),
148            }
149        })
150    }
151
152    /// Summarizes an overflowing prefix before running a conversational turn.
153    ///
154    /// Summary generation uses the supplied [`AutomaticConversationSummary`]
155    /// and the same [`RunContext`], preserving cancellation, deadline, budget,
156    /// and journal behavior. The immutable transcript is never rewritten.
157    pub fn run_conversation_with_summary<'a>(
158        &'a self,
159        input: impl Into<String> + Send + 'a,
160        run: &'a RunContext,
161        store: &'a dyn ConversationStore,
162        conversation_id: ConversationId,
163        namespace: MemoryNamespace,
164        automatic_summary: AutomaticConversationSummary<'a>,
165    ) -> AgentFuture<'a, Result<AgentConversationOutcome, AgentConversationError>> {
166        let input = input.into();
167        Box::pin(async move {
168            let policy = automatic_summary.context;
169            store.create(conversation_id, namespace.clone()).await?;
170            for pass in 0..automatic_summary.max_passes.get() {
171                let view = store
172                    .load_view(
173                        conversation_id,
174                        namespace.clone(),
175                        policy.window,
176                        policy.summary_batch,
177                    )
178                    .await?;
179                let Some(through_sequence) = view.summary_buffer.last().map(|entry| entry.sequence)
180                else {
181                    break;
182                };
183                let summary_backlog = view.summary_backlog;
184                let summary = automatic_summary
185                    .summarizer
186                    .summarize(
187                        ConversationSummaryRequest {
188                            transcript_version: view.version,
189                            previous_summary: view.summary,
190                            entries: view.summary_buffer,
191                        },
192                        run,
193                    )
194                    .await?;
195                store
196                    .commit_summary(
197                        namespace.clone(),
198                        ConversationSummaryCommit {
199                            conversation_id,
200                            expected_version: view.version,
201                            through_sequence,
202                            content: summary,
203                        },
204                    )
205                    .await?;
206                if summary_backlog == 0 {
207                    break;
208                }
209                if pass + 1 == automatic_summary.max_passes.get() {
210                    return Err(AgentConversationError::SummaryPassLimitExceeded {
211                        conversation_id,
212                        remaining_entries: summary_backlog,
213                    });
214                }
215            }
216            self.run_conversation(input, run, store, conversation_id, namespace, policy)
217                .await
218        })
219    }
220
221    /// Streams real-time events while driving the canonical Agent loop.
222    pub fn stream<'a>(
223        &'a self,
224        input: impl Into<String> + Send + 'a,
225        run: &'a RunContext,
226    ) -> AgentEventStream<'a> {
227        let state = self.initial_state(input.into(), run.root_run_id().to_string());
228        let observer = BufferedObserver::default();
229        let events = observer.events();
230        let execution = Box::pin(self.execute_state(state, run, None, Arc::new(observer), true));
231        AgentEventStream::new(execution, events)
232    }
233
234    /// Runs with write-ahead checkpoint persistence.
235    pub fn run_checkpointed<'a>(
236        &'a self,
237        input: impl Into<String> + Send + 'a,
238        run: &'a RunContext,
239        checkpoint: &'a AgentCheckpoint,
240    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
241        let input = input.into();
242        Box::pin(async move {
243            let mut state = self.initial_state(input, checkpoint.id().to_string());
244            state.usage = run.budget().usage();
245            let mut cursor = CheckpointCursor::create(checkpoint, run, &state)?;
246            self.execute_state(state, run, Some(&mut cursor), Arc::new(NoopObserver), true)
247                .await
248        })
249    }
250
251    /// Resumes a persisted Agent execution.
252    pub fn resume<'a>(
253        &'a self,
254        checkpoint: &'a AgentCheckpoint,
255        run: &'a RunContext,
256        policy: ResumePolicy,
257    ) -> AgentFuture<'a, Result<AgentOutcome, AgentError>> {
258        Box::pin(async move {
259            let (envelope, mut state) = checkpoint.load()?;
260            self.validate_checkpoint_identity(&state)?;
261            if let Some(outcome) = state.outcome() {
262                validate_exact_usage(state.usage, run.budget().usage())?;
263                return Ok(outcome);
264            }
265            if let AgentCheckpointPhase::TurnInFlight { turn } = state.phase {
266                if policy == ResumePolicy::RejectAmbiguous {
267                    return Err(AgentError::AmbiguousCheckpoint { turn });
268                }
269                validate_usage_floor(state.usage, run.budget().usage())?;
270                state.usage = run.budget().usage();
271                state.phase = AgentCheckpointPhase::ReadyForTurn;
272            } else {
273                validate_exact_usage(state.usage, run.budget().usage())?;
274            }
275            let mut cursor = CheckpointCursor::loaded(checkpoint, envelope);
276            self.execute_state(state, run, Some(&mut cursor), Arc::new(NoopObserver), false)
277                .await
278        })
279    }
280
281    fn initial_state(&self, input: String, execution_id: String) -> AgentCheckpointState {
282        let mut transcript = self.instructions.clone();
283        transcript.push(Message::user(input));
284        self.initial_state_from_transcript(transcript, execution_id)
285    }
286
287    fn initial_state_from_transcript(
288        &self,
289        transcript: Vec<Message>,
290        execution_id: String,
291    ) -> AgentCheckpointState {
292        AgentCheckpointState {
293            execution_id,
294            agent: self.name.clone(),
295            model: self.model_ref.clone(),
296            transcript,
297            turns: 0,
298            tool_calls: 0,
299            delegations: 0,
300            usage: Usage::default(),
301            phase: AgentCheckpointPhase::ReadyForTurn,
302        }
303    }
304
305    async fn execute_state(
306        &self,
307        state: AgentCheckpointState,
308        run: &RunContext,
309        mut checkpoint: Option<&mut CheckpointCursor>,
310        observer: Arc<dyn AgentObserver>,
311        retrieve_context: bool,
312    ) -> Result<AgentOutcome, AgentError> {
313        let started = run
314            .record(
315                RunEventKind::Lifecycle(LifecycleEvent::Started),
316                run.caused_by(),
317            )?
318            .map(|event| event.meta.event_id);
319        emit_agent_event(
320            observer.as_ref(),
321            AgentStreamEvent::Started {
322                agent: self.name.clone(),
323            },
324        )
325        .await;
326        let result = async {
327            let has_context = !self.context.is_empty() || !self.dynamic_context.is_empty();
328            let state = if retrieve_context && has_context {
329                let mut prepared = self
330                    .prepare_context(state, run, started, observer.as_ref())
331                    .await?;
332                prepared.usage = run.budget().usage();
333                save_checkpoint(&mut checkpoint, &prepared)?;
334                prepared
335            } else {
336                state
337            };
338            self.run_loop(state, run, started, checkpoint, observer.as_ref())
339                .await
340        }
341        .await;
342        let terminal = terminal_event(&self.name, &result);
343        run.record(terminal, started)?;
344        if let Ok(outcome) = &result {
345            emit_agent_event(
346                observer.as_ref(),
347                AgentStreamEvent::Completed {
348                    outcome: outcome.clone(),
349                },
350            )
351            .await;
352        }
353        result
354    }
355
356    async fn run_loop(
357        &self,
358        state: AgentCheckpointState,
359        run: &RunContext,
360        caused_by: Option<EventId>,
361        mut checkpoint: Option<&mut CheckpointCursor>,
362        observer: &dyn AgentObserver,
363    ) -> Result<AgentOutcome, AgentError> {
364        self.validate_config()?;
365        let mut progress = AgentProgress::from(state);
366
367        loop {
368            Self::check_lifecycle(run)?;
369            if progress.turns >= self.config.max_turns {
370                return Err(AgentError::MaxTurns {
371                    max_turns: self.config.max_turns,
372                });
373            }
374            save_checkpoint(
375                &mut checkpoint,
376                &self.checkpoint_state(
377                    &progress,
378                    run,
379                    AgentCheckpointPhase::TurnInFlight {
380                        turn: progress.turns + 1,
381                    },
382                ),
383            )?;
384            consume_budget(
385                run,
386                Usage {
387                    turns: 1,
388                    ..Usage::default()
389                },
390                caused_by,
391            )?;
392            progress.turns += 1;
393            emit_agent_event(
394                observer,
395                AgentStreamEvent::TurnStarted {
396                    turn: progress.turns,
397                },
398            )
399            .await;
400            emit_usage(observer, run).await;
401            record_domain(
402                run,
403                "turn.started",
404                serde_json::json!({"agent": self.name, "turn": progress.turns}),
405                caused_by,
406            )?;
407
408            let response = self
409                .invoke_model(
410                    &progress.transcript,
411                    run,
412                    progress.turns,
413                    caused_by,
414                    observer,
415                )
416                .await?;
417
418            let calls = tool_calls_from(&response.content);
419            let assistant = Message::new(Role::Assistant, response.content.clone())
420                .map_err(|error| AgentError::Protocol(error.to_string()))?;
421            progress.transcript.push(assistant);
422
423            if calls.is_empty() {
424                if matches!(
425                    response.finish_reason,
426                    runifold_model::FinishReason::ToolCalls
427                ) {
428                    return Err(AgentError::Protocol(
429                        "model stopped for tool calls without emitting a tool call".into(),
430                    ));
431                }
432                save_checkpoint(
433                    &mut checkpoint,
434                    &self.checkpoint_state(
435                        &progress,
436                        run,
437                        AgentCheckpointPhase::Completed {
438                            response: Box::new(response.clone()),
439                        },
440                    ),
441                )?;
442                return Ok(progress.outcome(response, run.budget().usage()));
443            }
444
445            self.execute_calls(calls, run, caused_by, &mut progress, observer)
446                .await?;
447            save_checkpoint(
448                &mut checkpoint,
449                &self.checkpoint_state(&progress, run, AgentCheckpointPhase::ReadyForTurn),
450            )?;
451        }
452    }
453
454    async fn invoke_model(
455        &self,
456        transcript: &[Message],
457        run: &RunContext,
458        turn: u32,
459        caused_by: Option<EventId>,
460        observer: &dyn AgentObserver,
461    ) -> Result<ModelResponse, AgentError> {
462        record_domain(
463            run,
464            "model.started",
465            serde_json::json!({
466                "agent": self.name,
467                "turn": turn,
468                "provider": self.model_ref.provider,
469                "model": self.model_ref.name,
470            }),
471            caused_by,
472        )?;
473        let response = match self
474            .stream_model_response(self.request(transcript)?, run, turn, observer)
475            .await
476        {
477            Ok(response) => response,
478            Err(error) => {
479                record_domain(
480                    run,
481                    "model.failed",
482                    serde_json::json!({
483                        "agent": self.name,
484                        "turn": turn,
485                        "kind": format!("{:?}", error.kind),
486                    }),
487                    caused_by,
488                )?;
489                return Err(error.into());
490            }
491        };
492        record_domain(
493            run,
494            "model.completed",
495            serde_json::json!({
496                "agent": self.name,
497                "turn": turn,
498                "finish_reason": response.finish_reason,
499                "usage": response.usage,
500            }),
501            caused_by,
502        )?;
503        consume_budget(run, response.usage.into(), caused_by)?;
504        emit_usage(observer, run).await;
505        Ok(response)
506    }
507
508    async fn stream_model_response(
509        &self,
510        request: ModelRequest,
511        run: &RunContext,
512        turn: u32,
513        observer: &dyn AgentObserver,
514    ) -> Result<ModelResponse, ModelError> {
515        let context = ModelCallContext::for_run(run);
516        let cancellation = context.cancellation().clone();
517        let opening = self.model.stream(request, context);
518        let mut stream = match select(Box::pin(cancellation.cancelled()), Box::pin(opening)).await {
519            Either::Left(_) => return Err(cancelled_model_error()),
520            Either::Right((result, _)) => result?,
521        };
522        let mut accumulator = ModelStreamAccumulator::new();
523        loop {
524            let next = stream.next();
525            let event = match select(Box::pin(cancellation.cancelled()), Box::pin(next)).await {
526                Either::Left(_) => return Err(cancelled_model_error()),
527                Either::Right((Some(event), _)) => event?,
528                Either::Right((None, _)) => {
529                    return Err(ModelError::local(
530                        ModelErrorKind::Protocol,
531                        "model stream ended before a terminal response event",
532                    ));
533                }
534            };
535            let response = accumulator.push(event.clone())?;
536            emit_agent_event(observer, AgentStreamEvent::Model { turn, event }).await;
537            if let Some(response) = response {
538                return Ok(response);
539            }
540        }
541    }
542
543    fn validate_config(&self) -> Result<(), AgentError> {
544        if self.name.trim().is_empty() {
545            return Err(AgentError::InvalidConfig(
546                "agent name cannot be empty".into(),
547            ));
548        }
549        if self.config.max_turns == 0 {
550            return Err(AgentError::InvalidConfig(
551                "max_turns must be greater than zero".into(),
552            ));
553        }
554        if let Some(collision) = self
555            .agents
556            .model_specs()
557            .into_iter()
558            .find(|spec| self.tools.contains(&spec.name))
559        {
560            return Err(AgentError::InvalidConfig(format!(
561                "callable name `{}` is registered as both a tool and an agent",
562                collision.name
563            )));
564        }
565        Ok(())
566    }
567
568    fn validate_checkpoint_identity(&self, state: &AgentCheckpointState) -> Result<(), AgentError> {
569        if state.agent != self.name || state.model != self.model_ref {
570            return Err(runifold_core::CheckpointError::new(
571                runifold_core::CheckpointErrorKind::InvalidPayload,
572                "checkpoint Agent or model identity does not match",
573            )
574            .into());
575        }
576        Ok(())
577    }
578
579    fn checkpoint_state(
580        &self,
581        progress: &AgentProgress,
582        run: &RunContext,
583        phase: AgentCheckpointPhase,
584    ) -> AgentCheckpointState {
585        AgentCheckpointState {
586            execution_id: progress.execution_id.clone(),
587            agent: self.name.clone(),
588            model: self.model_ref.clone(),
589            transcript: progress.transcript.clone(),
590            turns: progress.turns,
591            tool_calls: progress.tool_calls,
592            delegations: progress.delegations,
593            usage: run.budget().usage(),
594            phase,
595        }
596    }
597
598    pub(super) fn check_lifecycle(run: &RunContext) -> Result<(), AgentError> {
599        let error = if run.cancellation().is_cancelled() {
600            Some((
601                runifold_model::ModelErrorKind::Cancelled,
602                "agent run was cancelled",
603            ))
604        } else if run
605            .deadline()
606            .is_some_and(|deadline| deadline <= Instant::now())
607        {
608            Some((
609                runifold_model::ModelErrorKind::DeadlineExceeded,
610                "agent run deadline elapsed",
611            ))
612        } else {
613            None
614        };
615        if let Some((kind, message)) = error {
616            return Err(runifold_model::ModelError::local(kind, message).into());
617        }
618        Ok(())
619    }
620
621    fn request(&self, transcript: &[Message]) -> Result<ModelRequest, AgentError> {
622        let (first, rest) = transcript
623            .split_first()
624            .ok_or_else(|| AgentError::Protocol("agent transcript is empty".into()))?;
625        let mut request = ModelRequest::new(self.model_ref.clone(), first.clone());
626        request.messages.extend_from_slice(rest);
627        request.tools = self.tools.model_specs();
628        request.tools.extend(self.agents.model_specs());
629        request.feature_policy = self.config.feature_policy;
630        request.output_format.clone_from(&self.output_format);
631        Ok(request)
632    }
633}
634
635fn cancelled_model_error() -> ModelError {
636    ModelError::local(ModelErrorKind::Cancelled, "model invocation was cancelled")
637}
638
639fn tool_calls_from(content: &[ContentPart]) -> Vec<ToolCall> {
640    content
641        .iter()
642        .filter_map(|part| match part {
643            ContentPart::ToolCall(call) => Some(call.clone()),
644            _ => None,
645        })
646        .collect()
647}