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