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