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