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