Skip to main content

everruns_engine/execution/
reason.rs

1//! ReasonAtom - Atom for LLM reasoning (model call)
2//!
3//! This atom handles:
4//! 1. Emitting reason.started event
5//! 2. Context preparation (loading message history, adding system message)
6//! 3. Fixing invalid context (e.g., missing tool_results for dangling tool calls)
7//! 4. LLM call with streaming support
8//! 5. Storing the assistant response
9//! 6. Emitting reason.completed event
10//! 7. Returning the result with tool calls (if any)
11//!
12//! NOTES from Python spec:
13//! - Context preparation includes loading message history, adding system message, editing context if needed
14//! - Before LLM call, invalid context (e.g. missing tool_results) should be fixed
15//! - LLM call should emit start/end events
16//! - Failure of the LLM call should be "normal" result, should user message that LLM call failed
17//! - Reason should be cancellable, cancellation should stop LLM call and exit with message
18
19use futures::StreamExt;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Instant;
24use uuid::Uuid;
25
26fn add_compaction_cost(usage: &mut TokenUsage, compaction_cost: f64) {
27    let generation_cost = usage.effective_cost_usd();
28    usage.effective_cost_usd = Some(generation_cost.unwrap_or(0.0) + compaction_cost);
29    if let Some(actual_cost) = usage.actual_cost_usd.as_mut() {
30        *actual_cost += compaction_cost;
31    }
32}
33
34use super::ExecutionContext;
35use crate::annotation_hook::{collect_annotations, verify_annotations};
36use crate::capabilities::CapabilityRegistry;
37use crate::driver_registry::{LlmMessage, LlmMessageContent, LlmMessageRole, LlmStreamEvent};
38use crate::error::{AgentLoopError, Result};
39use crate::events::{
40    CapabilityUsageData, EventContext, EventRequest, LlmCompactionInfo, LlmGenerationData,
41    LlmRetryInfo, OutputMessageCompletedData, OutputMessageDeltaData, OutputMessageReplacedData,
42    OutputMessageStartedData, ReasonCompletedData, ReasonItemData, ReasonRecoveredData,
43    ReasonStartedData, ReasonThinkingCompletedData, ReasonThinkingDeltaData,
44    ReasonThinkingStartedData, RecoveryMode, TokenUsage, ToolDefinitionSummary,
45};
46use crate::llm_retry::{
47    LlmRetryConfig, RetryMetadata, is_transient_error_message, remaining_retry_time,
48    reserve_retry_wait,
49};
50use crate::message::{ContentPart, Message, MessageRole};
51use crate::message_retriever::MessageRetriever;
52use crate::output_guardrail::{
53    ArmedGuardrail, OutputGuardrailContext, PostGenerationOutputContext, evaluate_guardrails,
54    evaluate_post_generation_guardrails, post_generation_guardrail_text,
55};
56use crate::phase_effects::{PhaseEffectEmitter, PhaseEffectSink};
57use crate::runtime_context::{AssembledTurnContext, TurnContextRequest, TurnContextResolver};
58use crate::tool_types::{ToolCall, ToolDefinition};
59use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId};
60use crate::{ErrorDisclosure, UserFacingError, UserFacingErrorContext};
61use crate::{
62    durability::DurableToolResultStore, durability::PartialStreamState,
63    durability::PartialStreamStore, event_emitter::EventEmitter, image_services::ImageResolver,
64    image_services::ResolvedImage,
65};
66use everruns_provider::reasoning::{ReasoningContentPart, ReasoningText};
67
68mod compaction;
69mod error_policy;
70mod observability;
71mod output_hooks;
72mod reasoning_updates;
73mod request_controls;
74mod stream_state;
75mod transcript;
76
77use compaction::{
78    ProactiveCompactionContext, ReactiveCompactionContext, apply_proactive_compaction,
79    apply_reactive_compaction,
80};
81use error_policy::{
82    error_disclosure_override, filter_response_text, is_error_placeholder_message,
83    resolve_error_disclosure,
84};
85use observability::{build_request_options, capability_usage_snapshot_records};
86use output_hooks::collect_output_hooks;
87use request_controls::resolve_request_controls;
88use stream_state::{
89    StreamReplayState, StreamTermination, advances_stall_deadline, append_guarded_thinking_delta,
90    inspect_guarded_reasoning_item, merge_retry_metadata,
91};
92use transcript::repair_dangling_tool_calls;
93
94// ============================================================================
95// Helper Functions
96// ============================================================================
97
98fn client_visible_guardrail_text(
99    text: &str,
100    streamed_reasoning: &str,
101    reasoning: &[ReasoningContentPart],
102    citation_annotations: &[crate::message::TextAnnotation],
103) -> String {
104    let mut guarded = streamed_reasoning.to_string();
105    if guarded.is_empty() {
106        for item_text in reasoning
107            .iter()
108            .filter_map(ReasoningContentPart::display_text)
109        {
110            if !guarded.is_empty() {
111                guarded.push_str("\n\n");
112            }
113            guarded.push_str(&item_text);
114        }
115    }
116
117    let prose = post_generation_guardrail_text(text, citation_annotations);
118    if !guarded.is_empty() && !prose.is_empty() {
119        guarded.push_str("\n\n");
120    }
121    guarded.push_str(&prose);
122    guarded
123}
124
125/// Apply capability-owned transforms to the finalized model tool-call batch.
126/// The reason atom owns timing and context; each implementation owns its policy.
127#[allow(clippy::too_many_arguments)]
128async fn apply_finalized_tool_calls_hooks(
129    capability_registry: &CapabilityRegistry,
130    event_emitter: &dyn EventEmitter,
131    session_id: SessionId,
132    context: &ExecutionContext,
133    resolved_capability_configs: &[crate::CapabilityRef],
134    tool_definitions: &[ToolDefinition],
135    tool_calls: &mut [ToolCall],
136    iteration: u32,
137) {
138    let hook_context = crate::finalized_tool_calls::FinalizedToolCallsContext {
139        event_emitter,
140        session_id,
141        execution_context: context,
142        tool_definitions,
143        iteration,
144    };
145    for config in resolved_capability_configs {
146        let Some(capability) = capability_registry.get(config.capability_id()) else {
147            continue;
148        };
149        if let Some(hook) = capability.finalized_tool_calls_hook(config.config_value()) {
150            hook.apply(&hook_context, tool_calls).await;
151        }
152    }
153}
154
155fn unix_now_secs() -> u64 {
156    std::time::SystemTime::now()
157        .duration_since(std::time::UNIX_EPOCH)
158        .unwrap_or_default()
159        .as_secs()
160}
161
162/// Input for ReasonAtom
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ReasonInput {
165    /// Atom execution context
166    pub context: ExecutionContext,
167    /// Harness ID for loading base configuration
168    pub harness_id: HarnessId,
169    /// Agent ID for loading configuration (optional)
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub agent_id: Option<AgentId>,
172    /// Organization ID for multi-tenancy tracking
173    #[serde(default)]
174    pub org_id: i64,
175    /// MCP tool definitions from agent's MCP capabilities (pre-resolved)
176    /// These are passed from the control-plane since MCP capabilities
177    /// are not in the CapabilityRegistry.
178    #[serde(default)]
179    pub mcp_tool_definitions: Vec<ToolDefinition>,
180    /// Previous LLM response ID for stateful continuation.
181    /// Enables server-side context caching across reason iterations.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub previous_response_id: Option<String>,
184    /// Current iteration number within this turn (1-based).
185    /// Used for output.message.started events so UI can show progress.
186    #[serde(default = "default_iteration")]
187    pub iteration: u32,
188}
189
190fn default_iteration() -> u32 {
191    1
192}
193
194/// Result of the ReasonAtom
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct ReasonResult {
197    /// Whether the LLM call succeeded
198    pub success: bool,
199    /// Text response from the model
200    pub text: String,
201    /// Tool calls requested by the model
202    #[serde(default)]
203    pub tool_calls: Vec<ToolCall>,
204    /// Whether tool execution is needed
205    pub has_tool_calls: bool,
206    /// Tool definitions from applied capabilities (for tool execution)
207    #[serde(default)]
208    pub tool_definitions: Vec<ToolDefinition>,
209    /// Maximum iterations configured for the agent
210    #[serde(default = "default_max_iterations")]
211    pub max_iterations: usize,
212    /// Error message if the call failed
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub error: Option<String>,
215    /// Disclosed user-facing classification of the failure, already filtered
216    /// through the resolved error-disclosure mode. Hosts must prefer this over
217    /// re-classifying `error`/`text` strings so disclosure stays consistent.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub user_facing_error: Option<UserFacingError>,
220    /// Error-disclosure mode that was applied to `user_facing_error`.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub error_disclosure: Option<ErrorDisclosure>,
223    /// Token usage from the LLM call
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub usage: Option<TokenUsage>,
226    /// Assistant message emitted by `output.message.completed` for this generation.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub output_message_id: Option<MessageId>,
229    /// Streaming latency for this LLM call, when available.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub time_to_first_token_ms: Option<u64>,
232    /// LLM provider's response ID for chaining with `previous_response_id`
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub response_id: Option<String>,
235    /// Raw provider finish reason for this generation.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub finish_reason: Option<String>,
238    /// Resolved locale used for this turn's prompt and backend-authored strings.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub locale: Option<String>,
241    /// Merged network access list for URL filtering in tools.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub network_access: Option<crate::network_access::NetworkAccessList>,
244    /// Request-level parallel tool calling preference (EVE-598), carried from
245    /// the resolved agent config into `ActInput` so the act scheduler can honor
246    /// `Some(false)` (force serialize). `None` preserves the default schedule.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub parallel_tool_calls: Option<bool>,
249}
250
251fn default_max_iterations() -> usize {
252    500
253}
254
255// ============================================================================
256// ReasonAtom
257// ============================================================================
258
259/// Atom that calls the LLM model for reasoning
260///
261/// This atom:
262/// 1. Emits reason.started event
263/// 2. Retrieves agent and session configuration from stores
264/// 3. Resolves model using priority: controls.model_id > session.model_id > agent.default_model_id
265/// 4. Builds configuration with capabilities applied
266/// 5. Loads messages from the store
267/// 6. Patches dangling tool calls
268/// 7. Resolves image_file content parts to actual image data (if ImageResolver provided)
269/// 8. Calls the LLM with the messages
270/// 9. Stores the assistant response
271/// 10. Emits reason.completed event
272/// 11. Returns the result with tool calls (if any)
273pub struct ReasonAtom {
274    context_resolver: Arc<dyn TurnContextResolver>,
275    message_retriever: Arc<dyn MessageRetriever>,
276    capability_registry: CapabilityRegistry,
277    event_emitter: PhaseEffectEmitter<dyn PhaseEffectSink>,
278    /// Optional image resolver for resolving image_file content parts
279    image_resolver: Option<Arc<dyn ImageResolver>>,
280    /// Optional heartbeater for stream-liveness signalling (EVE-531).
281    stream_heartbeater: Option<Arc<dyn crate::durability::StreamHeartbeater>>,
282    /// Optional provider stall timeout (EVE-531). Default: 120s.
283    provider_stall_timeout: Option<std::time::Duration>,
284    /// Shared attempt/backoff/time budget for automatic provider recovery.
285    provider_retry_config: LlmRetryConfig,
286    /// Optional durable tool result store for transcript repair (EVE-533).
287    durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
288    /// Optional partial-stream store for ContinuePartial recovery (EVE-532).
289    partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
290    /// Optional live reasoning-effort handle (EVE-595). When set and holding a
291    /// value, it overrides the message-derived effort on every LLM step, so a
292    /// tool can change effort mid-turn and have subsequent steps observe it.
293    reasoning_effort_handle: Option<crate::tool_context::ReasoningEffortHandle>,
294    /// Optional utility LLM service (EVE-573). Powers model-backed
295    /// end-of-message output guardrails (e.g. moderation). When absent, those
296    /// guardrails fail open and the seam is a no-op.
297    utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
298    /// Optional session schedule store. Used by the `usage_limit_auto_continue`
299    /// capability to schedule a one-shot continuation after a provider usage
300    /// limit resets. When absent, the capability degrades to a no-op (no
301    /// continuation is scheduled and the error copy makes no auto-resume
302    /// promise).
303    schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
304    /// Optional durable store for replacement context checkpoints.
305    compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
306}
307
308impl ReasonAtom {
309    /// Create a new ReasonAtom
310    pub fn new(
311        context_resolver: impl TurnContextResolver + 'static,
312        message_retriever: impl MessageRetriever + 'static,
313        capability_registry: CapabilityRegistry,
314        event_emitter: impl PhaseEffectSink + 'static,
315    ) -> Self {
316        Self {
317            context_resolver: Arc::new(context_resolver),
318            message_retriever: Arc::new(message_retriever),
319            capability_registry,
320            event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
321            image_resolver: None,
322            stream_heartbeater: None,
323            provider_stall_timeout: None,
324            provider_retry_config: LlmRetryConfig::default(),
325            durable_tool_result_store: None,
326            partial_stream_store: None,
327            reasoning_effort_handle: None,
328            utility_llm_service: None,
329            schedule_store: None,
330            compaction_checkpoint_store: None,
331        }
332    }
333
334    /// Set the session schedule store used by `usage_limit_auto_continue` to
335    /// schedule a continuation after a provider usage limit resets.
336    pub fn with_schedule_store(
337        mut self,
338        store: Arc<dyn crate::session_services::SessionScheduleStore>,
339    ) -> Self {
340        self.schedule_store = Some(store);
341        self
342    }
343
344    pub fn with_compaction_checkpoint_store(
345        mut self,
346        store: Arc<dyn crate::CompactionCheckpointStore>,
347    ) -> Self {
348        self.compaction_checkpoint_store = Some(store);
349        self
350    }
351
352    /// Collect the [`LlmErrorHook`]s contributed by the active capabilities,
353    /// paired with each capability's per-agent config. Hooks are invoked
354    /// generically on the terminal-error path; the reason atom has no knowledge
355    /// of any specific capability's behavior. Capabilities that contribute no
356    /// hook — the common case — are skipped at zero allocation cost.
357    fn collect_llm_error_hooks(
358        &self,
359        resolved_capability_configs: &[crate::CapabilityRef],
360    ) -> Vec<(
361        Arc<dyn crate::llm_error_hook::LlmErrorHook>,
362        serde_json::Value,
363    )> {
364        resolved_capability_configs
365            .iter()
366            .filter_map(|cfg| {
367                let cap = self.capability_registry.get(cfg.capability_id())?;
368                let hook = cap.llm_error_hook()?;
369                Some((hook, cfg.config_value().clone()))
370            })
371            .collect()
372    }
373
374    /// Set the image resolver for resolving image_file content parts
375    ///
376    /// When set, image_file references in messages will be resolved to actual
377    /// image data before being sent to the LLM. This is required for multimodal
378    /// conversations that include image attachments.
379    ///
380    /// # Example
381    ///
382    /// ```ignore
383    /// let resolver = Arc::new(GrpcImageResolver::new(client));
384    /// let atom = ReasonAtom::new(/* ... */).with_image_resolver(resolver);
385    /// ```
386    pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
387        self.image_resolver = Some(resolver);
388        self
389    }
390
391    /// Set the stream heartbeater for liveness signalling during LLM streaming.
392    pub fn with_stream_heartbeater(
393        mut self,
394        heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
395    ) -> Self {
396        self.stream_heartbeater = Some(heartbeater);
397        self
398    }
399
400    /// Set the provider stall timeout. If no token arrives within this window,
401    /// the stream is aborted and the activity fails with a retryable error.
402    pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
403        self.provider_stall_timeout = Some(timeout);
404        self
405    }
406
407    /// Set the bounded provider recovery policy.
408    pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
409        self.provider_retry_config = config;
410        self
411    }
412
413    /// Set the durable tool result store for transcript repair (EVE-533).
414    ///
415    /// When provided, transcript repair consults this store to replay settled tool
416    /// results or synthesize appropriate interrupted placeholders rather than always
417    /// emitting a generic "cancelled" message.
418    pub fn with_durable_tool_result_store(
419        mut self,
420        store: Arc<dyn DurableToolResultStore>,
421    ) -> Self {
422        self.durable_tool_result_store = Some(store);
423        self
424    }
425
426    /// Set the partial-stream store for ContinuePartial recovery (EVE-532).
427    pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
428        self.partial_stream_store = Some(store);
429        self
430    }
431
432    /// Set the live reasoning-effort handle (EVE-595).
433    ///
434    /// When set and holding a value, the effort it carries overrides the
435    /// message-derived effort for every LLM step. Because the handle is shared
436    /// and re-read on each step, a tool that mutates it mid-turn causes
437    /// subsequent steps in the same turn to use the new effort.
438    pub fn with_reasoning_effort_handle(
439        mut self,
440        handle: crate::tool_context::ReasoningEffortHandle,
441    ) -> Self {
442        self.reasoning_effort_handle = Some(handle);
443        self
444    }
445
446    /// Set the utility LLM service used by model-backed end-of-message output
447    /// guardrails (EVE-573). When unset, those guardrails fail open.
448    pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
449        self.utility_llm_service = Some(service);
450        self
451    }
452}
453
454impl ReasonAtom {
455    /// Stable phase name used by logs and durable activity adapters.
456    pub fn name(&self) -> &'static str {
457        "reason"
458    }
459
460    /// Execute a reason phase using host-injected portable contracts.
461    pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
462        self.execute_inner(input, None).await
463    }
464}
465
466impl ReasonAtom {
467    /// Execute using a pre-assembled turn context.
468    ///
469    /// Hosts that already assembled turn context for the current reason phase can
470    /// pass it through here to avoid reloading messages and rebuilding the agent.
471    pub async fn execute_with_assembled_context(
472        &self,
473        input: ReasonInput,
474        assembled: AssembledTurnContext,
475    ) -> Result<ReasonResult> {
476        self.execute_inner(input, Some(assembled)).await
477    }
478
479    async fn emit_capability_usage_snapshot(
480        &self,
481        session_id: SessionId,
482        context: &ExecutionContext,
483        resolved_capability_configs: &[crate::CapabilityRef],
484        tool_definitions: &[ToolDefinition],
485    ) {
486        let records = capability_usage_snapshot_records(
487            &self.capability_registry,
488            resolved_capability_configs,
489            tool_definitions,
490        );
491        if records.is_empty() {
492            return;
493        }
494
495        if let Err(error) = self
496            .event_emitter
497            .emit(EventRequest::new(
498                session_id,
499                EventContext::from_execution_context(context),
500                CapabilityUsageData { records },
501            ))
502            .await
503        {
504            tracing::warn!(
505                session_id = %session_id,
506                error = %error,
507                "ReasonAtom: failed to emit capability.usage event"
508            );
509        }
510    }
511
512    /// Run configured capability hooks after model tool calls are finalized and
513    /// before the assistant message is persisted.
514    async fn apply_finalized_tool_call_hooks(
515        &self,
516        session_id: SessionId,
517        context: &ExecutionContext,
518        resolved_capability_configs: &[crate::CapabilityRef],
519        tool_definitions: &[ToolDefinition],
520        tool_calls: &mut [ToolCall],
521        iteration: u32,
522    ) {
523        apply_finalized_tool_calls_hooks(
524            &self.capability_registry,
525            self.event_emitter.as_ref(),
526            session_id,
527            context,
528            resolved_capability_configs,
529            tool_definitions,
530            tool_calls,
531            iteration,
532        )
533        .await;
534    }
535
536    async fn execute_inner(
537        &self,
538        input: ReasonInput,
539        assembled: Option<AssembledTurnContext>,
540    ) -> Result<ReasonResult> {
541        let ReasonInput {
542            context,
543            harness_id,
544            agent_id,
545            org_id,
546            mcp_tool_definitions,
547            previous_response_id,
548            iteration,
549        } = input;
550
551        tracing::info!(
552            session_id = %context.session_id,
553            turn_id = %context.turn_id,
554            exec_id = %context.exec_id,
555            harness_id = %harness_id,
556            agent_id = ?agent_id,
557            mcp_tools_count = %mcp_tool_definitions.len(),
558            "ReasonAtom: starting LLM call"
559        );
560
561        // Generate OTel-style span IDs for hierarchical tracing
562        // trace_id: groups all events in this turn
563        // span_id: unique identifier for this reason span (shared by started/completed)
564        // parent_span_id: links to turn as parent
565        //
566        // NOTE: TurnId::to_string() returns prefixed format (e.g., "turn_abc123")
567        // matching the format used by turn.started/completed events in Braintrust.
568        let trace_id = context.turn_id.to_string();
569        let reason_span_id = Uuid::now_v7().to_string();
570        let parent_span_id = trace_id.clone(); // Parent is the turn
571
572        // Create event context from atom context with span info
573        let event_context = EventContext::from_execution_context(&context).with_span(
574            trace_id.clone(),
575            reason_span_id.clone(),
576            Some(parent_span_id.clone()),
577        );
578
579        // Track reason phase timing for Braintrust observability
580        let reason_start = Instant::now();
581
582        // Emit reason.started event
583        if let Err(e) = self
584            .event_emitter
585            .emit(EventRequest::new(
586                context.session_id,
587                event_context.clone(),
588                ReasonStartedData {
589                    harness_id,
590                    agent_id,
591                    metadata: None, // Will be populated after model resolution
592                },
593            ))
594            .await
595        {
596            tracing::warn!(
597                session_id = %context.session_id,
598                error = %e,
599                "ReasonAtom: failed to emit reason.started event"
600            );
601        }
602
603        // Assemble the turn context up-front so the error path below knows
604        // the resolved provider/model and the error-disclosure mode even when
605        // the LLM call (or the assembly itself) fails.
606        let assembled = match assembled {
607            Some(assembled) => Ok(assembled),
608            None => {
609                self.context_resolver
610                    .resolve_turn_context(TurnContextRequest {
611                        session_id: context.session_id,
612                        harness_id,
613                        agent_id,
614                        mcp_tool_definitions: mcp_tool_definitions.clone(),
615                    })
616                    .await
617            }
618        };
619
620        let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
621            Ok(assembled) => {
622                let error_disclosure = resolve_error_disclosure(
623                    &self.capability_registry,
624                    &assembled.resolved_capability_configs,
625                    error_disclosure_override(&assembled.messages).as_deref(),
626                );
627                // Collected before `assembled` is consumed by the LLM call so the
628                // terminal-error path below can run capability error hooks even
629                // though it no longer has the capability configs.
630                let error_hooks =
631                    self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
632                let error_context = UserFacingErrorContext::default()
633                    .with_provider(assembled.model.provider_type.to_string())
634                    .with_model_id(assembled.model.model.clone());
635                let call_result = self
636                    .execute_llm_call(
637                        context.session_id,
638                        harness_id,
639                        agent_id,
640                        org_id,
641                        &context,
642                        &trace_id,
643                        &reason_span_id,
644                        previous_response_id,
645                        iteration,
646                        assembled,
647                    )
648                    .await;
649                (error_disclosure, error_context, error_hooks, call_result)
650            }
651            Err(error) => (
652                ErrorDisclosure::default(),
653                UserFacingErrorContext::default(),
654                Vec::new(),
655                Err(error),
656            ),
657        };
658
659        // Handle LLM call errors gracefully
660        let result = match call_result {
661            Ok(result) => {
662                // Calculate reason phase duration
663                let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
664
665                // Emit reason.completed event (same span as reason.started, parent is turn)
666                let completed_context = EventContext::from_execution_context(&context).with_span(
667                    trace_id.clone(),
668                    reason_span_id.clone(), // Same span_id as started
669                    Some(parent_span_id.clone()),
670                );
671                if let Err(e) = self
672                    .event_emitter
673                    .emit(EventRequest::new(
674                        context.session_id,
675                        completed_context,
676                        ReasonCompletedData::success(
677                            &result.text,
678                            result.has_tool_calls,
679                            result.tool_calls.len() as u32,
680                            Some(reason_duration_ms),
681                            result.usage.clone(),
682                        ),
683                    ))
684                    .await
685                {
686                    tracing::warn!(
687                        session_id = %context.session_id,
688                        error = %e,
689                        "ReasonAtom: failed to emit reason.completed event"
690                    );
691                }
692                result
693            }
694            Err(e) => {
695                // Calculate reason phase duration even for failures
696                let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
697
698                // LLM call failure is a "normal" result per the spec
699                // Return a result indicating failure with the error message
700                tracing::warn!(
701                    session_id = %context.session_id,
702                    turn_id = %context.turn_id,
703                    error = %e,
704                    "ReasonAtom: LLM call failed"
705                );
706
707                let error_msg = e.to_string();
708                let mut source_error = e.user_facing_error(error_context);
709
710                // Only emit user-facing error events for non-transient errors.
711                // Transient errors (server errors, rate limits, timeouts) will be
712                // retried by the durable task engine. Emitting error events on each
713                // retry attempt causes duplicate error messages in the UI.
714                // The durable worker emits a single error event when all retries
715                // are exhausted (DLQ).
716                let is_transient = e.is_transient_llm_error()
717                    || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
718
719                // Capability error-hook seam: on the terminal (non-retried)
720                // error path, let active capabilities react — perform a side
721                // effect and/or augment the user-facing error fields — before the
722                // message is built. The atom stays behavior-agnostic; each hook
723                // (e.g. `usage_limit_auto_continue`) owns its own logic.
724                if !is_transient && !error_hooks.is_empty() {
725                    let services = crate::llm_error_hook::LlmErrorHookServices {
726                        schedule_store: self.schedule_store.clone(),
727                    };
728                    for (hook, config) in &error_hooks {
729                        let outcome = {
730                            let ctx = crate::llm_error_hook::LlmErrorContext {
731                                session_id: context.session_id,
732                                error_code: &source_error.code,
733                                error_fields: &source_error.fields,
734                                config,
735                                services: &services,
736                            };
737                            hook.on_llm_error(&ctx).await
738                        };
739                        for (key, value) in outcome.extra_error_fields {
740                            source_error = source_error.with_field(key, value);
741                        }
742                    }
743                }
744
745                let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
746                let user_error_text = user_error.fallback_message();
747
748                let mut output_message_id = None;
749
750                if !is_transient {
751                    // Create error message for the user to see
752                    let mut error_message = Message::assistant(&user_error_text);
753                    let mut metadata = std::collections::HashMap::new();
754                    user_error.apply_to_message_metadata(&mut metadata);
755                    UserFacingError::apply_disclosure_to_message_metadata(
756                        &mut metadata,
757                        error_disclosure,
758                        &source_error.code,
759                    );
760                    error_message.metadata = Some(metadata);
761
762                    output_message_id = Some(error_message.id);
763
764                    // Emit output.message.completed event (stores message as event with proper turn context)
765                    // output.message.completed is child of reason span
766                    let error_msg_context = EventContext::from_execution_context(&context)
767                        .with_span(
768                            trace_id.clone(),
769                            Uuid::now_v7().to_string(),   // Own span_id
770                            Some(reason_span_id.clone()), // Parent is reason span
771                        );
772                    if let Err(emit_err) = self
773                        .event_emitter
774                        .emit(EventRequest::new(
775                            context.session_id,
776                            error_msg_context,
777                            OutputMessageCompletedData::new(error_message)
778                                .with_user_facing_error(&user_error)
779                                .with_error_disclosure(error_disclosure),
780                        ))
781                        .await
782                    {
783                        tracing::warn!(
784                            session_id = %context.session_id,
785                            error = %emit_err,
786                            "ReasonAtom: failed to emit output.message.completed event for error"
787                        );
788                    }
789                } else {
790                    tracing::info!(
791                        session_id = %context.session_id,
792                        "ReasonAtom: skipping error event for transient LLM error (will be retried)"
793                    );
794                }
795
796                // Emit reason.completed event for failure (same span as started, parent is turn)
797                let completed_context = EventContext::from_execution_context(&context).with_span(
798                    trace_id.clone(),
799                    reason_span_id.clone(), // Same span_id as started
800                    Some(parent_span_id.clone()),
801                );
802                if let Err(emit_err) = self
803                    .event_emitter
804                    .emit(EventRequest::new(
805                        context.session_id,
806                        completed_context,
807                        ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
808                    ))
809                    .await
810                {
811                    tracing::warn!(
812                        session_id = %context.session_id,
813                        error = %emit_err,
814                        "ReasonAtom: failed to emit reason.completed event"
815                    );
816                }
817
818                ReasonResult {
819                    success: false,
820                    text: user_error_text,
821                    tool_calls: vec![],
822                    has_tool_calls: false,
823                    tool_definitions: vec![],
824                    max_iterations: default_max_iterations(),
825                    error: Some(error_msg.clone()),
826                    user_facing_error: Some(user_error),
827                    error_disclosure: Some(error_disclosure),
828                    usage: None,
829                    output_message_id,
830                    time_to_first_token_ms: None,
831                    response_id: None,
832                    finish_reason: error_msg
833                        .to_ascii_lowercase()
834                        .contains("model refused")
835                        .then(|| "refusal".to_string()),
836                    locale: None,
837                    network_access: None,
838                    parallel_tool_calls: None,
839                }
840            }
841        };
842
843        Ok(result)
844    }
845
846    /// Execute the actual LLM call
847    #[allow(clippy::too_many_arguments)]
848    async fn execute_llm_call(
849        &self,
850        session_id: SessionId,
851        harness_id: HarnessId,
852        agent_id: Option<AgentId>,
853        org_id: i64,
854        context: &ExecutionContext,
855        trace_id: &str,
856        reason_span_id: &str,
857        previous_response_id: Option<String>,
858        iteration: u32,
859        assembled: AssembledTurnContext,
860    ) -> Result<ReasonResult> {
861        let prior_usage = assembled.cumulative_usage();
862        let mut messages = assembled.messages;
863        let mut message_source_sequence = assembled.message_source_sequence;
864        let model_with_provider = assembled.model;
865        let resolved_model_id = assembled.resolved_model_id;
866        let resolved_locale = assembled.resolved_locale;
867        let compaction_policy = assembled.compaction_policy;
868        let resolved_capability_configs = assembled.resolved_capability_configs;
869        let runtime_agent = assembled.runtime_agent;
870        let embedder_metadata = assembled.embedder_metadata;
871
872        self.emit_capability_usage_snapshot(
873            session_id,
874            context,
875            &resolved_capability_configs,
876            &runtime_agent.tools,
877        )
878        .await;
879
880        let output_hooks =
881            collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
882        let guardrail_providers = output_hooks.streaming;
883        let post_output_providers = output_hooks.post_generation;
884        let annotation_providers = output_hooks.annotations;
885        let citation_verifiers = output_hooks.citation_verifiers;
886
887        // 7. Create LLM driver using factory
888        let chat_driver = Arc::clone(&model_with_provider.driver);
889        let stateful_response_continuation =
890            previous_response_id.is_some() && chat_driver.supports_stateful_responses();
891        let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
892        let mut checkpoint_suffix_message_count = 0usize;
893        let native_reasoning_compaction = compaction_policy.as_ref().is_none_or(|policy| {
894            matches!(
895                policy.settings().strategy,
896                crate::compaction_policy::CompactionStrategy::Native
897                    | crate::compaction_policy::CompactionStrategy::Auto
898            ) && chat_driver.supports_compact()
899        });
900
901        if compaction_policy.is_some()
902            && let Some(store) = self.compaction_checkpoint_store.as_ref()
903            && let Some(checkpoint) = store
904                .get_latest(
905                    session_id,
906                    model_with_provider.provider_type.as_str(),
907                    &model_with_provider.model,
908                )
909                .await?
910            && checkpoint.is_compatible(
911                model_with_provider.provider_type.as_str(),
912                &model_with_provider.model,
913            )
914            // Local summary/trim cannot interpret an Astra native checkpoint.
915            // Rebuild from lossless events when the builder changes strategy.
916            && (native_reasoning_compaction || !matches!(
917                &checkpoint.payload,
918                crate::CompactionCheckpointPayload::ProviderOpaque {
919                    context: crate::ProviderOpaqueContext::OpenResponsesCompact {
920                        reasoning_state: Some(_), ..
921                    }
922                }
923            ))
924        {
925            let filters = crate::capabilities::collect_message_filters_only(
926                &resolved_capability_configs,
927                &self.capability_registry,
928            );
929            let mut query =
930                crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
931            filters.apply_message_filters(&mut query);
932            let history = self.message_retriever.load_filtered_history(query).await?;
933            messages = history.messages;
934            checkpoint_suffix_message_count = messages.len();
935            filters.apply_post_load_filters(&mut messages);
936            if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
937                messages.insert(
938                    0,
939                    Message::system(format!(
940                        "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
941                    )),
942                );
943            }
944            message_source_sequence = history.source_sequence.or(message_source_sequence);
945            restored_checkpoint = Some(checkpoint);
946        }
947
948        let controls = resolve_request_controls(
949            &messages,
950            self.reasoning_effort_handle.as_ref(),
951            &model_with_provider.provider_type,
952            &model_with_provider.model,
953        );
954        let reasoning_effort = controls.reasoning_effort;
955        let speed = controls.speed;
956        let verbosity = controls.verbosity;
957        let checkpoint_reasoning =
958            restored_checkpoint
959                .as_ref()
960                .and_then(|checkpoint| match &checkpoint.payload {
961                    crate::CompactionCheckpointPayload::ProviderOpaque {
962                        context:
963                            crate::ProviderOpaqueContext::OpenResponsesCompact {
964                                reasoning_state, ..
965                            },
966                    } => reasoning_state.as_ref(),
967                    _ => None,
968                });
969        let mut reasoning_replay = reasoning_updates::prepare(
970            &messages,
971            model_with_provider.provider_type.as_str(),
972            &model_with_provider.model,
973            reasoning_effort,
974            self.reasoning_effort_handle
975                .as_ref()
976                .and_then(crate::tool_context::ReasoningEffortHandle::get),
977            checkpoint_reasoning,
978        )
979        .filter(|_| native_reasoning_compaction);
980
981        // 9. Check for an in-flight partial assistant stream from a previous worker (EVE-532).
982        // If found, apply the ContinuePartial recovery policy: finalize from accumulated
983        // text (if non-empty) or restart clean (if empty/usable partial only).
984        if let Some(ref store) = self.partial_stream_store {
985            let turn_id_str = context.turn_id.to_string();
986            match store.get_partial_stream(session_id, &turn_id_str).await {
987                Ok(Some(partial)) if !partial.accumulated.is_empty() => {
988                    // Finalize: emit completed from persisted accumulated text.
989                    return self
990                        .finalize_partial_stream(
991                            session_id,
992                            context,
993                            partial,
994                            iteration,
995                            &runtime_agent,
996                            &resolved_capability_configs,
997                        )
998                        .await;
999                }
1000                Ok(Some(partial)) => {
1001                    if let (Some(replay), Some(mut saved)) =
1002                        (reasoning_replay.as_mut(), partial.reasoning_state)
1003                    {
1004                        // The old worker persisted the effective live override
1005                        // before sending. Its process-local handle is gone.
1006                        saved.pending = saved.effective;
1007                        replay.state = saved;
1008                    }
1009                    // Empty accumulated: restart clean — fall through to normal LLM call.
1010                    // Emit reason.recovered { mode: Restart } for observability.
1011                    let recovery_ctx = EventContext::from_execution_context(context);
1012                    let _ = self
1013                        .event_emitter
1014                        .emit(EventRequest::new(
1015                            session_id,
1016                            recovery_ctx,
1017                            ReasonRecoveredData {
1018                                turn_id: context.turn_id,
1019                                mode: RecoveryMode::Restart,
1020                                accumulated_len: 0,
1021                            },
1022                        ))
1023                        .await;
1024                    tracing::info!(
1025                        session_id = %session_id,
1026                        turn_id = %context.turn_id,
1027                        "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1028                    );
1029                }
1030                Ok(None) => {} // No partial; normal first-run execution.
1031                Err(e) => {
1032                    if reasoning_replay.is_some() {
1033                        return Err(e);
1034                    }
1035                    // Best-effort: log and continue with normal execution.
1036                    tracing::warn!(
1037                        session_id = %session_id,
1038                        turn_id = %context.turn_id,
1039                        error = %e,
1040                        "ReasonAtom: partial-stream store error; proceeding with normal execution"
1041                    );
1042                }
1043            }
1044        }
1045
1046        // 10. Repair dangling tool calls (EVE-533): ensure every assistant tool_call
1047        // has a matching ToolResult before the LLM call. Consults durable_tool_results
1048        // when available to replay settled results or synthesize interrupted placeholders.
1049        let repair_event_context = EventContext::from_execution_context(context);
1050        let patched_messages = repair_dangling_tool_calls(
1051            &messages,
1052            self.durable_tool_result_store.as_deref(),
1053            self.event_emitter.as_ref(),
1054            session_id,
1055            &repair_event_context,
1056            &context.turn_id.to_string(),
1057        )
1058        .await;
1059        let raw_tool_result_bytes = compaction_policy
1060            .as_ref()
1061            .map(|policy| policy.total_tool_result_bytes(&patched_messages))
1062            .unwrap_or(0);
1063
1064        // 9b. Let enabled capabilities build a prompt-facing model view from
1065        // lossless stored messages. Storage remains unchanged.
1066        let model_view_providers = crate::capabilities::collect_model_view_providers(
1067            &resolved_capability_configs,
1068            &self.capability_registry,
1069            Some(model_with_provider.model.as_str()),
1070        );
1071        let model_view_context = crate::capabilities::ModelViewContext {
1072            session_id,
1073            prior_usage: prior_usage.as_ref(),
1074        };
1075        let mut context_messages =
1076            model_view_providers.apply_model_view(patched_messages, &model_view_context);
1077        context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1078            &context_messages,
1079            stateful_response_continuation || restored_checkpoint.is_some(),
1080        );
1081
1082        // 9c. Append live dynamic facts (e.g. the current time) at the tail.
1083        // Collected fresh each request so values are current, and delivered as a
1084        // trailing user-role message so they never fold into the cached system
1085        // prompt. `volatile_suffix_len` tells the Anthropic driver to anchor its
1086        // message cache breakpoint *before* this block, so the volatile tail
1087        // rides uncached while the conversation prefix stays cached.
1088        let mut volatile_suffix_len = 0usize;
1089        {
1090            let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1091            let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1092                &resolved_capability_configs,
1093                &self.capability_registry,
1094                Some(model_with_provider.model.as_str()),
1095                &facts_ctx,
1096            );
1097            if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1098                context_messages.push(Message::user(block));
1099                volatile_suffix_len = 1;
1100            }
1101        }
1102
1103        // 9d. Prepend conversation context (e.g. hierarchical AGENTS.md) as
1104        // the leading user-role message.
1105        //
1106        // Project instructions ride here: model-visible on every turn and
1107        // re-resolved alongside the system prompt, but never folded into the
1108        // cached system prompt. Untrusted workspace content must stay below
1109        // harness safety instructions in the instruction hierarchy, and file
1110        // edits must not invalidate the cache-stable system prefix.
1111        if let Some(context) = runtime_agent.conversation_context.as_ref()
1112            && !context.is_empty()
1113        {
1114            context_messages.insert(0, Message::user(context.clone()));
1115        }
1116
1117        // 10. Resolve images from image_file references (if any)
1118        //
1119        // Image resolution converts image_file content parts (which only contain UUIDs)
1120        // into actual base64-encoded image data that can be sent to LLMs.
1121        let resolved_images = self.resolve_images(&context_messages).await;
1122
1123        // 11. Build LLM messages
1124        let mut llm_messages = Vec::new();
1125
1126        // Add system prompt
1127        let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1128        if has_system_prompt {
1129            llm_messages.push(LlmMessage {
1130                role: LlmMessageRole::System,
1131                content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1132                tool_calls: None,
1133                tool_call_id: None,
1134                phase: None,
1135                reasoning: Vec::new(),
1136                configuration_update: None,
1137            });
1138        }
1139
1140        // Build messages for llm.generation event (includes system message)
1141        let messages_for_event: Vec<Message> = if has_system_prompt {
1142            std::iter::once(Message::system(&runtime_agent.system_prompt))
1143                .chain(context_messages.iter().cloned())
1144                .collect()
1145        } else {
1146            context_messages.clone()
1147        };
1148
1149        // Add conversation messages with resolved images.
1150        // For user messages with an external_actor, prefix the first text part
1151        // with the actor's display label so the LLM knows who is speaking.
1152        // Skip error placeholder messages from prior failed turns — they add
1153        // no conversational value and inflate the request.
1154        let mut stripped_error_count = 0u32;
1155        for msg in &context_messages {
1156            if is_error_placeholder_message(msg) {
1157                stripped_error_count += 1;
1158                continue;
1159            }
1160            let mut llm_msg =
1161                crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1162            llm_msg.configuration_update = reasoning_replay
1163                .as_ref()
1164                .and_then(|replay| replay.transitions.get(&msg.id).copied());
1165            if msg.role == MessageRole::User
1166                && let Some(ref actor) = msg.external_actor
1167            {
1168                llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1169            }
1170            llm_messages.push(llm_msg);
1171        }
1172        if stripped_error_count > 0 {
1173            tracing::info!(
1174                session_id = %session_id,
1175                stripped_error_count,
1176                "ReasonAtom: stripped error placeholder messages from LLM input"
1177            );
1178        }
1179
1180        // Context reducers operate on prompt-facing copies and may select only
1181        // one side of a tool exchange at a window boundary. Stateless requests
1182        // must be self-contained; stateful Responses requests may retain
1183        // result-only deltas whose calls live behind `previous_response_id`.
1184        llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1185            llm_messages,
1186            stateful_response_continuation || restored_checkpoint.is_some(),
1187        );
1188
1189        // 12. Build LLM call config with reasoning effort and metadata
1190        let mut llm_config_builder =
1191            crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1192        if let Some(effort) = reasoning_effort {
1193            llm_config_builder = llm_config_builder.reasoning_effort(effort);
1194        }
1195        if let Some(speed) = speed {
1196            llm_config_builder = llm_config_builder.speed(speed);
1197        }
1198        if let Some(verbosity) = verbosity {
1199            llm_config_builder = llm_config_builder.verbosity(verbosity);
1200        }
1201
1202        // Inject embedder metadata first; system keys added below take precedence
1203        for (k, v) in &embedder_metadata {
1204            llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1205        }
1206
1207        // Add metadata for API tracking and debugging
1208        // These IDs help correlate API requests with Everruns entities
1209        // TypedId::to_string() produces prefixed format (e.g., "session_abc123")
1210        llm_config_builder = llm_config_builder
1211            .with_metadata("session_id", session_id.to_string())
1212            .with_metadata("harness_id", harness_id.to_string())
1213            .with_metadata("turn_id", context.turn_id.to_string())
1214            .with_metadata("exec_id", context.exec_id.to_string())
1215            .with_metadata("org_id", format!("org_{:032x}", org_id));
1216        if let Some(agent_id) = agent_id {
1217            llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1218        }
1219
1220        // Add model_id if we have one (not available for system default model)
1221        if let Some(model_id) = &resolved_model_id {
1222            llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1223        }
1224
1225        let mut llm_config = llm_config_builder
1226            .previous_response_id(previous_response_id.clone())
1227            .volatile_suffix_len(volatile_suffix_len)
1228            .build();
1229        if let Some(replay) = &reasoning_replay {
1230            llm_config.reasoning_effort = replay.state.baseline;
1231            llm_config.reasoning_state = Some(replay.state.clone());
1232            if replay.reset_continuation {
1233                llm_config.previous_response_id = None;
1234            }
1235        } else if messages
1236            .iter()
1237            .rev()
1238            .find(|message| {
1239                message.role == MessageRole::Agent && !is_error_placeholder_message(message)
1240            })
1241            .and_then(|message| message.metadata.as_ref())
1242            .is_some_and(|metadata| metadata.contains_key(reasoning_updates::STATE_KEY))
1243        {
1244            // Leaving Astra's configuration-update mode starts a fresh provider
1245            // chain. Never inherit its updates in another model or protocol.
1246            llm_config.previous_response_id = None;
1247        }
1248        if let Some(checkpoint) = restored_checkpoint.as_ref()
1249            && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1250                &checkpoint.payload
1251        {
1252            llm_config.previous_response_id = None;
1253            llm_config.provider_opaque_context = Some(context.clone());
1254        }
1255
1256        tracing::debug!(
1257            session_id = %session_id,
1258            turn_id = %context.turn_id,
1259            model = %runtime_agent.model,
1260            message_count = %llm_messages.len(),
1261            "ReasonAtom: calling LLM"
1262        );
1263
1264        // 13. Emit output.message.started event BEFORE starting LLM call
1265        // This allows UI to show a thinking indicator immediately
1266        let streaming_event_context = EventContext::from_execution_context(context);
1267
1268        // Arm output guardrails for this stream. Each guardrail sees the
1269        // assembled system prompt and its own per-capability config (already
1270        // borrowed in `guardrail_providers` above, so no second scan over
1271        // `resolved_capability_configs`). Guardrails that decline to arm —
1272        // e.g. the canary couldn't extract a long-enough sentence — are
1273        // skipped, leaving the streaming hot path entirely free of work.
1274        let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1275        for (cap_id, cfg, provider) in &guardrail_providers {
1276            let ctx = OutputGuardrailContext {
1277                system_prompt: &runtime_agent.system_prompt,
1278                config: cfg,
1279            };
1280            let guardrail_id = provider.id().to_string();
1281            if let Some(run) = provider.arm(&ctx) {
1282                armed_guardrails.push(ArmedGuardrail {
1283                    capability_id: cap_id.clone(),
1284                    guardrail_id,
1285                    run,
1286                });
1287            }
1288        }
1289        // Blocking post-generation guardrails need the full assistant message
1290        // before they can decide. When active, withhold text deltas until the
1291        // seam allows the finalized output so blocked tokens are never emitted
1292        // or persisted as output.message.delta events.
1293        let buffer_output_deltas = !post_output_providers.is_empty();
1294        // Allocate the public message id before the first lifecycle event so
1295        // started/delta/replaced/completed can be grouped without turn-level
1296        // heuristics. Each reasoning iteration reaches this point separately.
1297        let output_message_id = MessageId::new();
1298        tracing::info!(
1299            session_id = %session_id,
1300            turn_id = %context.turn_id,
1301            "ReasonAtom: emitting output.message.started event"
1302        );
1303        if let Err(e) = self
1304            .event_emitter
1305            .emit(EventRequest::new(
1306                session_id,
1307                streaming_event_context.clone(),
1308                OutputMessageStartedData {
1309                    reasoning_state: llm_config.reasoning_state.clone(),
1310                    turn_id: context.turn_id,
1311                    message_id: output_message_id,
1312                    model: Some(runtime_agent.model.clone()),
1313                    iteration: Some(iteration),
1314                    // Emitted before the LLM call — phase is not yet known, so the
1315                    // streamed hint starts `None` (treat as assistant text).
1316                    phase: None,
1317                },
1318            ))
1319            .await
1320        {
1321            if llm_config.reasoning_state.is_some() {
1322                return Err(e);
1323            }
1324            tracing::warn!(
1325                session_id = %session_id,
1326                error = %e,
1327                "ReasonAtom: failed to emit output.message.started event"
1328            );
1329        } else {
1330            tracing::info!(
1331                session_id = %session_id,
1332                "ReasonAtom: output.message.started event emitted successfully"
1333            );
1334        }
1335
1336        // Also emit reason.thinking.started if extended thinking is enabled
1337        let thinking_enabled = reasoning_effort.is_some();
1338        if thinking_enabled {
1339            tracing::info!(
1340                session_id = %session_id,
1341                turn_id = %context.turn_id,
1342                "ReasonAtom: emitting reason.thinking.started event"
1343            );
1344            if let Err(e) = self
1345                .event_emitter
1346                .emit(EventRequest::new(
1347                    session_id,
1348                    streaming_event_context.clone(),
1349                    ReasonThinkingStartedData {
1350                        turn_id: context.turn_id,
1351                        model: Some(runtime_agent.model.clone()),
1352                    },
1353                ))
1354                .await
1355            {
1356                tracing::warn!(
1357                    session_id = %session_id,
1358                    error = %e,
1359                    "ReasonAtom: failed to emit reason.thinking.started event"
1360                );
1361            } else {
1362                tracing::info!(
1363                    session_id = %session_id,
1364                    "ReasonAtom: reason.thinking.started event emitted successfully"
1365                );
1366            }
1367        }
1368
1369        // Track LLM call timing
1370        let llm_start = Instant::now();
1371
1372        // Try LLM call with automatic compaction on RequestTooLarge.
1373        // Transient errors (429, 5xx) are retried at the driver level.
1374        // Stream-level errors are not retried here to avoid duplicate user-visible messages.
1375        let mut compaction_info: Option<LlmCompactionInfo> = None;
1376        let mut llm_messages_for_call = llm_messages.clone();
1377
1378        if let Some(policy) = compaction_policy.as_deref() {
1379            compaction_info = apply_proactive_compaction(
1380                ProactiveCompactionContext {
1381                    chat_driver: chat_driver.as_ref(),
1382                    policy,
1383                    checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1384                    event_emitter: self.event_emitter.as_ref(),
1385                    event_context: &streaming_event_context,
1386                    session_id,
1387                    message_source_sequence,
1388                    provider_type: model_with_provider.provider_type.as_str(),
1389                    model: &model_with_provider.model,
1390                    system_prompt: has_system_prompt
1391                        .then_some(runtime_agent.system_prompt.as_str()),
1392                    stateful_response_continuation,
1393                    checkpoint_restored: restored_checkpoint.is_some(),
1394                    checkpoint_suffix_message_count,
1395                    raw_tool_result_bytes,
1396                    prior_usage: prior_usage.as_ref(),
1397                },
1398                &mut llm_messages_for_call,
1399                &mut llm_config,
1400            )
1401            .await?;
1402        }
1403
1404        // 14. Process stream with batched output.message.delta emissions
1405        // Batch deltas every 100ms to reduce event volume while providing real-time feedback
1406        const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1407        let retry_config = self.provider_retry_config.clone();
1408        // OpenRouter server tools execute inside the provider and therefore do
1409        // not surface as agent ToolCalls. Reissuing their request can duplicate
1410        // side effects even when the stream has emitted only reasoning.
1411        let has_provider_executed_tools = llm_config
1412            .openrouter_routing
1413            .as_ref()
1414            .is_some_and(|routing| !routing.server_tools.is_empty());
1415        let mut stream_retry_metadata = RetryMetadata::default();
1416        let mut retry_started_at = None;
1417        // Best-effort streamed phase hint (EVE-774). Starts `None` ("not yet
1418        // classified — treat as assistant text") and is refined monotonically
1419        // once a provider reveals a native phase mid-stream. Declared outside the
1420        // retry loop so it is available to the post-loop guarded delta emission.
1421        let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1422        let (
1423            text,
1424            thinking,
1425            reasoning,
1426            tool_calls,
1427            completion_metadata,
1428            time_to_first_token_ms,
1429            pending_delta,
1430            mut tripped,
1431        ) = 'stream_attempt: loop {
1432            let stream_result = if let Some(remaining) =
1433                remaining_retry_time(&retry_config, retry_started_at)
1434            {
1435                match tokio::time::timeout(
1436                    remaining,
1437                    chat_driver.chat_completion_stream(
1438                        &crate::ProviderEndpoint::default(),
1439                        llm_messages_for_call.clone(),
1440                        &llm_config,
1441                    ),
1442                )
1443                .await
1444                {
1445                    Ok(result) => result,
1446                    Err(_) => {
1447                        return Err(AgentLoopError::llm_kind(
1448                            crate::error::LlmErrorKind::Unavailable,
1449                            format!(
1450                                "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1451                                stream_retry_metadata.attempts,
1452                                retry_config.max_retry_elapsed.as_secs_f64()
1453                            ),
1454                        )
1455                        .with_retry_metadata(&stream_retry_metadata));
1456                    }
1457                }
1458            } else {
1459                chat_driver
1460                    .chat_completion_stream(
1461                        &crate::ProviderEndpoint::default(),
1462                        llm_messages_for_call.clone(),
1463                        &llm_config,
1464                    )
1465                    .await
1466            };
1467            let mut stream = match stream_result {
1468                Ok(stream) => stream,
1469                Err(e) if e.is_request_too_large() => {
1470                    let Some(policy) = compaction_policy.as_deref() else {
1471                        tracing::warn!(
1472                            session_id = %session_id,
1473                            turn_id = %context.turn_id,
1474                            "ReasonAtom: context too large and compaction capability is not enabled"
1475                        );
1476                        return Err(e);
1477                    };
1478                    let outcome = apply_reactive_compaction(
1479                        ReactiveCompactionContext {
1480                            chat_driver: chat_driver.as_ref(),
1481                            policy,
1482                            checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1483                            event_emitter: self.event_emitter.as_ref(),
1484                            event_context: &streaming_event_context,
1485                            session_id,
1486                            message_source_sequence,
1487                            provider_type: model_with_provider.provider_type.as_str(),
1488                            model: &model_with_provider.model,
1489                            summarization_model_fallback: &runtime_agent.model,
1490                            system_prompt: has_system_prompt
1491                                .then_some(runtime_agent.system_prompt.as_str()),
1492                            stateful_response_continuation,
1493                        },
1494                        &mut llm_messages_for_call,
1495                        &mut llm_config,
1496                    )
1497                    .await?;
1498                    let Some(outcome) = outcome else {
1499                        return Err(e);
1500                    };
1501                    if outcome.generation_info.is_some() {
1502                        compaction_info = outcome.generation_info;
1503                    }
1504
1505                    chat_driver
1506                        .chat_completion_stream(
1507                            &crate::ProviderEndpoint::default(),
1508                            llm_messages_for_call.clone(),
1509                            &llm_config,
1510                        )
1511                        .await?
1512                }
1513                Err(e)
1514                    if e.is_transient_llm_error()
1515                        && !e.llm_retry_handled()
1516                        && !has_provider_executed_tools
1517                        && stream_retry_metadata.attempts < retry_config.max_retries =>
1518                {
1519                    let proposed_wait =
1520                        retry_config.calculate_backoff(stream_retry_metadata.attempts);
1521                    let Some(wait_duration) =
1522                        reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1523                    else {
1524                        return Err(AgentLoopError::llm_kind(
1525                            e.llm_error_kind()
1526                                .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1527                            format!(
1528                                "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1529                                stream_retry_metadata.attempts
1530                            ),
1531                        )
1532                        .with_retry_metadata(&stream_retry_metadata));
1533                    };
1534                    tracing::warn!(
1535                        session_id = %session_id,
1536                        turn_id = %context.turn_id,
1537                        attempt = stream_retry_metadata.attempts + 1,
1538                        max_retries = retry_config.max_retries,
1539                        wait_secs = wait_duration.as_secs_f64(),
1540                        error = %e,
1541                        "ReasonAtom: transient provider failure before stream, retrying"
1542                    );
1543                    stream_retry_metadata.record_retry(wait_duration, None);
1544                    tokio::time::sleep(wait_duration).await;
1545                    continue 'stream_attempt;
1546                }
1547                Err(e) => return Err(e),
1548            };
1549
1550            let mut text = String::new();
1551            // Reasoning artifacts in emission order. One entry per provider
1552            // block, each keeping its own signature/id, so interleaved thinking
1553            // and per-call thought signatures survive replay.
1554            let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1555            // Live-render buffer only; the durable text lives on the artifacts.
1556            let mut thinking = String::new();
1557            let mut tool_calls = Vec::new();
1558            let mut termination = StreamTermination::Exhausted;
1559            let mut replay_state = StreamReplayState::for_request(has_provider_executed_tools);
1560            let mut pending_delta = String::new();
1561            let mut pending_thinking_delta = String::new();
1562            let mut last_delta_emit = Instant::now();
1563            let mut last_thinking_delta_emit = Instant::now();
1564            let mut time_to_first_token_ms: Option<u64> = None;
1565
1566            // EVE-531: stall timeout + keepalive heartbeat for stream-liveness
1567            let stall_timeout = self
1568                .provider_stall_timeout
1569                .unwrap_or(std::time::Duration::from_secs(120));
1570            let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1571                .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1572            let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1573            let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1574            keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1575            keepalive_ticker.tick().await; // consume immediate first tick
1576            let mut last_stream_heartbeat = Instant::now();
1577            // Tracks the wall-clock time of the last actual token received.
1578            // Updated only on content events; keepalive heartbeats use this
1579            // so the control plane can distinguish "alive/slow" from "making
1580            // progress" without conflating keepalive pings with real tokens.
1581            let mut last_token_at_unix: u64 = unix_now_secs();
1582
1583            loop {
1584                let event = tokio::select! {
1585                    biased;
1586                    next = stream.next() => match next {
1587                        Some(e) => e,
1588                        None => break,
1589                    },
1590                    _ = &mut stall_sleep => {
1591                        // EVE-806: a stream that produced no tokens within the
1592                        // liveness window is equivalent to a dropped connection.
1593                        // Route it through the same bounded transient-retry path
1594                        // as an in-stream provider error (everruns-provider
1595                        // classifies this message as transient) instead of
1596                        // failing the turn immediately. Retrying re-issues the
1597                        // same request with no artificial history messages; a
1598                        // stall after partial output is not retried, and repeated
1599                        // stalls stay bounded by retry_config.max_retries.
1600                        let stall_error =
1601                            crate::driver_registry::LlmStreamError::new(format!(
1602                                "provider stream stall: no tokens for {}s",
1603                                stall_timeout.as_secs()
1604                            ));
1605                        tracing::warn!(
1606                            session_id = %session_id,
1607                            turn_id = %context.turn_id,
1608                            stall_secs = stall_timeout.as_secs(),
1609                            "ReasonAtom: provider stream stall timeout"
1610                        );
1611                        if replay_state.should_retry(
1612                            &stall_error,
1613                            stream_retry_metadata.attempts,
1614                            retry_config.max_retries,
1615                        ) {
1616                            let proposed_wait = retry_config
1617                                .calculate_backoff(stream_retry_metadata.attempts);
1618                            let Some(wait_duration) = reserve_retry_wait(
1619                                &retry_config,
1620                                &mut retry_started_at,
1621                                proposed_wait,
1622                            ) else {
1623                                return Err(AgentLoopError::llm_kind(
1624                                    crate::error::LlmErrorKind::Unavailable,
1625                                    format!(
1626                                        "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1627                                        stall_error.message,
1628                                        stream_retry_metadata.attempts
1629                                    ),
1630                                )
1631                                .with_retry_metadata(&stream_retry_metadata));
1632                            };
1633                            tracing::warn!(
1634                                session_id = %session_id,
1635                                turn_id = %context.turn_id,
1636                                attempt = stream_retry_metadata.attempts + 1,
1637                                max_retries = retry_config.max_retries,
1638                                wait_secs = wait_duration.as_secs_f64(),
1639                                "ReasonAtom: provider stream stall, retrying"
1640                            );
1641                            stream_retry_metadata.record_retry(wait_duration, None);
1642                            tokio::time::sleep(wait_duration).await;
1643                            continue 'stream_attempt;
1644                        }
1645                        return Err(AgentLoopError::llm(stall_error.message));
1646                    },
1647                    _ = keepalive_ticker.tick() => {
1648                        if let Some(ref hb) = self.stream_heartbeater {
1649                            hb.heartbeat(crate::durability::StreamProgress {
1650                                accumulated_len: text.len() + thinking.len(),
1651                                last_delta_at: last_token_at_unix,
1652                            })
1653                            .await;
1654                            last_stream_heartbeat = Instant::now();
1655                        }
1656                        continue;
1657                    },
1658                };
1659                let event = event?;
1660                replay_state.observe(&event);
1661                let advanced_stall_deadline = advances_stall_deadline(&event);
1662                if advanced_stall_deadline {
1663                    stall_sleep
1664                        .as_mut()
1665                        .reset(tokio::time::Instant::now() + stall_timeout);
1666                    last_token_at_unix = unix_now_secs();
1667                }
1668                match event {
1669                    LlmStreamEvent::TextDelta(delta) => {
1670                        if delta.is_empty() {
1671                            continue;
1672                        }
1673                        // Track time-to-first-token on first non-empty delta
1674                        if time_to_first_token_ms.is_none() {
1675                            let ttft = llm_start.elapsed().as_millis() as u64;
1676                            time_to_first_token_ms = Some(ttft);
1677                            tracing::info!(
1678                                session_id = %session_id,
1679                                time_to_first_token_ms = ttft,
1680                                "ReasonAtom: received first token from LLM"
1681                            );
1682                        }
1683                        text.push_str(&delta);
1684                        pending_delta.push_str(&delta);
1685
1686                        // Run output guardrails on the new accumulated text.
1687                        // Cheap by contract — runs in the streaming hot path.
1688                        // On block: suppress the pending delta (the bad text
1689                        // never reaches the client as a delta), record the
1690                        // trip, and break the loop. The replacement message is
1691                        // emitted below after the streaming block.
1692                        if !armed_guardrails.is_empty()
1693                            && let Some(t) =
1694                                evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1695                        {
1696                            tracing::warn!(
1697                                session_id = %session_id,
1698                                turn_id = %context.turn_id,
1699                                guardrail_capability_id = %t.capability_id,
1700                                guardrail_id = %t.guardrail_id,
1701                                reason_code = %t.block.reason_code,
1702                                "ReasonAtom: output guardrail tripped, replacing assistant message"
1703                            );
1704                            pending_delta.clear();
1705                            termination = StreamTermination::GuardrailBlocked(t);
1706                            break;
1707                        }
1708
1709                        // Emit batched delta if interval elapsed
1710                        if !buffer_output_deltas
1711                            && last_delta_emit.elapsed().as_millis() as u64
1712                                >= DELTA_BATCH_INTERVAL_MS
1713                            && !pending_delta.is_empty()
1714                        {
1715                            if let Err(e) = self
1716                                .event_emitter
1717                                .emit(EventRequest::new(
1718                                    session_id,
1719                                    streaming_event_context.clone(),
1720                                    OutputMessageDeltaData {
1721                                        turn_id: context.turn_id,
1722                                        message_id: output_message_id,
1723                                        delta: pending_delta.clone(),
1724                                        accumulated: text.clone(),
1725                                        phase: streamed_phase,
1726                                    },
1727                                ))
1728                                .await
1729                            {
1730                                tracing::warn!(
1731                                    session_id = %session_id,
1732                                    error = %e,
1733                                    "ReasonAtom: failed to emit output.message.delta event"
1734                                );
1735                            }
1736                            pending_delta.clear();
1737                            last_delta_emit = Instant::now();
1738                        }
1739                    }
1740                    LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1741                        if delta.is_empty() {
1742                            continue;
1743                        }
1744                        if let Some(t) = append_guarded_thinking_delta(
1745                            &mut armed_guardrails,
1746                            &mut thinking,
1747                            &mut pending_thinking_delta,
1748                            &delta,
1749                        ) {
1750                            tracing::warn!(
1751                                session_id = %session_id,
1752                                guardrail_capability_id = %t.capability_id,
1753                                guardrail_id = %t.guardrail_id,
1754                                "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1755                            );
1756                            termination = StreamTermination::GuardrailBlocked(t);
1757                            break;
1758                        }
1759                        tracing::debug!(
1760                            session_id = %session_id,
1761                            delta_len = delta.len(),
1762                            total_thinking_len = thinking.len(),
1763                            "ReasonAtom: received ThinkingDelta from LLM"
1764                        );
1765
1766                        // Emit batched thinking delta if interval elapsed
1767                        if last_thinking_delta_emit.elapsed().as_millis() as u64
1768                            >= DELTA_BATCH_INTERVAL_MS
1769                            && !pending_thinking_delta.is_empty()
1770                        {
1771                            if let Err(e) = self
1772                                .event_emitter
1773                                .emit(EventRequest::new(
1774                                    session_id,
1775                                    streaming_event_context.clone(),
1776                                    ReasonThinkingDeltaData {
1777                                        turn_id: context.turn_id,
1778                                        delta: pending_thinking_delta.clone(),
1779                                        accumulated: thinking.clone(),
1780                                    },
1781                                ))
1782                                .await
1783                            {
1784                                tracing::warn!(
1785                                    session_id = %session_id,
1786                                    error = %e,
1787                                    "ReasonAtom: failed to emit reason.thinking.delta event"
1788                                );
1789                            }
1790                            pending_thinking_delta.clear();
1791                            last_thinking_delta_emit = Instant::now();
1792                        }
1793                    }
1794                    LlmStreamEvent::ReasoningItem(item) => {
1795                        if let Some(t) = inspect_guarded_reasoning_item(
1796                            &mut armed_guardrails,
1797                            &mut thinking,
1798                            &item,
1799                        ) {
1800                            tracing::warn!(
1801                                session_id = %session_id,
1802                                guardrail_capability_id = %t.capability_id,
1803                                guardrail_id = %t.guardrail_id,
1804                                "ReasonAtom: output guardrail tripped on completed reasoning item, replacing assistant message"
1805                            );
1806                            termination = StreamTermination::GuardrailBlocked(t);
1807                            break;
1808                        }
1809                        // One durable artifact per provider block, appended in
1810                        // order. Replay walks these; nothing is collapsed into
1811                        // a single per-message slot.
1812                        tracing::debug!(
1813                            session_id = %session_id,
1814                            provider = %item.provider,
1815                            item_id = ?item.item_id,
1816                            has_signature = item.signature.is_some(),
1817                            has_encrypted = item.encrypted.is_some(),
1818                            "ReasonAtom: captured reasoning artifact"
1819                        );
1820                        reasoning.push(item);
1821                    }
1822                    LlmStreamEvent::ToolCalls(calls) => {
1823                        tool_calls = calls;
1824                    }
1825                    LlmStreamEvent::MessagePhase(phase) => {
1826                        // Provider revealed a native phase for the current
1827                        // assistant message mid-stream. Refine the streamed hint
1828                        // monotonically (never flip-flop, never back to None);
1829                        // subsequent output.message.delta events carry it. This is
1830                        // a hint only — it is NOT a completion signal and does not
1831                        // count as stream output. The completed Message.phase stays
1832                        // authoritative, and the hint is deliberately not derived
1833                        // from later tool-call presence (EVE-448 anti-pattern).
1834                        streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1835                            streamed_phase,
1836                            phase,
1837                        );
1838                    }
1839                    LlmStreamEvent::Done(metadata) => {
1840                        // Emit any remaining pending delta before completing,
1841                        // unless a post-generation guardrail must first inspect
1842                        // the finalized assistant text.
1843                        if !buffer_output_deltas
1844                            && !pending_delta.is_empty()
1845                            && let Err(e) = self
1846                                .event_emitter
1847                                .emit(EventRequest::new(
1848                                    session_id,
1849                                    streaming_event_context.clone(),
1850                                    OutputMessageDeltaData {
1851                                        turn_id: context.turn_id,
1852                                        message_id: output_message_id,
1853                                        delta: pending_delta.clone(),
1854                                        accumulated: text.clone(),
1855                                        phase: streamed_phase,
1856                                    },
1857                                ))
1858                                .await
1859                        {
1860                            tracing::warn!(
1861                                session_id = %session_id,
1862                                error = %e,
1863                                "ReasonAtom: failed to emit final output.message.delta event"
1864                            );
1865                        }
1866
1867                        // Emit any remaining pending thinking delta before completing
1868                        if !pending_thinking_delta.is_empty()
1869                            && let Err(e) = self
1870                                .event_emitter
1871                                .emit(EventRequest::new(
1872                                    session_id,
1873                                    streaming_event_context.clone(),
1874                                    ReasonThinkingDeltaData {
1875                                        turn_id: context.turn_id,
1876                                        delta: pending_thinking_delta.clone(),
1877                                        accumulated: thinking.clone(),
1878                                    },
1879                                ))
1880                                .await
1881                        {
1882                            tracing::warn!(
1883                                session_id = %session_id,
1884                                error = %e,
1885                                "ReasonAtom: failed to emit final reason.thinking.delta event"
1886                            );
1887                        }
1888
1889                        // Emit reason.thinking.completed if we had any thinking content
1890                        if !thinking.is_empty()
1891                            && let Err(e) = self
1892                                .event_emitter
1893                                .emit(EventRequest::new(
1894                                    session_id,
1895                                    streaming_event_context.clone(),
1896                                    ReasonThinkingCompletedData {
1897                                        turn_id: context.turn_id,
1898                                        thinking: thinking.clone(),
1899                                    },
1900                                ))
1901                                .await
1902                        {
1903                            tracing::warn!(
1904                                session_id = %session_id,
1905                                error = %e,
1906                                "ReasonAtom: failed to emit reason.thinking.completed event"
1907                            );
1908                        }
1909                        termination = StreamTermination::Completed(metadata);
1910                        break;
1911                    }
1912                    LlmStreamEvent::Error(err) => {
1913                        // If we already collected valid tool calls or text before
1914                        // the error arrived, treat it as a partial success. This
1915                        // handles OpenAI Responses API behaviour where a trailing
1916                        // server_error can follow fully-streamed function calls.
1917                        let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
1918
1919                        if has_partial_output {
1920                            tracing::warn!(
1921                                session_id = %session_id,
1922                                error = %err,
1923                                tool_call_count = tool_calls.len(),
1924                                text_len = text.len(),
1925                                "ReasonAtom: trailing stream error after valid output — treating as partial success"
1926                            );
1927                            // Break out of the stream loop and use the output
1928                            // we already collected. completion_metadata will be
1929                            // None since we never got a Done event.
1930                            termination = StreamTermination::PartialSuccess;
1931                            break;
1932                        }
1933
1934                        if replay_state.should_retry(
1935                            &err,
1936                            stream_retry_metadata.attempts,
1937                            retry_config.max_retries,
1938                        ) {
1939                            let proposed_wait =
1940                                retry_config.calculate_backoff(stream_retry_metadata.attempts);
1941                            let Some(wait_duration) = reserve_retry_wait(
1942                                &retry_config,
1943                                &mut retry_started_at,
1944                                proposed_wait,
1945                            ) else {
1946                                return Err(AgentLoopError::llm_kind(
1947                                    err.kind(),
1948                                    format!(
1949                                        "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1950                                        stream_retry_metadata.attempts
1951                                    ),
1952                                )
1953                                .with_retry_metadata(&stream_retry_metadata));
1954                            };
1955                            tracing::warn!(
1956                                session_id = %session_id,
1957                                turn_id = %context.turn_id,
1958                                attempt = stream_retry_metadata.attempts + 1,
1959                                max_retries = retry_config.max_retries,
1960                                wait_secs = wait_duration.as_secs_f64(),
1961                                error_code = err.code.as_deref().unwrap_or("none"),
1962                                error_status = err.status,
1963                                error = %err,
1964                                "ReasonAtom: transient stream error before output, retrying"
1965                            );
1966                            stream_retry_metadata.record_retry(wait_duration, None);
1967                            tokio::time::sleep(wait_duration).await;
1968                            continue 'stream_attempt;
1969                        }
1970
1971                        // No useful output collected — treat as a real failure.
1972                        let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
1973                        let event_context = EventContext::from_execution_context(context)
1974                            .with_span(
1975                                trace_id.to_string(),
1976                                Uuid::now_v7().to_string(),
1977                                Some(reason_span_id.to_string()),
1978                            );
1979                        let tools_summary: Vec<ToolDefinitionSummary> =
1980                            runtime_agent.tools.iter().map(|t| t.into()).collect();
1981                        let generation_data = LlmGenerationData::failure(
1982                            messages_for_event.clone(),
1983                            tools_summary,
1984                            runtime_agent.model.clone(),
1985                            Some(model_with_provider.provider_type.to_string()),
1986                            err.to_string(),
1987                            Some(llm_duration_ms),
1988                            time_to_first_token_ms,
1989                        );
1990                        let _ = self
1991                            .event_emitter
1992                            .emit(EventRequest::new(
1993                                session_id,
1994                                event_context,
1995                                generation_data,
1996                            ))
1997                            .await;
1998                        return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
1999                    }
2000                }
2001                // Per-event heartbeat after processing the event, so accumulated_len
2002                // reflects the just-received tokens. Throttled to every 5s.
2003                if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2004                    && let Some(ref hb) = self.stream_heartbeater
2005                {
2006                    hb.heartbeat(crate::durability::StreamProgress {
2007                        accumulated_len: text.len() + thinking.len(),
2008                        last_delta_at: last_token_at_unix,
2009                    })
2010                    .await;
2011                    last_stream_heartbeat = Instant::now();
2012                }
2013            }
2014            let (mut completion_metadata, tripped) = termination.into_parts();
2015            if let Some(metadata) = completion_metadata.as_mut() {
2016                metadata.retry_metadata =
2017                    merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2018            }
2019
2020            break 'stream_attempt (
2021                text,
2022                thinking,
2023                reasoning,
2024                tool_calls,
2025                completion_metadata,
2026                time_to_first_token_ms,
2027                pending_delta,
2028                tripped,
2029            );
2030        };
2031        let (mut text, mut thinking, mut reasoning, mut tool_calls) =
2032            (text, thinking, reasoning, tool_calls);
2033
2034        // End-of-message citation annotation seam (see knowledge/runtime-resources/citations.md). Runs
2035        // once on the finalized final-answer text to attach claim-level citations
2036        // before guardrails inspect the complete client-visible payload. Skipped
2037        // when a guardrail already tripped,
2038        // when there are no citation providers, when there is no text, or while
2039        // the message still carries tool calls (citations attach to answer
2040        // prose, not intermediate tool-calling turns). The built-in feeds do not
2041        // rewrite text, so streamed deltas stay valid and no buffering is needed;
2042        // a future feed that rewrites (e.g. to strip inline markers) must also
2043        // opt into delta buffering.
2044        let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
2045        if tripped.is_none()
2046            && !annotation_providers.is_empty()
2047            && !text.is_empty()
2048            && tool_calls.is_empty()
2049        {
2050            // Apply deterministic capability-owned response filters before
2051            // annotation offsets are computed against the finalized text.
2052            text = filter_response_text(
2053                &self.capability_registry,
2054                &resolved_capability_configs,
2055                text,
2056            );
2057            let collected = collect_annotations(
2058                &annotation_providers,
2059                &runtime_agent.system_prompt,
2060                &text,
2061                &messages,
2062                self.utility_llm_service.as_ref(),
2063            )
2064            .await;
2065            text = collected.text;
2066            citation_annotations = collected.annotations;
2067
2068            // Post-generation guardrails must inspect citation metadata as well
2069            // as prose because annotations are persisted and rendered to clients.
2070            if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
2071                let guarded_output = client_visible_guardrail_text(
2072                    &text,
2073                    &thinking,
2074                    &reasoning,
2075                    &citation_annotations,
2076                );
2077                let ctx = PostGenerationOutputContext {
2078                    system_prompt: &runtime_agent.system_prompt,
2079                    message_text: &guarded_output,
2080                    utility_llm_service: self.utility_llm_service.as_ref(),
2081                };
2082                tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2083            }
2084
2085            // Verification pass: stamp faithfulness verdicts on the collected
2086            // citations (no-op when no citation_verification capability is on).
2087            if tripped.is_none()
2088                && !citation_annotations.is_empty()
2089                && !citation_verifiers.is_empty()
2090            {
2091                citation_annotations = verify_annotations(
2092                    &citation_verifiers,
2093                    &text,
2094                    self.utility_llm_service.as_ref(),
2095                    citation_annotations,
2096                )
2097                .await;
2098            }
2099        }
2100
2101        // Messages without citation annotations still cross the same
2102        // post-generation output seam once.
2103        if tripped.is_none()
2104            && citation_annotations.is_empty()
2105            && !post_output_providers.is_empty()
2106            && (!text.is_empty() || !thinking.is_empty() || !reasoning.is_empty())
2107        {
2108            let guarded_output = client_visible_guardrail_text(&text, &thinking, &reasoning, &[]);
2109            let ctx = PostGenerationOutputContext {
2110                system_prompt: &runtime_agent.system_prompt,
2111                message_text: &guarded_output,
2112                utility_llm_service: self.utility_llm_service.as_ref(),
2113            };
2114            tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2115        }
2116
2117        if tripped.is_some() {
2118            citation_annotations.clear();
2119        }
2120
2121        // Completed reasoning metadata is withheld until every output
2122        // guardrail has allowed the message. Otherwise a summary could escape
2123        // through `reason.item` before a later assistant-text block trips.
2124        if tripped.is_none() {
2125            for item in &reasoning {
2126                if let Err(e) = self
2127                    .event_emitter
2128                    .emit(EventRequest::new(
2129                        session_id,
2130                        streaming_event_context.clone(),
2131                        ReasonItemData {
2132                            turn_id: context.turn_id,
2133                            provider: item.provider.clone(),
2134                            model: Some(llm_config.model.clone()),
2135                            item_id: item.item_id.clone().unwrap_or_default(),
2136                            summary: item
2137                                .display_text()
2138                                .filter(|_| !matches!(item.text, Some(ReasoningText::Plain { .. })))
2139                                .into_iter()
2140                                .collect(),
2141                            token_count: item.tokens,
2142                        },
2143                    ))
2144                    .await
2145                {
2146                    tracing::warn!(
2147                        session_id = %session_id,
2148                        error = %e,
2149                        "ReasonAtom: failed to emit reason.item event"
2150                    );
2151                }
2152            }
2153        }
2154
2155        // Release buffered text only after post-generation guardrails allow it.
2156        // If they block, the replacement path below emits only sanitized text.
2157        if buffer_output_deltas
2158            && tripped.is_none()
2159            && !pending_delta.is_empty()
2160            && let Err(e) = self
2161                .event_emitter
2162                .emit(EventRequest::new(
2163                    session_id,
2164                    streaming_event_context.clone(),
2165                    OutputMessageDeltaData {
2166                        turn_id: context.turn_id,
2167                        message_id: output_message_id,
2168                        delta: pending_delta.clone(),
2169                        accumulated: text.clone(),
2170                        phase: streamed_phase,
2171                    },
2172                ))
2173                .await
2174        {
2175            tracing::warn!(
2176                session_id = %session_id,
2177                error = %e,
2178                "ReasonAtom: failed to emit guarded output.message.delta event"
2179            );
2180        }
2181
2182        // If a streaming output guardrail tripped, emit
2183        // output.message.replaced and overwrite the assistant output now so
2184        // every downstream event (llm.generation, output.message.completed)
2185        // carries the replacement instead of the model's withheld tokens.
2186        // The original tokens are never persisted or replayed.
2187        if let Some(ref t) = tripped {
2188            let replaced_event_context = EventContext::from_execution_context(context).with_span(
2189                trace_id.to_string(),
2190                Uuid::now_v7().to_string(),
2191                Some(reason_span_id.to_string()),
2192            );
2193            if let Err(e) = self
2194                .event_emitter
2195                .emit(EventRequest::new(
2196                    session_id,
2197                    replaced_event_context,
2198                    OutputMessageReplacedData {
2199                        turn_id: context.turn_id,
2200                        message_id: output_message_id,
2201                        guardrail_capability_id: t.capability_id.clone(),
2202                        guardrail_id: t.guardrail_id.clone(),
2203                        reason_code: t.block.reason_code.clone(),
2204                        replacement: t.block.replacement.clone(),
2205                    },
2206                ))
2207                .await
2208            {
2209                tracing::warn!(
2210                    session_id = %session_id,
2211                    error = %e,
2212                    "ReasonAtom: failed to emit output.message.replaced event"
2213                );
2214            }
2215            text = t.block.replacement.clone();
2216            tool_calls.clear();
2217            thinking.clear();
2218            reasoning.clear();
2219        }
2220
2221        // Finalized tool-call policy seam. This is the smallest provider-neutral
2222        // point where configured capabilities can normalize a complete call
2223        // batch before the assistant message and downstream events are built.
2224        if !tool_calls.is_empty() {
2225            self.apply_finalized_tool_call_hooks(
2226                session_id,
2227                context,
2228                &resolved_capability_configs,
2229                &runtime_agent.tools,
2230                &mut tool_calls,
2231                iteration,
2232            )
2233            .await;
2234        }
2235
2236        let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2237
2238        // Extract response_id from completion metadata for chaining and OTel
2239        let response_id = completion_metadata
2240            .as_ref()
2241            .and_then(|meta| meta.response_id.clone());
2242        let finish_reason = completion_metadata
2243            .as_ref()
2244            .and_then(|meta| meta.finish_reason.clone());
2245
2246        // 15. Convert completion metadata to TokenUsage.
2247        //
2248        // Cost is tracked as two independent values: the provider's authoritative
2249        // inline cost when present (e.g. OpenRouter's usage.cost), and a price-table
2250        // estimate from the model profile computed whenever profile cost data
2251        // exists. Keeping both lets downstream consumers prefer the actual charge
2252        // while still reconciling estimate-vs-actual drift.
2253        let usage = completion_metadata.as_ref().and_then(|meta| {
2254            match (meta.prompt_tokens, meta.completion_tokens) {
2255                (Some(input), Some(output)) => {
2256                    let actual_cost_usd = meta.provider_cost_usd;
2257                    let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2258                        &model_with_provider.provider_type,
2259                        &runtime_agent.model,
2260                        input,
2261                        output,
2262                        meta.cache_read_tokens.unwrap_or(0),
2263                        meta.cache_creation_tokens.unwrap_or(0),
2264                    );
2265                    Some(
2266                        TokenUsage::with_cache(
2267                            input,
2268                            output,
2269                            meta.cache_read_tokens,
2270                            meta.cache_creation_tokens,
2271                        )
2272                        .with_cost(actual_cost_usd, estimated_cost_usd),
2273                    )
2274                }
2275                _ => None,
2276            }
2277        });
2278
2279        // 16. Emit llm.generation event (child of reason span)
2280        let event_context = EventContext::from_execution_context(context).with_span(
2281            trace_id.to_string(),
2282            Uuid::now_v7().to_string(),
2283            Some(reason_span_id.to_string()),
2284        );
2285        let tools_summary: Vec<ToolDefinitionSummary> =
2286            runtime_agent.tools.iter().map(|t| t.into()).collect();
2287        let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2288            if tool_calls.is_empty() {
2289                "stop".to_string()
2290            } else {
2291                "tool_calls".to_string()
2292            }
2293        })]);
2294        // Extract retry info from completion metadata (if retries occurred)
2295        let retry_info = completion_metadata
2296            .as_ref()
2297            .and_then(|meta| meta.retry_metadata.as_ref())
2298            .filter(|rm| rm.had_retries())
2299            .map(|rm| LlmRetryInfo {
2300                attempts: rm.attempts,
2301                total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2302            });
2303        // Build LlmGenerationData with retry and compaction info
2304        let mut generation_data = LlmGenerationData::success_with_retry(
2305            messages_for_event.clone(),
2306            tools_summary,
2307            Some(text.clone()).filter(|s| !s.is_empty()),
2308            tool_calls.clone(),
2309            runtime_agent.model.clone(),
2310            Some(model_with_provider.provider_type.to_string()),
2311            usage.clone(),
2312            Some(llm_duration_ms),
2313            time_to_first_token_ms,
2314            finish_reasons,
2315            response_id.clone(),
2316            retry_info,
2317        );
2318
2319        // Add compaction info if compaction was performed. Compaction is a
2320        // separate billable model call on the same turn. Preserve whether the
2321        // generation cost was actual or estimated while recording their combined
2322        // best-effort cost for budgets and usage totals. `compaction.cost_usd`
2323        // keeps the split visible (EVE-895).
2324        if let Some(info) = compaction_info {
2325            if let Some(compaction_cost) = info.cost_usd {
2326                match generation_data.metadata.usage.as_mut() {
2327                    Some(usage) => {
2328                        add_compaction_cost(usage, compaction_cost);
2329                    }
2330                    // The generation itself reported no usage — a provider may
2331                    // price compaction without returning usage on the retry.
2332                    // Carry the cost on a usage record of its own rather than
2333                    // dropping it, which is the failure this fixes.
2334                    None => {
2335                        generation_data.metadata.usage = Some(crate::events::TokenUsage {
2336                            input_tokens: 0,
2337                            output_tokens: 0,
2338                            cache_read_tokens: None,
2339                            cache_creation_tokens: None,
2340                            actual_cost_usd: Some(compaction_cost),
2341                            estimated_cost_usd: None,
2342                            effective_cost_usd: None,
2343                        });
2344                    }
2345                }
2346            }
2347            generation_data = generation_data.with_compaction(info);
2348        }
2349
2350        if let Some(request_options) =
2351            build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2352        {
2353            generation_data = generation_data.with_request_options(request_options);
2354        }
2355
2356        if let Err(e) = self
2357            .event_emitter
2358            .emit(EventRequest::new(
2359                session_id,
2360                event_context,
2361                generation_data,
2362            ))
2363            .await
2364        {
2365            tracing::warn!(
2366                session_id = %session_id,
2367                error = %e,
2368                "ReasonAtom: failed to emit llm.generation event"
2369            );
2370        }
2371
2372        // 17. Build metadata with model and reasoning effort info
2373        let mut metadata = std::collections::HashMap::new();
2374        metadata.insert(
2375            "model".to_string(),
2376            serde_json::Value::String(runtime_agent.model.clone()),
2377        );
2378        if let Some(state) = &llm_config.reasoning_state {
2379            metadata.insert(
2380                reasoning_updates::STATE_KEY.to_string(),
2381                serde_json::json!(state),
2382            );
2383        }
2384        if let Some(effort) = llm_config
2385            .reasoning_state
2386            .as_ref()
2387            .and_then(|state| state.effective)
2388            .or(reasoning_effort)
2389        {
2390            metadata.insert(
2391                "reasoning_effort".to_string(),
2392                serde_json::Value::String(effort.as_str().to_string()),
2393            );
2394        }
2395        // Stamp the provider driver id and provider response id so the chat UI
2396        // can build a deep link to the provider's trace/logs for this message
2397        // (see ProviderTraceConfig). The resolved model carries the driver id,
2398        // not the concrete provider instance id, so the UI keys trace config by
2399        // driver. `response_id` is the provider's generation id (e.g.
2400        // OpenRouter's "gen-..."); absent for providers that do not return one.
2401        metadata.insert(
2402            "provider".to_string(),
2403            serde_json::Value::String(model_with_provider.provider_type.to_string()),
2404        );
2405        if let Some(ref rid) = response_id {
2406            metadata.insert(
2407                "response_id".to_string(),
2408                serde_json::Value::String(rid.clone()),
2409            );
2410        }
2411
2412        // 18. Store and emit output.message.completed event with metadata and usage.
2413        // Apply capability-owned response filters before persisting/returning
2414        // the finalized assistant text.
2415        let text = filter_response_text(
2416            &self.capability_registry,
2417            &resolved_capability_configs,
2418            text,
2419        );
2420        let has_tool_calls = !tool_calls.is_empty();
2421        let mut assistant_message = if has_tool_calls {
2422            Message::assistant_with_tools(&text, tool_calls.clone())
2423        } else {
2424            Message::assistant(&text)
2425        }
2426        .with_id(output_message_id);
2427        // Attach citation annotations produced by the annotation seam above to
2428        // the message's text part (see knowledge/runtime-resources/citations.md).
2429        if !citation_annotations.is_empty() {
2430            for part in assistant_message.content.iter_mut() {
2431                if let crate::message::ContentPart::Text(t) = part {
2432                    t.annotations = std::mem::take(&mut citation_annotations);
2433                    break;
2434                }
2435            }
2436        }
2437        // Use the API-provided phase when available (preserving the provider's value),
2438        // otherwise derive from state: Commentary for intermediate iterations (with tool
2439        // calls), FinalAnswer for the completed response.
2440        let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2441        // Record where the phase came from. A provider-reported phase is a real
2442        // classification; a derived one is just `has_tool_calls` wearing a
2443        // classification's name, and consumers must be able to tell.
2444        let provider_phase = completion_metadata
2445            .as_ref()
2446            .and_then(|meta| meta.phase.as_deref())
2447            .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2448        let (phase, phase_source) = match provider_phase {
2449            Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2450            None => (
2451                everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2452                everruns_provider::PhaseSource::Derived,
2453            ),
2454        };
2455        assistant_message.phase = Some(phase);
2456        assistant_message.phase_source = Some(phase_source);
2457        assistant_message.metadata = Some(metadata);
2458        // Reasoning artifacts lead the message content, preserving their order
2459        // among themselves. Every current provider emits reasoning ahead of the
2460        // text and tool calls it produced, and all three require it replayed in
2461        // that position, so leading is the faithful placement.
2462        // Providers that stream reasoning without any replayable artifact
2463        // (Chat Completions `reasoning_content`) would otherwise render live
2464        // and vanish on reload. Persist what was shown, with no replay state,
2465        // so every provider's readable reasoning survives uniformly.
2466        if reasoning.is_empty() && !thinking.is_empty() {
2467            reasoning.push(
2468                ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2469                    ReasoningText::Plain {
2470                        text: thinking.clone(),
2471                    },
2472                ),
2473            );
2474        }
2475        if !reasoning.is_empty() {
2476            let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2477            content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2478            content.append(&mut assistant_message.content);
2479            assistant_message.content = content;
2480        }
2481        // Emit output.message.completed event (this stores the message as an event with proper turn context)
2482        // Include token usage for tracking (child of reason span)
2483        let message_event_context = EventContext::from_execution_context(context).with_span(
2484            trace_id.to_string(),
2485            Uuid::now_v7().to_string(),
2486            Some(reason_span_id.to_string()),
2487        );
2488        let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2489        if let Some(ref u) = usage {
2490            output_message_data = output_message_data.with_usage(u.clone());
2491        }
2492        self.event_emitter
2493            .emit(EventRequest::new(
2494                session_id,
2495                message_event_context,
2496                output_message_data,
2497            ))
2498            .await?;
2499
2500        tracing::info!(
2501            session_id = %session_id,
2502            turn_id = %context.turn_id,
2503            has_tool_calls = %has_tool_calls,
2504            tool_count = %tool_calls.len(),
2505            "ReasonAtom: LLM call completed"
2506        );
2507
2508        Ok(ReasonResult {
2509            success: true,
2510            text,
2511            tool_calls,
2512            has_tool_calls,
2513            tool_definitions: runtime_agent.tools.clone(),
2514            max_iterations: runtime_agent.max_iterations,
2515            error: None,
2516            user_facing_error: None,
2517            error_disclosure: None,
2518            usage,
2519            output_message_id: Some(output_message_id),
2520            time_to_first_token_ms,
2521            response_id,
2522            finish_reason,
2523            locale: resolved_locale,
2524            network_access: runtime_agent.network_access.clone(),
2525            parallel_tool_calls: runtime_agent.parallel_tool_calls,
2526        })
2527    }
2528
2529    /// Finalize a partial assistant stream without making a new provider call (EVE-532).
2530    ///
2531    /// Emits `output.message.started`, `output.message.completed` from the persisted
2532    /// `accumulated` text, and `reason.recovered { mode: Finalize }`.
2533    async fn finalize_partial_stream(
2534        &self,
2535        session_id: SessionId,
2536        context: &ExecutionContext,
2537        partial: PartialStreamState,
2538        iteration: u32,
2539        runtime_agent: &crate::RuntimeAgent,
2540        resolved_capability_configs: &[crate::CapabilityRef],
2541    ) -> Result<ReasonResult> {
2542        let event_context = EventContext::from_execution_context(context);
2543        let turn_id = context.turn_id;
2544        let message_id = partial.message_id;
2545
2546        // Signal that output is starting (keeps the streaming protocol intact).
2547        let _ = self
2548            .event_emitter
2549            .emit(EventRequest::new(
2550                session_id,
2551                event_context.clone(),
2552                OutputMessageStartedData {
2553                    reasoning_state: partial.reasoning_state.clone(),
2554                    turn_id,
2555                    message_id,
2556                    model: None,
2557                    iteration: Some(iteration),
2558                    // Recovery/finalize path reconstructs the started signal only;
2559                    // the streamed phase hint is unavailable here (None).
2560                    phase: None,
2561                },
2562            ))
2563            .await;
2564
2565        // Build the assistant message from capability-filtered accumulated text
2566        // and persist it via the canonical event path.
2567        let accumulated = filter_response_text(
2568            &self.capability_registry,
2569            resolved_capability_configs,
2570            partial.accumulated,
2571        );
2572        let mut assistant_message = Message::assistant(&accumulated).with_id(message_id);
2573        if let Some(state) = partial.reasoning_state {
2574            assistant_message.metadata = Some(HashMap::from([
2575                ("model".into(), serde_json::json!("gpt-6-astra")),
2576                ("provider".into(), serde_json::json!("openai")),
2577                (
2578                    reasoning_updates::STATE_KEY.into(),
2579                    serde_json::json!(state),
2580                ),
2581                (
2582                    "reasoning_effort".into(),
2583                    serde_json::json!(state.effective),
2584                ),
2585            ]));
2586        }
2587        let output_message_id = message_id;
2588        self.event_emitter
2589            .emit(EventRequest::new(
2590                session_id,
2591                event_context.clone(),
2592                OutputMessageCompletedData::new(assistant_message),
2593            ))
2594            .await?;
2595
2596        // Emit observability event.
2597        let accumulated_len = accumulated.len();
2598        let _ = self
2599            .event_emitter
2600            .emit(EventRequest::new(
2601                session_id,
2602                event_context.clone(),
2603                ReasonRecoveredData {
2604                    turn_id,
2605                    mode: RecoveryMode::Finalize,
2606                    accumulated_len,
2607                },
2608            ))
2609            .await;
2610
2611        tracing::info!(
2612            session_id = %session_id,
2613            turn_id = %turn_id,
2614            accumulated_len,
2615            "ReasonAtom: finalized partial stream from persisted accumulated text"
2616        );
2617
2618        Ok(ReasonResult {
2619            success: true,
2620            text: accumulated,
2621            tool_calls: vec![],
2622            has_tool_calls: false,
2623            tool_definitions: runtime_agent.tools.clone(),
2624            max_iterations: runtime_agent.max_iterations,
2625            error: None,
2626            user_facing_error: None,
2627            error_disclosure: None,
2628            usage: None,
2629            output_message_id: Some(output_message_id),
2630            time_to_first_token_ms: None,
2631            response_id: None,
2632            finish_reason: Some("stop".to_string()),
2633            locale: None,
2634            network_access: None,
2635            // Finalize path has no tool calls, so the preference is irrelevant.
2636            parallel_tool_calls: None,
2637        })
2638    }
2639
2640    /// Resolve image_file references to actual image data
2641    ///
2642    /// This method extracts all image_file IDs from the messages and resolves
2643    /// them to base64-encoded image data using the configured ImageResolver.
2644    ///
2645    /// # Returns
2646    ///
2647    /// A HashMap mapping image IDs to ResolvedImage data. If no ImageResolver
2648    /// is configured, or if resolution fails for some images, those images
2649    /// will simply be missing from the map (and converted to placeholder text).
2650    async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2651        let mut resolved = HashMap::new();
2652
2653        // Check if we have an image resolver
2654        let resolver = match &self.image_resolver {
2655            Some(r) => r,
2656            None => return resolved,
2657        };
2658
2659        // Collect all unique image_file IDs from all messages
2660        let image_ids: Vec<Uuid> = messages
2661            .iter()
2662            .flat_map(crate::llm_conversions::extract_image_file_ids)
2663            .collect::<std::collections::HashSet<_>>()
2664            .into_iter()
2665            .collect();
2666
2667        if image_ids.is_empty() {
2668            return resolved;
2669        }
2670
2671        tracing::debug!(
2672            image_count = image_ids.len(),
2673            "ReasonAtom: resolving image_file references"
2674        );
2675
2676        // Resolve each image
2677        for image_id in image_ids {
2678            match resolver.resolve_image(image_id).await {
2679                Ok(Some(image)) => {
2680                    resolved.insert(image_id, image);
2681                }
2682                Ok(None) => {
2683                    tracing::warn!(
2684                        image_id = %image_id,
2685                        "ReasonAtom: image not found during resolution"
2686                    );
2687                }
2688                Err(e) => {
2689                    tracing::warn!(
2690                        image_id = %image_id,
2691                        error = %e,
2692                        "ReasonAtom: failed to resolve image"
2693                    );
2694                }
2695            }
2696        }
2697
2698        tracing::debug!(
2699            resolved_count = resolved.len(),
2700            "ReasonAtom: image resolution complete"
2701        );
2702
2703        resolved
2704    }
2705}
2706
2707// ============================================================================
2708// Tests
2709// ============================================================================
2710
2711#[cfg(test)]
2712mod tests;