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