Skip to main content

af_agent_runtime/
lib.rs

1//! The single default cloud Agent loop. It emits every model-visible fact and
2//! lifecycle transition before exposing it to projections or UI consumers.
3
4#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7mod builder;
8mod compactor;
9mod error;
10mod extension;
11mod hooks;
12mod model;
13mod output;
14mod recovery;
15mod replay;
16mod tools;
17mod types;
18
19pub use af_agent::CancellationToken;
20pub use af_agent_session::{cancel_events, failure_events, recovery_events};
21use af_context::{InputId, InteractionId, RunId, ToolCallId};
22pub use compactor::ModelCompactor;
23pub use error::RuntimeError;
24use model::ModelTurn;
25use output::assistant_content;
26use replay::{content_text, tool_message, transcript_from_events, user_message};
27use tools::PlannedCall;
28pub use types::{
29    ApproximateTokenMeter, CompactionResult, Compactor, EventWriter, RuntimeLimits, RuntimeOutcome,
30    TokenMeter, TurnRequest,
31};
32
33use std::{collections::HashSet, sync::Arc};
34
35use af_agent::{
36    ChatModel, ContextContributor, Hook, HookDecision, MountedPlugins, PreStepDecision,
37    PromptRegistry, ToolRegistry, TurnStopDecision,
38};
39use af_agent_session::{
40    DeliveryMode, Event, RecordedToolCall, RunStatus, SessionEvent, SessionProjection,
41    ToolAuthorizationStatus,
42};
43use af_llm::{ChatMessage, FinishReason, Role};
44use serde_json::{json, Value};
45
46/// The single Turn/Step loop. Build it with [`AgentRuntime::new`] and the `with_*` methods, then call [`run`](Self::run) per Turn.
47pub struct AgentRuntime {
48    model: Arc<dyn ChatModel>,
49    model_name: String,
50    prompts: PromptRegistry,
51    contexts: Vec<Arc<dyn ContextContributor>>,
52    tools: ToolRegistry,
53    hooks: Vec<Arc<dyn Hook>>,
54    limits: RuntimeLimits,
55    meter: Arc<dyn TokenMeter>,
56    compactor: Arc<dyn Compactor>,
57    plugins: Option<MountedPlugins>,
58    reasoning_effort: Option<af_llm::ReasoningEffort>,
59    output_policy: Option<Value>,
60}
61
62struct InputDrain<'a> {
63    claimed: &'a mut HashSet<InputId>,
64    transcript: &'a mut Vec<ChatMessage>,
65    context_query: &'a mut String,
66    seen_seq: &'a mut u64,
67}
68
69impl AgentRuntime {
70    /// Run one Turn for `request`, appending every model-visible fact through `writer` and honouring `cancellation`.
71    /// Returns the terminal outcome, or an error after the failure has been recorded.
72    pub async fn run(
73        &self,
74        request: TurnRequest,
75        writer: &dyn EventWriter,
76        cancellation: CancellationToken,
77    ) -> Result<RuntimeOutcome, RuntimeError> {
78        let projection = SessionProjection::replay(&request.history)
79            .map_err(|error| RuntimeError::Invariant(error.to_string()))?;
80        if projection
81            .active_run_id
82            .as_deref()
83            .is_some_and(|active| active != request.run_id)
84        {
85            return Err(RuntimeError::SessionBusy);
86        }
87        let input_message = user_message(&request.content)?;
88        let mut transcript = transcript_from_events(&request.history)?;
89        let user_text = content_text(&request.content);
90        if user_text.trim().is_empty() {
91            return Err(RuntimeError::InvalidInput(
92                "text content is required".into(),
93            ));
94        }
95        if projection.active_run_id.is_none() {
96            writer
97                .append(vec![
98                    Event::InputClaimed {
99                        input_id: request.input_id.clone(),
100                        run_id: request.run_id.clone(),
101                    },
102                    Event::RunStarted {
103                        run_id: request.run_id.clone(),
104                        input_id: request.input_id.clone(),
105                    },
106                ])
107                .await?;
108        }
109        let resuming = projection.open_turn.is_some();
110        if !resuming {
111            writer
112                .append(vec![
113                    Event::TurnStarted {
114                        run_id: request.run_id.clone(),
115                        turn: 1,
116                    },
117                    Event::UserMessage {
118                        run_id: request.run_id.clone(),
119                        content: request.content.clone(),
120                    },
121                ])
122                .await?;
123        }
124
125        let mut context_query = user_text.clone();
126        if !resuming {
127            transcript.push(input_message);
128        }
129        let (mut prompt_tokens, mut completion_tokens) = projection.usage_for(&request.run_id);
130        let mut tool_calls = request
131            .history
132            .iter()
133            .filter(|event| {
134                matches!(&event.event, Event::ToolCall { run_id, .. } if run_id == &request.run_id)
135            })
136            .count() as u32;
137        let mut seen_seq = request.history.last().map_or(0, |event| event.seq);
138        let mut claimed_inputs = projection
139            .claimed_inputs
140            .keys()
141            .cloned()
142            .collect::<HashSet<_>>();
143
144        let recovery = self
145            .recover_open_surface(
146                &request,
147                &projection,
148                writer,
149                cancellation.clone(),
150                &mut transcript,
151                (prompt_tokens, completion_tokens),
152            )
153            .await?;
154        if let Some(outcome) = recovery.terminal {
155            return Ok(outcome);
156        }
157        let first_step = recovery.first_step;
158
159        Self::drain_inputs(
160            &request,
161            &request.history,
162            writer,
163            InputDrain {
164                claimed: &mut claimed_inputs,
165                transcript: &mut transcript,
166                context_query: &mut context_query,
167                seen_seq: &mut seen_seq,
168            },
169        )
170        .await?;
171
172        for step in first_step..=self.limits.max_steps {
173            if cancellation.is_cancelled() {
174                return self
175                    .finish_open(
176                        writer,
177                        &request.run_id,
178                        None,
179                        RunStatus::Cancelled,
180                        None,
181                        (prompt_tokens, completion_tokens),
182                    )
183                    .await;
184            }
185            let incoming = writer.load_after(seen_seq).await?;
186            if let Some(last) = incoming.last() {
187                seen_seq = last.seq;
188            }
189            Self::drain_inputs(
190                &request,
191                &incoming,
192                writer,
193                InputDrain {
194                    claimed: &mut claimed_inputs,
195                    transcript: &mut transcript,
196                    context_query: &mut context_query,
197                    seen_seq: &mut seen_seq,
198                },
199            )
200            .await?;
201            let step_context = self.step_context(&request, step);
202            if let PreStepDecision::Reject { reason } = self.pre_step(&step_context).await {
203                writer
204                    .append(vec![Event::Extension {
205                        run_id: request.run_id.clone(),
206                        plugin_id: "af-agent-runtime".into(),
207                        event_type: "step_rejected".into(),
208                        payload: json!({ "step": step, "reason": reason }),
209                    }])
210                    .await?;
211                return self
212                    .finish_open(
213                        writer,
214                        &request.run_id,
215                        None,
216                        RunStatus::Cancelled,
217                        None,
218                        (prompt_tokens, completion_tokens),
219                    )
220                    .await;
221            }
222            writer
223                .append(vec![Event::StepStarted {
224                    run_id: request.run_id.clone(),
225                    step,
226                }])
227                .await?;
228            let context_messages = match self
229                .context_messages(&request, step, &context_query, writer, cancellation.clone())
230                .await
231            {
232                Ok(messages) => messages,
233                Err(RuntimeError::Cancelled) => {
234                    return self
235                        .finish_open(
236                            writer,
237                            &request.run_id,
238                            Some(step),
239                            RunStatus::Cancelled,
240                            None,
241                            (prompt_tokens, completion_tokens),
242                        )
243                        .await
244                }
245                Err(error) => return Err(error),
246            };
247            let compacted = match self
248                .compact_if_needed(
249                    writer,
250                    &request.run_id,
251                    step,
252                    &mut transcript,
253                    &context_messages,
254                    cancellation.clone(),
255                )
256                .await
257            {
258                Ok(compacted) => compacted,
259                Err(RuntimeError::Cancelled) => {
260                    return self
261                        .finish_open(
262                            writer,
263                            &request.run_id,
264                            Some(step),
265                            RunStatus::Cancelled,
266                            None,
267                            (prompt_tokens, completion_tokens),
268                        )
269                        .await
270                }
271                Err(RuntimeError::CompactionConflict) => {
272                    writer
273                        .append(vec![Event::StepFinished {
274                            run_id: request.run_id.clone(),
275                            step,
276                        }])
277                        .await?;
278                    continue;
279                }
280                Err(error) => return Err(error),
281            };
282            prompt_tokens += compacted.0;
283            completion_tokens += compacted.1;
284            let completion = match self
285                .complete_with_retry(
286                    writer,
287                    ModelTurn {
288                        run_id: &request.run_id,
289                        step,
290                        step_context: &step_context,
291                        transcript: &transcript,
292                        context: &context_messages,
293                        after_seq: seen_seq,
294                    },
295                    cancellation.clone(),
296                )
297                .await
298            {
299                Ok(completion) => completion,
300                Err(RuntimeError::Cancelled) => {
301                    return self
302                        .finish_open(
303                            writer,
304                            &request.run_id,
305                            Some(step),
306                            RunStatus::Cancelled,
307                            None,
308                            (prompt_tokens, completion_tokens),
309                        )
310                        .await
311                }
312                Err(RuntimeError::Steered) => {
313                    writer
314                        .append(vec![Event::StepFinished {
315                            run_id: request.run_id.clone(),
316                            step,
317                        }])
318                        .await?;
319                    continue;
320                }
321                Err(error) => return Err(error),
322            };
323            prompt_tokens += completion.prompt_tokens;
324            completion_tokens += completion.completion_tokens;
325            let response = completion.response;
326            if prompt_tokens + completion_tokens > self.limits.max_tokens {
327                return self
328                    .finish_open(
329                        writer,
330                        &request.run_id,
331                        Some(step),
332                        RunStatus::MaxStepsReached,
333                        None,
334                        (prompt_tokens, completion_tokens),
335                    )
336                    .await;
337            }
338            let choice = response
339                .choices
340                .into_iter()
341                .next()
342                .ok_or(RuntimeError::EmptyModelResponse)?;
343            let finish_reason = choice
344                .finish_reason
345                .ok_or_else(|| RuntimeError::Model("missing finish_reason".into()))?;
346            let message = choice.message;
347            let output_blocks = choice.output_blocks;
348            match finish_reason {
349                FinishReason::Length => {
350                    return self
351                        .finish_open(
352                            writer,
353                            &request.run_id,
354                            Some(step),
355                            RunStatus::MaxStepsReached,
356                            None,
357                            (prompt_tokens, completion_tokens),
358                        )
359                        .await
360                }
361                FinishReason::ContentFilter | FinishReason::Unknown(_) => {
362                    return Err(RuntimeError::FinishReason(finish_reason))
363                }
364                FinishReason::Stop => {
365                    let (content, answer) =
366                        assistant_content(message.content.as_deref(), output_blocks)?;
367                    if let Some(policy) = &self.output_policy {
368                        af_agent::validate_json_schema_value(
369                            policy,
370                            &serde_json::to_value(&content)
371                                .map_err(|error| RuntimeError::Invariant(error.to_string()))?,
372                            "assistant output",
373                        )
374                        .map_err(RuntimeError::Model)?;
375                    }
376                    if let TurnStopDecision::Steer { content: steer } =
377                        self.turn_stopping(&step_context).await
378                    {
379                        let steer_message = user_message(&steer)?;
380                        writer
381                            .append(vec![
382                                Event::AssistantMessage {
383                                    run_id: request.run_id.clone(),
384                                    step,
385                                    attempt: completion.attempt,
386                                    content,
387                                },
388                                Event::StepFinished {
389                                    run_id: request.run_id.clone(),
390                                    step,
391                                },
392                                Event::UserMessage {
393                                    run_id: request.run_id.clone(),
394                                    content: steer,
395                                },
396                            ])
397                            .await?;
398                        transcript.push(ChatMessage::assistant(answer.clone().unwrap_or_default()));
399                        transcript.push(steer_message);
400                        continue;
401                    }
402                    let terminal = writer
403                        .append(vec![
404                            Event::AssistantMessage {
405                                run_id: request.run_id.clone(),
406                                step,
407                                attempt: completion.attempt,
408                                content,
409                            },
410                            Event::StepFinished {
411                                run_id: request.run_id.clone(),
412                                step,
413                            },
414                            Event::TurnFinished {
415                                run_id: request.run_id.clone(),
416                                turn: 1,
417                            },
418                            Event::RunFinished {
419                                run_id: request.run_id.clone(),
420                                status: RunStatus::Completed,
421                                error_code: None,
422                            },
423                        ])
424                        .await;
425                    if let Err(error) = terminal {
426                        let pending = writer.load_after(seen_seq).await?;
427                        if pending.iter().any(|event| matches!(
428                            &event.event,
429                            Event::InputQueued { run_id, mode: af_agent_session::DeliveryMode::Steer | af_agent_session::DeliveryMode::Inject, .. }
430                                if run_id == &request.run_id
431                        )) {
432                            writer
433                                .append(vec![Event::StepFinished {
434                                    run_id: request.run_id.clone(),
435                                    step,
436                                }])
437                                .await?;
438                            continue;
439                        }
440                        return Err(error);
441                    }
442                    return Ok(RuntimeOutcome {
443                        status: RunStatus::Completed.as_str().into(),
444                        final_text: answer,
445                        prompt_tokens,
446                        completion_tokens,
447                        waiting_interaction_id: None,
448                    });
449                }
450                FinishReason::ToolCalls => {}
451            }
452            if !output_blocks.is_empty() {
453                return Err(RuntimeError::Model(
454                    "structured output cannot accompany tool calls".into(),
455                ));
456            }
457            let calls = message.tool_calls.clone().unwrap_or_default();
458            if calls.is_empty() {
459                return Err(RuntimeError::Model(
460                    "finish_reason tool_calls without tool_calls".into(),
461                ));
462            }
463            if tool_calls + calls.len() as u32 > self.limits.max_tool_calls {
464                return self
465                    .finish_open(
466                        writer,
467                        &request.run_id,
468                        Some(step),
469                        RunStatus::MaxStepsReached,
470                        None,
471                        (prompt_tokens, completion_tokens),
472                    )
473                    .await;
474            }
475            let planned = calls
476                .into_iter()
477                .map(|call| {
478                    let id: ToolCallId = call.id.clone();
479                    let canonical_name = self
480                        .tools
481                        .canonical_name(&call.function.name)
482                        .map(str::to_string);
483                    let (arguments, preflight_error) =
484                        match serde_json::from_str(&call.function.arguments) {
485                            Ok(arguments) => {
486                                let error = self
487                                    .tools
488                                    .validate_arguments(&call.function.name, &arguments)
489                                    .err();
490                                (arguments, error)
491                            }
492                            Err(error) => (
493                                json!({"_raw":call.function.arguments}),
494                                Some(format!("invalid JSON arguments: {error}")),
495                            ),
496                        };
497                    PlannedCall {
498                        transcript_id: id.clone(),
499                        id,
500                        name: canonical_name.unwrap_or(call.function.name),
501                        arguments,
502                        step,
503                        source_event_seq: 0,
504                        preflight_error,
505                    }
506                })
507                .collect::<Vec<_>>();
508            let mut durable_calls = vec![Event::AssistantToolCalls {
509                run_id: request.run_id.clone(),
510                step,
511                content: message.content.clone(),
512                calls: planned
513                    .iter()
514                    .map(|call| RecordedToolCall {
515                        call_id: call.id.clone(),
516                        tool: call.name.clone(),
517                        arguments: call.arguments.clone(),
518                    })
519                    .collect(),
520            }];
521            durable_calls.extend(planned.iter().map(|call| Event::ToolCall {
522                run_id: request.run_id.clone(),
523                step,
524                call_id: call.id.clone(),
525                tool: call.name.clone(),
526                arguments: call.arguments.clone(),
527            }));
528            let appended_calls = writer.append(durable_calls).await?;
529            transcript.push(ChatMessage {
530                images: Vec::new(),
531                role: Role::Assistant,
532                content: message.content,
533                tool_calls: Some(
534                    planned
535                        .iter()
536                        .map(|call| af_llm::ToolCall {
537                            id: call.id.clone(),
538                            kind: "function".into(),
539                            function: af_llm::FunctionCall {
540                                name: af_agent::model_tool_name(&call.name),
541                                arguments: call.arguments.to_string(),
542                            },
543                        })
544                        .collect(),
545                ),
546                tool_call_id: None,
547                name: None,
548            });
549
550            let mut planned = planned;
551            let mut decisions = Vec::with_capacity(planned.len());
552            for (call, envelope) in planned.iter_mut().zip(appended_calls.iter().skip(1)) {
553                call.source_event_seq = envelope.seq;
554                let decision = if let Some(reason) = &call.preflight_error {
555                    HookDecision::Deny {
556                        reason: reason.clone(),
557                    }
558                } else {
559                    self.authorize_call(&request, call, None, cancellation.clone())
560                        .await
561                };
562                decisions.push(decision);
563            }
564            tool_calls += planned.len() as u32;
565            if let Some(waiting_index) = decisions
566                .iter()
567                .position(|decision| matches!(decision, HookDecision::WaitForInput { .. }))
568            {
569                let mut events = Vec::with_capacity(planned.len() * 2 + 2);
570                for (index, call) in planned.iter().enumerate() {
571                    if index == waiting_index {
572                        events.push(Event::ToolAuthorization {
573                            run_id: request.run_id.clone(),
574                            step,
575                            call_id: call.id.clone(),
576                            status: ToolAuthorizationStatus::Waiting,
577                            reason: None,
578                        });
579                    } else {
580                        let reason = match &decisions[index] {
581                            HookDecision::Deny { reason } => reason.clone(),
582                            _ => "blocked_by_pending_interaction".into(),
583                        };
584                        events.extend([
585                            Event::ToolAuthorization {
586                                run_id: request.run_id.clone(),
587                                step,
588                                call_id: call.id.clone(),
589                                status: ToolAuthorizationStatus::Denied,
590                                reason: Some(reason.clone()),
591                            },
592                            Event::ToolResult {
593                                run_id: request.run_id.clone(),
594                                step,
595                                call_id: call.id.clone(),
596                                result: json!({"error":reason}),
597                                is_error: true,
598                            },
599                        ]);
600                    }
601                }
602                let call = &planned[waiting_index];
603                let HookDecision::WaitForInput { kind, mut payload } =
604                    decisions.swap_remove(waiting_index)
605                else {
606                    unreachable!("waiting decision selected above")
607                };
608                if let Value::Object(object) = &mut payload {
609                    object.insert("call_id".into(), Value::String(call.id.to_string()));
610                    object.insert(
611                        "source_event_seq".into(),
612                        Value::from(call.source_event_seq),
613                    );
614                }
615                let interaction_id = InteractionId::parse(uuid::Uuid::new_v4().to_string())
616                    .expect("uuid interaction ids are never blank");
617                events.extend([
618                    Event::InteractionRequested {
619                        run_id: request.run_id.clone(),
620                        interaction_id: interaction_id.clone(),
621                        kind: if kind == "question" {
622                            af_agent_session::InteractionKind::UserQuestion
623                        } else {
624                            af_agent_session::InteractionKind::Action
625                        },
626                        payload,
627                    },
628                    Event::RunWaiting {
629                        run_id: request.run_id.clone(),
630                        interaction_id: interaction_id.clone(),
631                    },
632                ]);
633                writer.append(events).await?;
634                return Ok(RuntimeOutcome {
635                    status: "waiting_for_input".into(),
636                    final_text: None,
637                    prompt_tokens,
638                    completion_tokens,
639                    waiting_interaction_id: Some(interaction_id),
640                });
641            }
642
643            let mut executable = Vec::new();
644            for (call, decision) in planned.iter().zip(decisions) {
645                match decision {
646                    HookDecision::Continue => {
647                        writer
648                            .append(vec![Event::ToolAuthorization {
649                                run_id: request.run_id.clone(),
650                                step,
651                                call_id: call.id.clone(),
652                                status: ToolAuthorizationStatus::Allowed,
653                                reason: None,
654                            }])
655                            .await?;
656                        executable.push(call.clone());
657                    }
658                    HookDecision::Deny { reason } => {
659                        let value = json!({"error":reason});
660                        writer
661                            .append(vec![
662                                Event::ToolAuthorization {
663                                    run_id: request.run_id.clone(),
664                                    step,
665                                    call_id: call.id.clone(),
666                                    status: ToolAuthorizationStatus::Denied,
667                                    reason: Some(reason),
668                                },
669                                Event::ToolResult {
670                                    run_id: request.run_id.clone(),
671                                    step,
672                                    call_id: call.id.clone(),
673                                    result: value.clone(),
674                                    is_error: true,
675                                },
676                            ])
677                            .await?;
678                        transcript.push(tool_message(call, value));
679                    }
680                    HookDecision::WaitForInput { .. } => {
681                        writer
682                            .append(vec![Event::ToolAuthorization {
683                                run_id: request.run_id.clone(),
684                                step,
685                                call_id: call.id.clone(),
686                                status: ToolAuthorizationStatus::Waiting,
687                                reason: None,
688                            }])
689                            .await?;
690                        unreachable!("waiting decisions are handled as one atomic batch");
691                    }
692                }
693            }
694            let executed = self
695                .execute_tools(&request, &executable, writer, cancellation.clone(), None)
696                .await?;
697            let mut tool_outcome_unknown = false;
698            for (call, result) in executable.into_iter().zip(executed) {
699                let outcome_unknown = matches!(
700                    &result,
701                    Err(error) if error.contains("tool_outcome_unknown")
702                );
703                tool_outcome_unknown |= outcome_unknown;
704                let value = match result {
705                    Ok(value) => value,
706                    Err(error) => json!({"error": error}),
707                };
708                if !outcome_unknown {
709                    if let Err(error) = self
710                        .run_after_hooks(&request, &call, &value, None, cancellation.clone())
711                        .await
712                    {
713                        writer
714                            .append(vec![Event::Extension {
715                                run_id: request.run_id.clone(),
716                                plugin_id: "agentfactory.runtime".into(),
717                                event_type: "after_tool_failed".into(),
718                                payload: json!({"call_id":call.id,"error":error}),
719                            }])
720                            .await?;
721                    }
722                }
723                transcript.push(tool_message(&call, value));
724            }
725            writer
726                .append(vec![Event::StepFinished {
727                    run_id: request.run_id.clone(),
728                    step,
729                }])
730                .await?;
731            if tool_outcome_unknown {
732                return self
733                    .finish_open(
734                        writer,
735                        &request.run_id,
736                        None,
737                        RunStatus::Failed,
738                        None,
739                        (prompt_tokens, completion_tokens),
740                    )
741                    .await;
742            }
743        }
744        self.finish_open(
745            writer,
746            &request.run_id,
747            None,
748            RunStatus::MaxStepsReached,
749            None,
750            (prompt_tokens, completion_tokens),
751        )
752        .await
753    }
754
755    async fn drain_inputs(
756        request: &TurnRequest,
757        events: &[SessionEvent],
758        writer: &dyn EventWriter,
759        state: InputDrain<'_>,
760    ) -> Result<(), RuntimeError> {
761        for envelope in events {
762            let Event::InputQueued {
763                input_id,
764                run_id,
765                mode: DeliveryMode::Steer | DeliveryMode::Inject,
766                content,
767                ..
768            } = &envelope.event
769            else {
770                continue;
771            };
772            if run_id != &request.run_id || !state.claimed.insert(input_id.clone()) {
773                continue;
774            }
775            let input_message = user_message(content)?;
776            let appended = writer
777                .append(vec![
778                    Event::InputClaimed {
779                        input_id: input_id.clone(),
780                        run_id: request.run_id.clone(),
781                    },
782                    Event::UserMessage {
783                        run_id: request.run_id.clone(),
784                        content: content.clone(),
785                    },
786                ])
787                .await?;
788            *state.seen_seq = appended.last().map_or(*state.seen_seq, |event| event.seq);
789            *state.context_query = content_text(content);
790            state.transcript.push(input_message);
791        }
792        Ok(())
793    }
794
795    async fn finish_open(
796        &self,
797        writer: &dyn EventWriter,
798        run_id: &RunId,
799        step: Option<u32>,
800        status: RunStatus,
801        final_text: Option<String>,
802        usage: (u64, u64),
803    ) -> Result<RuntimeOutcome, RuntimeError> {
804        let mut events = Vec::with_capacity(3);
805        if let Some(step) = step {
806            events.push(Event::StepFinished {
807                run_id: run_id.clone(),
808                step,
809            });
810        }
811        events.push(Event::TurnFinished {
812            run_id: run_id.clone(),
813            turn: 1,
814        });
815        events.push(Event::RunFinished {
816            run_id: run_id.clone(),
817            status,
818            error_code: None,
819        });
820        writer.append(events).await?;
821        Ok(RuntimeOutcome {
822            status: status.as_str().into(),
823            final_text,
824            prompt_tokens: usage.0,
825            completion_tokens: usage.1,
826            waiting_interaction_id: None,
827        })
828    }
829}