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