Skip to main content

everruns_core/atoms/
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 async_trait::async_trait;
20use futures::StreamExt;
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23use std::collections::{BTreeSet, HashMap};
24use std::sync::Arc;
25use std::time::Instant;
26use uuid::Uuid;
27
28use super::{Atom, AtomContext};
29use crate::capabilities::CapabilityRegistry;
30use crate::driver_registry::{
31    DriverRegistry, LlmCompletionMetadata, LlmMessage, LlmMessageContent, LlmMessageRole,
32    LlmStreamEvent,
33};
34use crate::error::{AgentLoopError, Result};
35use crate::events::{
36    CapabilityUsageData, CapabilityUsageKind, CapabilityUsageRecord, EventContext, EventRequest,
37    LlmCompactionInfo, LlmGenerationData, LlmPromptCacheInfo, LlmRequestOptions, LlmRetryInfo,
38    LlmToolSearchInfo, OutputMessageCompletedData, OutputMessageDeltaData,
39    OutputMessageReplacedData, OutputMessageStartedData, ReasonCompletedData, ReasonItemData,
40    ReasonRecoveredData, ReasonStartedData, ReasonThinkingCompletedData, ReasonThinkingDeltaData,
41    ReasonThinkingStartedData, RecoveryMode, TokenUsage, ToolDefinitionSummary,
42    TranscriptRepairAction, TranscriptRepairedData,
43};
44use crate::llm_retry::{
45    LlmRetryConfig, RetryMetadata, is_transient_error_message, is_transient_stream_error,
46};
47use crate::message::{Message, MessageRole};
48use crate::message_retriever::MessageRetriever;
49use crate::openresponses_protocol::{
50    CompactInputItem, CompactRequest, compact_output_to_messages, messages_to_compact_input,
51};
52use crate::output_guardrail::{
53    ArmedGuardrail, OutputGuardrailContext, PostGenerationOutputContext, PostGenerationProvider,
54    TrippedGuardrail, evaluate_guardrails, evaluate_post_generation_guardrails,
55};
56use crate::runtime_context::{AssembledTurnContext, assemble_turn_context};
57use crate::tool_types::{ToolCall, ToolDefinition};
58use crate::traits::{
59    AgentStore, DurableToolCallStatus, DurableToolResultStore, EventEmitter, HarnessStore,
60    ImageResolver, PartialStreamState, PartialStreamStore, ProviderStore, ResolvedImage,
61    ResolvedModel, SessionStore,
62};
63use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId};
64use crate::{ErrorDisclosure, UserFacingError, UserFacingErrorContext, user_facing_error_codes};
65
66// ============================================================================
67// Helper Functions
68// ============================================================================
69
70/// Apply the opt-in tool-call repair capability (EVE-600) to a finalized batch
71/// of tool calls. No-op unless `tool_call_repair` is in the resolved capability
72/// set, so the default path stays byte-for-byte unchanged.
73///
74/// For each malformed call this runs deterministic local salvage against the
75/// tool's JSON schema, rewrites `arguments` in place when salvage succeeds, and
76/// emits one `tool.call_repaired` event per malformed call with an outcome label
77/// (`local-salvage` | `re-prompt` | `gave-up`). Un-salvaged calls are left
78/// unchanged so they flow to the existing act-phase error path; the per-call
79/// attempt cap (bounding the corrective re-prompt) is enforced by
80/// `ToolCallRepairConfig`, so there is never an infinite repair loop.
81///
82/// Extracted as a free function (like `repair_dangling_tool_calls`) so it can be
83/// exercised by capability-level tests without constructing a full `ReasonAtom`.
84#[allow(clippy::too_many_arguments)] // all inputs are the per-turn repair context
85async fn apply_tool_call_repair(
86    capability_registry: &CapabilityRegistry,
87    event_emitter: &dyn EventEmitter,
88    session_id: SessionId,
89    context: &AtomContext,
90    resolved_capability_configs: &[crate::AgentCapabilityConfig],
91    tool_definitions: &[ToolDefinition],
92    tool_calls: &mut [ToolCall],
93    iteration: u32,
94) {
95    use crate::capabilities::{
96        RepairOutcome, SalvageResult, TOOL_CALL_REPAIR_CAPABILITY_ID, ToolCallRepairConfig,
97        salvage_tool_arguments,
98    };
99
100    // Opt-in: only run when the capability is enabled for this agent.
101    let Some(cfg) = resolved_capability_configs.iter().find(|c| {
102        capability_registry.canonical_id(c.capability_ref.as_str())
103            == Some(TOOL_CALL_REPAIR_CAPABILITY_ID)
104    }) else {
105        return;
106    };
107    let repair_config = ToolCallRepairConfig::from_json(&cfg.config);
108
109    for call in tool_calls.iter_mut() {
110        // Schema for the targeted tool, if its definition is available.
111        let schema = tool_definitions
112            .iter()
113            .find(|t| t.name() == call.name)
114            .map(|t| t.full_parameters().clone());
115
116        let outcome = match salvage_tool_arguments(&call.arguments, schema.as_ref()) {
117            // Well-formed call: nothing to do, do not emit an event.
118            SalvageResult::AlreadyValid => continue,
119            SalvageResult::Repaired(fixed) => {
120                call.arguments = fixed;
121                RepairOutcome::LocalSalvage
122            }
123            // Local salvage failed. The corrective re-prompt is realized by the
124            // outer agent loop retrying on the next reason iteration, so the
125            // per-turn `iteration` (1-based) is the count of attempts already
126            // spent on this turn: prior_attempts = iteration - 1. The bounded
127            // decision is Reprompt while attempts remain, else GaveUp. Either way
128            // the call is left unchanged and flows to the existing error path.
129            SalvageResult::Unsalvageable => {
130                repair_config.outcome_after_failed_salvage(iteration.saturating_sub(1))
131            }
132        };
133
134        tracing::info!(
135            session_id = %session_id,
136            turn_id = %context.turn_id,
137            tool_call_id = %call.id,
138            tool_name = %call.name,
139            outcome = outcome.label(),
140            "ReasonAtom: tool-call repair"
141        );
142
143        if let Err(e) = event_emitter
144            .emit(EventRequest::new(
145                session_id,
146                EventContext::from_atom_context(context),
147                crate::events::ToolCallRepairedData {
148                    turn_id: context.turn_id,
149                    tool_call_id: call.id.clone(),
150                    tool_name: call.name.clone(),
151                    outcome: outcome.label().to_string(),
152                },
153            ))
154            .await
155        {
156            tracing::warn!(
157                session_id = %session_id,
158                error = %e,
159                "ReasonAtom: failed to emit tool.call_repaired event"
160            );
161        }
162    }
163}
164
165/// Repair dangling tool calls (EVE-533): for every assistant tool_call with no matching
166/// ToolResult, synthesize a well-formed result so the next LLM call does not reject the
167/// transcript. Consults `durable_tool_results` (EVE-530) when available:
168///
169/// - `Settled` row found   → replay the stored result directly.
170/// - `Interrupted` row     → replay the stored interrupted error.
171/// - `Running` (stale)     → synthesize an "interrupted – uncertain – do not retry" placeholder.
172/// - Row not found         → synthesize an "interrupted – not executed – safe to retry" placeholder.
173/// - Store error           → synthesize an "interrupted – status unknown – do not retry" placeholder.
174///
175/// Emits `transcript.repaired` events via `event_emitter` for each repaired call.
176async fn repair_dangling_tool_calls(
177    messages: &[Message],
178    durable_store: Option<&dyn DurableToolResultStore>,
179    event_emitter: &dyn EventEmitter,
180    session_id: crate::typed_id::SessionId,
181    event_context: &EventContext,
182    turn_id: &str,
183) -> Vec<Message> {
184    let mut result = Vec::new();
185
186    for (i, msg) in messages.iter().enumerate() {
187        result.push(msg.clone());
188
189        if msg.role != MessageRole::Agent || !msg.has_tool_calls() {
190            continue;
191        }
192
193        for tc in msg.tool_calls() {
194            let has_result = messages[(i + 1)..]
195                .iter()
196                .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
197
198            if has_result {
199                continue;
200            }
201
202            // Consult durable_tool_results to determine the best repair strategy.
203            let (repair_msg, action) = if let Some(store) = durable_store {
204                match store.get_tool_call_status(turn_id, &tc.id).await {
205                    Ok(Some(DurableToolCallStatus::Settled { result_json })) => {
206                        // A settled result exists in durable storage; deserialize and replay it.
207                        let repair = match serde_json::from_value::<crate::tool_types::ToolResult>(
208                            result_json.clone(),
209                        ) {
210                            Ok(tr) => Message::tool_result(&tc.id, tr.result, tr.error),
211                            Err(_) => Message::tool_result(&tc.id, Some(result_json), None),
212                        };
213                        (repair, TranscriptRepairAction::Replay)
214                    }
215                    Ok(Some(DurableToolCallStatus::Interrupted { result_json })) => {
216                        // Settled as interrupted by a prior recovery.
217                        let err = result_json
218                            .as_ref()
219                            .and_then(|v| {
220                                serde_json::from_value::<crate::tool_types::ToolResult>(v.clone())
221                                    .ok()
222                            })
223                            .and_then(|tr| tr.error)
224                            .unwrap_or_else(|| {
225                                "tool execution did not complete before recovery; result unknown"
226                                    .to_string()
227                            });
228                        (
229                            Message::tool_result(&tc.id, None, Some(err)),
230                            TranscriptRepairAction::Replay,
231                        )
232                    }
233                    Ok(Some(DurableToolCallStatus::Running)) => {
234                        // Stale running claim from a dead worker; safe to synthesize.
235                        (
236                            Message::tool_result(
237                                &tc.id,
238                                None,
239                                Some(
240                                    "interrupted - tool execution was interrupted by worker \
241                                     failure and the result is uncertain; do not retry \
242                                     automatically"
243                                        .to_string(),
244                                ),
245                            ),
246                            TranscriptRepairAction::Synthesize,
247                        )
248                    }
249                    Ok(None) => {
250                        // No durable record: tool was never dispatched before recovery.
251                        (
252                            Message::tool_result(
253                                &tc.id,
254                                None,
255                                Some(
256                                    "interrupted - tool was not executed before recovery; \
257                                     safe to retry"
258                                        .to_string(),
259                                ),
260                            ),
261                            TranscriptRepairAction::Synthesize,
262                        )
263                    }
264                    Err(e) => {
265                        // Store temporarily unavailable: we cannot know whether the tool ran.
266                        tracing::warn!(
267                            tool_call_id = %tc.id,
268                            error = %e,
269                            "transcript repair: durable store error; status unknown"
270                        );
271                        (
272                            Message::tool_result(
273                                &tc.id,
274                                None,
275                                Some(
276                                    "interrupted - tool execution status unknown due to \
277                                     store error; do not retry automatically"
278                                        .to_string(),
279                                ),
280                            ),
281                            TranscriptRepairAction::Synthesize,
282                        )
283                    }
284                }
285            } else {
286                // No durable store; fall back to generic cancelled.
287                (
288                    Message::tool_result(
289                        &tc.id,
290                        None,
291                        Some(
292                            "cancelled - another message came in before it could be completed"
293                                .to_string(),
294                        ),
295                    ),
296                    TranscriptRepairAction::Synthesize,
297                )
298            };
299
300            // Emit transcript.repaired event for observability.
301            let repair_event = EventRequest::new(
302                session_id,
303                event_context.clone(),
304                TranscriptRepairedData {
305                    tool_call_id: tc.id.clone(),
306                    tool_name: Some(tc.name.clone()),
307                    action,
308                },
309            );
310            if let Err(e) = event_emitter.emit(repair_event).await {
311                tracing::warn!(
312                    tool_call_id = %tc.id,
313                    error = %e,
314                    "transcript repair: failed to emit transcript.repaired event"
315                );
316            }
317
318            result.push(repair_msg);
319        }
320    }
321
322    result
323}
324
325/// Known error placeholder texts emitted by the DLQ handler and user_facing_message().
326/// These add no conversational value and inflate subsequent LLM requests.
327const ERROR_PLACEHOLDER_MESSAGES: &[&str] = &[
328    "I encountered an error while processing your request. Please try again later.",
329    "The AI provider is experiencing issues. Please try again shortly.",
330    "Rate limited by the AI provider. Please wait a moment.",
331    "The conversation has become too long for the model to process. Please start a new session or reduce the context size.",
332    "There is a misconfiguration with the AI provider. Please contact support.",
333    "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue.",
334];
335
336/// Returns true when a stream event carries assistant output progress.
337fn stream_event_advances_stall_deadline(event: &LlmStreamEvent) -> bool {
338    match event {
339        LlmStreamEvent::TextDelta(delta) | LlmStreamEvent::ThinkingDelta(delta) => {
340            !delta.is_empty()
341        }
342        LlmStreamEvent::ReasonItem {
343            encrypted_content,
344            summary,
345            token_count,
346            ..
347        } => {
348            encrypted_content
349                .as_ref()
350                .is_some_and(|content| !content.is_empty())
351                || summary.iter().any(|item| !item.is_empty())
352                || token_count.is_some_and(|count| count > 0)
353        }
354        LlmStreamEvent::ToolCalls(calls) => !calls.is_empty(),
355        // MessagePhase is a metadata hint, not assistant output progress.
356        LlmStreamEvent::MessagePhase(_)
357        | LlmStreamEvent::ThinkingSignature(_)
358        | LlmStreamEvent::Done(_)
359        | LlmStreamEvent::Error(_) => false,
360    }
361}
362
363fn should_retry_stream_error(
364    error: &crate::driver_registry::LlmStreamError,
365    retry_attempts: u32,
366    max_retries: u32,
367    has_output: bool,
368) -> bool {
369    retry_attempts < max_retries && !has_output && is_transient_stream_error(error)
370}
371
372fn merge_retry_metadata(
373    existing: Option<RetryMetadata>,
374    additional: &RetryMetadata,
375) -> Option<RetryMetadata> {
376    if !additional.had_retries() {
377        return existing;
378    }
379
380    let mut merged = existing.unwrap_or_default();
381    merged.attempts += additional.attempts;
382    merged.total_retry_wait += additional.total_retry_wait;
383    if additional.last_rate_limit_info.is_some() {
384        merged.last_rate_limit_info = additional.last_rate_limit_info.clone();
385    }
386    Some(merged)
387}
388
389fn unix_now_secs() -> u64 {
390    std::time::SystemTime::now()
391        .duration_since(std::time::UNIX_EPOCH)
392        .unwrap_or_default()
393        .as_secs()
394}
395
396/// Returns true if the message is an error placeholder that should be stripped
397/// from the conversation history before sending to the LLM.
398fn is_error_placeholder_message(msg: &Message) -> bool {
399    if msg.role != MessageRole::Agent {
400        return false;
401    }
402    // Must have no tool calls (pure text-only error message)
403    if msg.has_tool_calls() {
404        return false;
405    }
406    if let Some(metadata) = &msg.metadata
407        && let Some(serde_json::Value::String(code)) = metadata.get("error_code")
408    {
409        return matches!(
410            code.as_str(),
411            user_facing_error_codes::BUDGET_EXHAUSTED
412                | user_facing_error_codes::BUDGET_PAUSED
413                | user_facing_error_codes::MODEL_UNAVAILABLE
414                | user_facing_error_codes::REQUEST_TOO_LARGE
415                | user_facing_error_codes::PROVIDER_RATE_LIMITED
416                | user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED
417                | user_facing_error_codes::PROVIDER_MISCONFIGURED
418                | user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
419                | user_facing_error_codes::PROVIDER_UNAVAILABLE
420                | user_facing_error_codes::DEPENDENCY_UNAVAILABLE
421                | user_facing_error_codes::PROCESSING_ERROR
422        );
423    }
424    let text = msg.text().unwrap_or("");
425    ERROR_PLACEHOLDER_MESSAGES.contains(&text) || is_dynamic_error_placeholder(text)
426}
427
428fn append_guarded_thinking_delta(
429    armed_guardrails: &mut [ArmedGuardrail],
430    thinking: &mut String,
431    pending_thinking_delta: &mut String,
432    delta: &str,
433) -> Option<TrippedGuardrail> {
434    thinking.push_str(delta);
435
436    // Thinking streams are user-visible and persisted on completion, so they
437    // must pass the same output guardrails as assistant text before any delta
438    // is emitted.
439    if let Some(t) = evaluate_guardrails(armed_guardrails, thinking, delta) {
440        pending_thinking_delta.clear();
441        Some(t)
442    } else {
443        pending_thinking_delta.push_str(delta);
444        None
445    }
446}
447
448/// Per-message error-disclosure override from the most recent user message's
449/// controls (mirrors how reasoning effort is resolved). The value is clamped
450/// against the capability-configured ceiling in `resolve_error_disclosure`.
451fn error_disclosure_override(messages: &[Message]) -> Option<String> {
452    messages
453        .iter()
454        .rev()
455        .find(|m| m.role == MessageRole::User)?
456        .controls
457        .as_ref()?
458        .error_disclosure
459        .clone()
460}
461
462fn is_dynamic_error_placeholder(text: &str) -> bool {
463    (text.starts_with("Budget exhausted.") && text.ends_with("Increase the budget to continue."))
464        || (text.starts_with("Budget paused.")
465            && text.ends_with("Increase or resume the budget to continue."))
466        || (text.starts_with("Budget paused with ")
467            && text.ends_with("Increase or resume the budget to continue."))
468        || (text.starts_with("Soft limit reached.") && text.ends_with("soft limit."))
469        || (text.starts_with("The model `") && text.ends_with("Please select a different model."))
470}
471
472// ============================================================================
473// Input and Output Types
474// ============================================================================
475
476/// Input for ReasonAtom
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct ReasonInput {
479    /// Atom execution context
480    pub context: AtomContext,
481    /// Harness ID for loading base configuration
482    pub harness_id: HarnessId,
483    /// Agent ID for loading configuration (optional)
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub agent_id: Option<AgentId>,
486    /// Organization ID for multi-tenancy tracking
487    #[serde(default)]
488    pub org_id: i64,
489    /// MCP tool definitions from agent's MCP capabilities (pre-resolved)
490    /// These are passed from the control-plane since MCP capabilities
491    /// are not in the CapabilityRegistry.
492    #[serde(default)]
493    pub mcp_tool_definitions: Vec<ToolDefinition>,
494    /// Previous LLM response ID for stateful continuation.
495    /// Enables server-side context caching across reason iterations.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub previous_response_id: Option<String>,
498    /// Current iteration number within this turn (1-based).
499    /// Used for output.message.started events so UI can show progress.
500    #[serde(default = "default_iteration")]
501    pub iteration: u32,
502}
503
504fn default_iteration() -> u32 {
505    1
506}
507
508/// Result of the ReasonAtom
509#[derive(Debug, Clone, Default, Serialize, Deserialize)]
510pub struct ReasonResult {
511    /// Whether the LLM call succeeded
512    pub success: bool,
513    /// Text response from the model
514    pub text: String,
515    /// Tool calls requested by the model
516    #[serde(default)]
517    pub tool_calls: Vec<ToolCall>,
518    /// Whether tool execution is needed
519    pub has_tool_calls: bool,
520    /// Tool definitions from applied capabilities (for tool execution)
521    #[serde(default)]
522    pub tool_definitions: Vec<ToolDefinition>,
523    /// Maximum iterations configured for the agent
524    #[serde(default = "default_max_iterations")]
525    pub max_iterations: usize,
526    /// Error message if the call failed
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub error: Option<String>,
529    /// Disclosed user-facing classification of the failure, already filtered
530    /// through the resolved error-disclosure mode. Hosts must prefer this over
531    /// re-classifying `error`/`text` strings so disclosure stays consistent.
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub user_facing_error: Option<UserFacingError>,
534    /// Error-disclosure mode that was applied to `user_facing_error`.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub error_disclosure: Option<ErrorDisclosure>,
537    /// Token usage from the LLM call
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub usage: Option<TokenUsage>,
540    /// Assistant message emitted by `output.message.completed` for this generation.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub output_message_id: Option<MessageId>,
543    /// Streaming latency for this LLM call, when available.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub time_to_first_token_ms: Option<u64>,
546    /// LLM provider's response ID for chaining with `previous_response_id`
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub response_id: Option<String>,
549    /// Resolved locale used for this turn's prompt and backend-authored strings.
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub locale: Option<String>,
552    /// Merged network access list for URL filtering in tools.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub network_access: Option<crate::network_access::NetworkAccessList>,
555    /// Request-level parallel tool calling preference (EVE-598), carried from
556    /// the resolved agent config into `ActInput` so the act scheduler can honor
557    /// `Some(false)` (force serialize). `None` preserves the default schedule.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub parallel_tool_calls: Option<bool>,
560}
561
562fn default_max_iterations() -> usize {
563    500
564}
565
566fn build_request_options(
567    config: &crate::driver_registry::LlmCallConfig,
568    provider: &str,
569) -> Option<LlmRequestOptions> {
570    let prompt_cache = config
571        .prompt_cache
572        .as_ref()
573        .filter(|cfg| cfg.enabled)
574        .map(|cfg| LlmPromptCacheInfo {
575            enabled: true,
576            strategy: cfg.strategy,
577            provider_mode: match provider {
578                "openai" => Some("prompt_cache_key".to_string()),
579                "anthropic" => Some("cache_control".to_string()),
580                "gemini" => Some(
581                    if cfg.gemini_cached_content.is_some() {
582                        "cached_content"
583                    } else {
584                        "implicit"
585                    }
586                    .to_string(),
587                ),
588                _ => None,
589            },
590        });
591
592    let tool_search = config
593        .tool_search
594        .as_ref()
595        .filter(|cfg| cfg.enabled)
596        .map(|cfg| LlmToolSearchInfo {
597            enabled: true,
598            threshold: cfg.threshold,
599        });
600
601    let mut provider_options = HashMap::new();
602    if provider == "openai" && config.previous_response_id.is_some() {
603        provider_options.insert(
604            "openai".to_string(),
605            json!({ "previous_response_id": true }),
606        );
607    }
608    if provider == "gemini"
609        && config
610            .prompt_cache
611            .as_ref()
612            .filter(|cfg| cfg.enabled)
613            .and_then(|cfg| cfg.gemini_cached_content.as_ref())
614            .is_some()
615    {
616        provider_options.insert("gemini".to_string(), json!({ "cached_content": true }));
617    }
618
619    let request_options = LlmRequestOptions {
620        prompt_cache,
621        tool_search,
622        provider_options,
623        metadata: config.metadata.clone(),
624    };
625
626    (!request_options.is_empty()).then_some(request_options)
627}
628
629fn capability_name_snapshot(registry: &CapabilityRegistry, capability_id: &str) -> Option<String> {
630    registry
631        .get(capability_id)
632        .map(|capability| capability.name().to_string())
633}
634
635fn capability_usage_snapshot_records(
636    registry: &CapabilityRegistry,
637    resolved_capability_configs: &[crate::AgentCapabilityConfig],
638    tool_definitions: &[ToolDefinition],
639) -> Vec<CapabilityUsageRecord> {
640    let mut records = Vec::new();
641    let mut seen = BTreeSet::new();
642
643    for config in resolved_capability_configs {
644        let capability_id = config.capability_id().to_string();
645        if seen.insert((
646            "resolved".to_string(),
647            capability_id.clone(),
648            None::<String>,
649        )) {
650            records.push(CapabilityUsageRecord {
651                capability_name: capability_name_snapshot(registry, &capability_id),
652                capability_id,
653                usage_kind: CapabilityUsageKind::Resolved,
654                tool_name: None,
655                usage_count: Some(1),
656                duration_ms: None,
657            });
658        }
659    }
660
661    for tool in tool_definitions {
662        let Some((capability_id, capability_name)) = tool.capability_attribution() else {
663            continue;
664        };
665        let capability_id = capability_id.to_string();
666        let tool_name = tool.name().to_string();
667        if seen.insert((
668            "exposed".to_string(),
669            capability_id.clone(),
670            Some(tool_name.clone()),
671        )) {
672            records.push(CapabilityUsageRecord {
673                capability_name: capability_name
674                    .map(str::to_string)
675                    .or_else(|| capability_name_snapshot(registry, &capability_id)),
676                capability_id,
677                usage_kind: CapabilityUsageKind::Exposed,
678                tool_name: Some(tool_name),
679                usage_count: Some(1),
680                duration_ms: None,
681            });
682        }
683    }
684
685    records
686}
687
688// ============================================================================
689// ReasonAtom
690// ============================================================================
691
692/// Atom that calls the LLM model for reasoning
693///
694/// This atom:
695/// 1. Emits reason.started event
696/// 2. Retrieves agent and session configuration from stores
697/// 3. Resolves model using priority: controls.model_id > session.model_id > agent.default_model_id
698/// 4. Builds configuration with capabilities applied
699/// 5. Loads messages from the store
700/// 6. Patches dangling tool calls
701/// 7. Resolves image_file content parts to actual image data (if ImageResolver provided)
702/// 8. Calls the LLM with the messages
703/// 9. Stores the assistant response
704/// 10. Emits reason.completed event
705/// 11. Returns the result with tool calls (if any)
706pub struct ReasonAtom {
707    harness_store: Arc<dyn HarnessStore>,
708    agent_store: Arc<dyn AgentStore>,
709    session_store: Arc<dyn SessionStore>,
710    message_retriever: Arc<dyn MessageRetriever>,
711    provider_store: Arc<dyn ProviderStore>,
712    capability_registry: CapabilityRegistry,
713    driver_registry: DriverRegistry,
714    event_emitter: Arc<dyn EventEmitter>,
715    /// Optional image resolver for resolving image_file content parts
716    image_resolver: Option<Arc<dyn ImageResolver>>,
717    /// Optional file store for capabilities that need filesystem access
718    /// (e.g., agent_instructions reads AGENTS.md, skills_discovery scans for skills)
719    file_store: Option<Arc<dyn crate::traits::SessionFileSystem>>,
720    /// Optional heartbeater for stream-liveness signalling (EVE-531).
721    stream_heartbeater: Option<Arc<dyn crate::traits::StreamHeartbeater>>,
722    /// Optional provider stall timeout (EVE-531). Default: 120s.
723    provider_stall_timeout: Option<std::time::Duration>,
724    /// Optional durable tool result store for transcript repair (EVE-533).
725    durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
726    /// Optional partial-stream store for ContinuePartial recovery (EVE-532).
727    partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
728    /// Optional live reasoning-effort handle (EVE-595). When set and holding a
729    /// value, it overrides the message-derived effort on every LLM step, so a
730    /// tool can change effort mid-turn and have subsequent steps observe it.
731    reasoning_effort_handle: Option<crate::traits::ReasoningEffortHandle>,
732    /// Optional utility LLM service (EVE-573). Powers model-backed
733    /// end-of-message output guardrails (e.g. moderation). When absent, those
734    /// guardrails fail open and the seam is a no-op.
735    utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
736    /// Optional session schedule store. Used by the `usage_limit_auto_continue`
737    /// capability to schedule a one-shot continuation after a provider usage
738    /// limit resets. When absent, the capability degrades to a no-op (no
739    /// continuation is scheduled and the error copy makes no auto-resume
740    /// promise).
741    schedule_store: Option<Arc<dyn crate::traits::SessionScheduleStore>>,
742}
743
744impl ReasonAtom {
745    /// Create a new ReasonAtom
746    #[allow(clippy::too_many_arguments)]
747    pub fn new(
748        harness_store: impl HarnessStore + 'static,
749        agent_store: impl AgentStore + 'static,
750        session_store: impl SessionStore + 'static,
751        message_retriever: impl MessageRetriever + 'static,
752        provider_store: impl ProviderStore + 'static,
753        capability_registry: CapabilityRegistry,
754        driver_registry: DriverRegistry,
755        event_emitter: impl EventEmitter + 'static,
756    ) -> Self {
757        Self {
758            harness_store: Arc::new(harness_store),
759            agent_store: Arc::new(agent_store),
760            session_store: Arc::new(session_store),
761            message_retriever: Arc::new(message_retriever),
762            provider_store: Arc::new(provider_store),
763            capability_registry,
764            driver_registry,
765            event_emitter: Arc::new(event_emitter),
766            image_resolver: None,
767            file_store: None,
768            stream_heartbeater: None,
769            provider_stall_timeout: None,
770            durable_tool_result_store: None,
771            partial_stream_store: None,
772            reasoning_effort_handle: None,
773            utility_llm_service: None,
774            schedule_store: None,
775        }
776    }
777
778    /// Set the session schedule store used by `usage_limit_auto_continue` to
779    /// schedule a continuation after a provider usage limit resets.
780    pub fn with_schedule_store(
781        mut self,
782        store: Arc<dyn crate::traits::SessionScheduleStore>,
783    ) -> Self {
784        self.schedule_store = Some(store);
785        self
786    }
787
788    /// Collect the [`LlmErrorHook`]s contributed by the active capabilities,
789    /// paired with each capability's per-agent config. Hooks are invoked
790    /// generically on the terminal-error path; the reason atom has no knowledge
791    /// of any specific capability's behavior. Capabilities that contribute no
792    /// hook — the common case — are skipped at zero allocation cost.
793    fn collect_llm_error_hooks(
794        &self,
795        resolved_capability_configs: &[crate::AgentCapabilityConfig],
796    ) -> Vec<(
797        Arc<dyn crate::llm_error_hook::LlmErrorHook>,
798        serde_json::Value,
799    )> {
800        resolved_capability_configs
801            .iter()
802            .filter_map(|cfg| {
803                let cap = self.capability_registry.get(cfg.capability_ref.as_str())?;
804                let hook = cap.llm_error_hook()?;
805                Some((hook, cfg.config.clone()))
806            })
807            .collect()
808    }
809
810    /// Set the file store for capabilities that need filesystem access.
811    ///
812    /// Provides filesystem access to capabilities via `SystemPromptContext`.
813    /// Capabilities like `agent_instructions` (reads AGENTS.md) and
814    /// `skills_discovery` (scans for skills) use this to generate dynamic
815    /// system prompt content.
816    pub fn with_file_store(
817        mut self,
818        file_store: Arc<dyn crate::traits::SessionFileSystem>,
819    ) -> Self {
820        self.file_store = Some(file_store);
821        self
822    }
823
824    /// Set the image resolver for resolving image_file content parts
825    ///
826    /// When set, image_file references in messages will be resolved to actual
827    /// image data before being sent to the LLM. This is required for multimodal
828    /// conversations that include image attachments.
829    ///
830    /// # Example
831    ///
832    /// ```ignore
833    /// let resolver = Arc::new(GrpcImageResolver::new(client));
834    /// let atom = ReasonAtom::new(/* ... */).with_image_resolver(resolver);
835    /// ```
836    pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
837        self.image_resolver = Some(resolver);
838        self
839    }
840
841    /// Set the stream heartbeater for liveness signalling during LLM streaming.
842    pub fn with_stream_heartbeater(
843        mut self,
844        heartbeater: Arc<dyn crate::traits::StreamHeartbeater>,
845    ) -> Self {
846        self.stream_heartbeater = Some(heartbeater);
847        self
848    }
849
850    /// Set the provider stall timeout. If no token arrives within this window,
851    /// the stream is aborted and the activity fails with a retryable error.
852    pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
853        self.provider_stall_timeout = Some(timeout);
854        self
855    }
856
857    /// Set the durable tool result store for transcript repair (EVE-533).
858    ///
859    /// When provided, transcript repair consults this store to replay settled tool
860    /// results or synthesize appropriate interrupted placeholders rather than always
861    /// emitting a generic "cancelled" message.
862    pub fn with_durable_tool_result_store(
863        mut self,
864        store: Arc<dyn DurableToolResultStore>,
865    ) -> Self {
866        self.durable_tool_result_store = Some(store);
867        self
868    }
869
870    /// Set the partial-stream store for ContinuePartial recovery (EVE-532).
871    pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
872        self.partial_stream_store = Some(store);
873        self
874    }
875
876    /// Set the live reasoning-effort handle (EVE-595).
877    ///
878    /// When set and holding a value, the effort it carries overrides the
879    /// message-derived effort for every LLM step. Because the handle is shared
880    /// and re-read on each step, a tool that mutates it mid-turn causes
881    /// subsequent steps in the same turn to use the new effort.
882    pub fn with_reasoning_effort_handle(
883        mut self,
884        handle: crate::traits::ReasoningEffortHandle,
885    ) -> Self {
886        self.reasoning_effort_handle = Some(handle);
887        self
888    }
889
890    /// Set the utility LLM service used by model-backed end-of-message output
891    /// guardrails (EVE-573). When unset, those guardrails fail open.
892    pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
893        self.utility_llm_service = Some(service);
894        self
895    }
896}
897
898#[async_trait]
899impl Atom for ReasonAtom {
900    type Input = ReasonInput;
901    type Output = ReasonResult;
902
903    fn name(&self) -> &'static str {
904        "reason"
905    }
906
907    async fn execute(&self, input: Self::Input) -> Result<Self::Output> {
908        self.execute_inner(input, None).await
909    }
910}
911
912impl ReasonAtom {
913    /// Execute using a pre-assembled turn context.
914    ///
915    /// Hosts that already assembled turn context for the current reason phase can
916    /// pass it through here to avoid reloading messages and rebuilding the agent.
917    pub async fn execute_with_assembled_context(
918        &self,
919        input: ReasonInput,
920        assembled: AssembledTurnContext,
921    ) -> Result<ReasonResult> {
922        self.execute_inner(input, Some(assembled)).await
923    }
924
925    async fn emit_capability_usage_snapshot(
926        &self,
927        session_id: SessionId,
928        context: &AtomContext,
929        resolved_capability_configs: &[crate::AgentCapabilityConfig],
930        tool_definitions: &[ToolDefinition],
931    ) {
932        let records = capability_usage_snapshot_records(
933            &self.capability_registry,
934            resolved_capability_configs,
935            tool_definitions,
936        );
937        if records.is_empty() {
938            return;
939        }
940
941        if let Err(error) = self
942            .event_emitter
943            .emit(EventRequest::new(
944                session_id,
945                EventContext::from_atom_context(context),
946                CapabilityUsageData { records },
947            ))
948            .await
949        {
950            tracing::warn!(
951                session_id = %session_id,
952                error = %error,
953                "ReasonAtom: failed to emit capability.usage event"
954            );
955        }
956    }
957
958    /// Repair malformed tool-call arguments via the opt-in `tool_call_repair`
959    /// capability (EVE-600). No-op unless the capability is in the resolved set,
960    /// keeping the default path byte-for-byte unchanged. Runs deterministic
961    /// local salvage on each call and emits one `tool.call_repaired` event per
962    /// malformed call with an outcome label. The bounded corrective re-prompt is
963    /// realized by the outer agent loop: an un-salvaged call proceeds unchanged
964    /// to the act phase (today's error path) and the model retries next
965    /// iteration; the per-call attempt cap is enforced by `ToolCallRepairConfig`.
966    async fn repair_malformed_tool_calls(
967        &self,
968        session_id: SessionId,
969        context: &AtomContext,
970        resolved_capability_configs: &[crate::AgentCapabilityConfig],
971        tool_definitions: &[ToolDefinition],
972        tool_calls: &mut [ToolCall],
973        iteration: u32,
974    ) {
975        apply_tool_call_repair(
976            &self.capability_registry,
977            self.event_emitter.as_ref(),
978            session_id,
979            context,
980            resolved_capability_configs,
981            tool_definitions,
982            tool_calls,
983            iteration,
984        )
985        .await;
986    }
987
988    async fn execute_inner(
989        &self,
990        input: ReasonInput,
991        assembled: Option<AssembledTurnContext>,
992    ) -> Result<ReasonResult> {
993        let ReasonInput {
994            context,
995            harness_id,
996            agent_id,
997            org_id,
998            mcp_tool_definitions,
999            previous_response_id,
1000            iteration,
1001        } = input;
1002
1003        tracing::info!(
1004            session_id = %context.session_id,
1005            turn_id = %context.turn_id,
1006            exec_id = %context.exec_id,
1007            harness_id = %harness_id,
1008            agent_id = ?agent_id,
1009            mcp_tools_count = %mcp_tool_definitions.len(),
1010            "ReasonAtom: starting LLM call"
1011        );
1012
1013        // Generate OTel-style span IDs for hierarchical tracing
1014        // trace_id: groups all events in this turn
1015        // span_id: unique identifier for this reason span (shared by started/completed)
1016        // parent_span_id: links to turn as parent
1017        //
1018        // NOTE: TurnId::to_string() returns prefixed format (e.g., "turn_abc123")
1019        // matching the format used by turn.started/completed events in Braintrust.
1020        let trace_id = context.turn_id.to_string();
1021        let reason_span_id = Uuid::now_v7().to_string();
1022        let parent_span_id = trace_id.clone(); // Parent is the turn
1023
1024        // Create event context from atom context with span info
1025        let event_context = EventContext::from_atom_context(&context).with_span(
1026            trace_id.clone(),
1027            reason_span_id.clone(),
1028            Some(parent_span_id.clone()),
1029        );
1030
1031        // Track reason phase timing for Braintrust observability
1032        let reason_start = Instant::now();
1033
1034        // Emit reason.started event
1035        if let Err(e) = self
1036            .event_emitter
1037            .emit(EventRequest::new(
1038                context.session_id,
1039                event_context.clone(),
1040                ReasonStartedData {
1041                    harness_id,
1042                    agent_id,
1043                    metadata: None, // Will be populated after model resolution
1044                },
1045            ))
1046            .await
1047        {
1048            tracing::warn!(
1049                session_id = %context.session_id,
1050                error = %e,
1051                "ReasonAtom: failed to emit reason.started event"
1052            );
1053        }
1054
1055        // Assemble the turn context up-front so the error path below knows
1056        // the resolved provider/model and the error-disclosure mode even when
1057        // the LLM call (or the assembly itself) fails.
1058        let assembled = match assembled {
1059            Some(assembled) => Ok(assembled),
1060            None => {
1061                assemble_turn_context(
1062                    self.harness_store.as_ref(),
1063                    self.agent_store.as_ref(),
1064                    self.session_store.as_ref(),
1065                    self.message_retriever.as_ref(),
1066                    self.provider_store.as_ref(),
1067                    &self.capability_registry,
1068                    context.session_id,
1069                    harness_id,
1070                    agent_id,
1071                    &mcp_tool_definitions,
1072                    self.file_store.clone(),
1073                )
1074                .await
1075            }
1076        };
1077
1078        let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
1079            Ok(assembled) => {
1080                let error_disclosure = crate::capabilities::resolve_error_disclosure(
1081                    &assembled.resolved_capability_configs,
1082                    error_disclosure_override(&assembled.messages).as_deref(),
1083                );
1084                // Collected before `assembled` is consumed by the LLM call so the
1085                // terminal-error path below can run capability error hooks even
1086                // though it no longer has the capability configs.
1087                let error_hooks =
1088                    self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
1089                let error_context = UserFacingErrorContext::default()
1090                    .with_provider(assembled.model_with_provider.provider_type.to_string())
1091                    .with_model_id(assembled.model_with_provider.model.clone());
1092                let call_result = self
1093                    .execute_llm_call(
1094                        context.session_id,
1095                        harness_id,
1096                        agent_id,
1097                        org_id,
1098                        &context,
1099                        &trace_id,
1100                        &reason_span_id,
1101                        previous_response_id,
1102                        iteration,
1103                        assembled,
1104                    )
1105                    .await;
1106                (error_disclosure, error_context, error_hooks, call_result)
1107            }
1108            Err(error) => (
1109                ErrorDisclosure::default(),
1110                UserFacingErrorContext::default(),
1111                Vec::new(),
1112                Err(error),
1113            ),
1114        };
1115
1116        // Handle LLM call errors gracefully
1117        let result = match call_result {
1118            Ok(result) => {
1119                // Calculate reason phase duration
1120                let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
1121
1122                // Emit reason.completed event (same span as reason.started, parent is turn)
1123                let completed_context = EventContext::from_atom_context(&context).with_span(
1124                    trace_id.clone(),
1125                    reason_span_id.clone(), // Same span_id as started
1126                    Some(parent_span_id.clone()),
1127                );
1128                if let Err(e) = self
1129                    .event_emitter
1130                    .emit(EventRequest::new(
1131                        context.session_id,
1132                        completed_context,
1133                        ReasonCompletedData::success(
1134                            &result.text,
1135                            result.has_tool_calls,
1136                            result.tool_calls.len() as u32,
1137                            Some(reason_duration_ms),
1138                            result.usage.clone(),
1139                        ),
1140                    ))
1141                    .await
1142                {
1143                    tracing::warn!(
1144                        session_id = %context.session_id,
1145                        error = %e,
1146                        "ReasonAtom: failed to emit reason.completed event"
1147                    );
1148                }
1149                result
1150            }
1151            Err(e) => {
1152                // Calculate reason phase duration even for failures
1153                let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
1154
1155                // LLM call failure is a "normal" result per the spec
1156                // Return a result indicating failure with the error message
1157                tracing::warn!(
1158                    session_id = %context.session_id,
1159                    turn_id = %context.turn_id,
1160                    error = %e,
1161                    "ReasonAtom: LLM call failed"
1162                );
1163
1164                let error_msg = e.to_string();
1165                let mut source_error = e.user_facing_error(error_context);
1166
1167                // Only emit user-facing error events for non-transient errors.
1168                // Transient errors (server errors, rate limits, timeouts) will be
1169                // retried by the durable task engine. Emitting error events on each
1170                // retry attempt causes duplicate error messages in the UI.
1171                // The durable worker emits a single error event when all retries
1172                // are exhausted (DLQ).
1173                let is_transient = e.is_transient_llm_error()
1174                    || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
1175
1176                // Capability error-hook seam: on the terminal (non-retried)
1177                // error path, let active capabilities react — perform a side
1178                // effect and/or augment the user-facing error fields — before the
1179                // message is built. The atom stays behavior-agnostic; each hook
1180                // (e.g. `usage_limit_auto_continue`) owns its own logic.
1181                if !is_transient && !error_hooks.is_empty() {
1182                    let services = crate::llm_error_hook::LlmErrorHookServices {
1183                        schedule_store: self.schedule_store.clone(),
1184                    };
1185                    for (hook, config) in &error_hooks {
1186                        let outcome = {
1187                            let ctx = crate::llm_error_hook::LlmErrorContext {
1188                                session_id: context.session_id,
1189                                error_code: &source_error.code,
1190                                error_fields: &source_error.fields,
1191                                config,
1192                                services: &services,
1193                            };
1194                            hook.on_llm_error(&ctx).await
1195                        };
1196                        for (key, value) in outcome.extra_error_fields {
1197                            source_error = source_error.with_field(key, value);
1198                        }
1199                    }
1200                }
1201
1202                let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
1203                let user_error_text = user_error.fallback_message();
1204
1205                let mut output_message_id = None;
1206
1207                if !is_transient {
1208                    // Create error message for the user to see
1209                    let mut error_message = Message::assistant(&user_error_text);
1210                    let mut metadata = std::collections::HashMap::new();
1211                    user_error.apply_to_message_metadata(&mut metadata);
1212                    UserFacingError::apply_disclosure_to_message_metadata(
1213                        &mut metadata,
1214                        error_disclosure,
1215                        &source_error.code,
1216                    );
1217                    error_message.metadata = Some(metadata);
1218
1219                    output_message_id = Some(error_message.id);
1220
1221                    // Emit output.message.completed event (stores message as event with proper turn context)
1222                    // output.message.completed is child of reason span
1223                    let error_msg_context = EventContext::from_atom_context(&context).with_span(
1224                        trace_id.clone(),
1225                        Uuid::now_v7().to_string(),   // Own span_id
1226                        Some(reason_span_id.clone()), // Parent is reason span
1227                    );
1228                    if let Err(emit_err) = self
1229                        .event_emitter
1230                        .emit(EventRequest::new(
1231                            context.session_id,
1232                            error_msg_context,
1233                            OutputMessageCompletedData::new(error_message)
1234                                .with_user_facing_error(&user_error)
1235                                .with_error_disclosure(error_disclosure),
1236                        ))
1237                        .await
1238                    {
1239                        tracing::warn!(
1240                            session_id = %context.session_id,
1241                            error = %emit_err,
1242                            "ReasonAtom: failed to emit output.message.completed event for error"
1243                        );
1244                    }
1245                } else {
1246                    tracing::info!(
1247                        session_id = %context.session_id,
1248                        "ReasonAtom: skipping error event for transient LLM error (will be retried)"
1249                    );
1250                }
1251
1252                // Emit reason.completed event for failure (same span as started, parent is turn)
1253                let completed_context = EventContext::from_atom_context(&context).with_span(
1254                    trace_id.clone(),
1255                    reason_span_id.clone(), // Same span_id as started
1256                    Some(parent_span_id.clone()),
1257                );
1258                if let Err(emit_err) = self
1259                    .event_emitter
1260                    .emit(EventRequest::new(
1261                        context.session_id,
1262                        completed_context,
1263                        ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
1264                    ))
1265                    .await
1266                {
1267                    tracing::warn!(
1268                        session_id = %context.session_id,
1269                        error = %emit_err,
1270                        "ReasonAtom: failed to emit reason.completed event"
1271                    );
1272                }
1273
1274                ReasonResult {
1275                    success: false,
1276                    text: user_error_text,
1277                    tool_calls: vec![],
1278                    has_tool_calls: false,
1279                    tool_definitions: vec![],
1280                    max_iterations: default_max_iterations(),
1281                    error: Some(error_msg),
1282                    user_facing_error: Some(user_error),
1283                    error_disclosure: Some(error_disclosure),
1284                    usage: None,
1285                    output_message_id,
1286                    time_to_first_token_ms: None,
1287                    response_id: None,
1288                    locale: None,
1289                    network_access: None,
1290                    parallel_tool_calls: None,
1291                }
1292            }
1293        };
1294
1295        Ok(result)
1296    }
1297
1298    /// Execute the actual LLM call
1299    #[allow(clippy::too_many_arguments)]
1300    async fn execute_llm_call(
1301        &self,
1302        session_id: SessionId,
1303        harness_id: HarnessId,
1304        agent_id: Option<AgentId>,
1305        org_id: i64,
1306        context: &AtomContext,
1307        trace_id: &str,
1308        reason_span_id: &str,
1309        previous_response_id: Option<String>,
1310        iteration: u32,
1311        assembled: AssembledTurnContext,
1312    ) -> Result<ReasonResult> {
1313        let messages = assembled.messages;
1314        let prior_usage = assembled.session.usage.clone();
1315        let model_with_provider = assembled.model_with_provider;
1316        let resolved_model_id = assembled.resolved_model_id;
1317        let resolved_locale = assembled.resolved_locale;
1318        let compaction_config = assembled.compaction_config;
1319        let resolved_capability_configs = assembled.resolved_capability_configs;
1320        let runtime_agent = assembled.runtime_agent;
1321        let embedder_metadata = assembled.embedder_metadata;
1322
1323        self.emit_capability_usage_snapshot(
1324            session_id,
1325            context,
1326            &resolved_capability_configs,
1327            &runtime_agent.tools,
1328        )
1329        .await;
1330
1331        // Collect streaming output guardrail providers contributed by enabled
1332        // capabilities. Each tuple carries the contributing capability id, a
1333        // borrow of that capability's per-agent config (so arming below doesn't
1334        // need a second scan), and the provider itself. Capabilities that
1335        // contribute no guardrails — the common case — are skipped at zero
1336        // allocation cost.
1337        let guardrail_providers: Vec<(
1338            &str,
1339            &serde_json::Value,
1340            Arc<dyn crate::output_guardrail::OutputGuardrail>,
1341        )> = resolved_capability_configs
1342            .iter()
1343            .filter_map(|cfg| {
1344                let cap_id = cfg.capability_ref.as_str();
1345                let cap = self.capability_registry.get(cap_id)?;
1346                let guards = cap.output_guardrails();
1347                if guards.is_empty() {
1348                    return None;
1349                }
1350                Some(
1351                    guards
1352                        .into_iter()
1353                        .map(move |g| (cap_id, &cfg.config, g))
1354                        .collect::<Vec<_>>(),
1355                )
1356            })
1357            .flatten()
1358            .collect();
1359
1360        // End-of-message (post-generation) output guardrail providers (EVE-573).
1361        // Collected here alongside the streaming providers, but evaluated once
1362        // on the finalized assistant message after the stream ends. Capabilities
1363        // contribute nothing unless an applicable output check is configured, so
1364        // the common case stays free of work.
1365        let post_output_providers: Vec<PostGenerationProvider> = resolved_capability_configs
1366            .iter()
1367            .filter_map(|cfg| {
1368                let cap_id = cfg.capability_ref.as_str();
1369                let cap = self.capability_registry.get(cap_id)?;
1370                let providers = cap.post_output_guardrails_with_config(&cfg.config);
1371                if providers.is_empty() {
1372                    return None;
1373                }
1374                Some(
1375                    providers
1376                        .into_iter()
1377                        .map(move |provider| PostGenerationProvider {
1378                            capability_id: cap_id.to_string(),
1379                            provider,
1380                        })
1381                        .collect::<Vec<_>>(),
1382                )
1383            })
1384            .flatten()
1385            .collect();
1386
1387        // 7. Create LLM driver using factory
1388        let chat_driver = self.create_chat_driver(&model_with_provider)?;
1389        let stateful_response_continuation =
1390            previous_response_id.is_some() && chat_driver.supports_stateful_responses();
1391
1392        // 8. Resolve the reasoning effort for THIS LLM step.
1393        //    Source priority (re-evaluated on every step so mid-turn changes
1394        //    take effect, EVE-595):
1395        //      1. The live reasoning-effort handle, when set with a value. A
1396        //         tool can mutate this handle mid-turn; because we re-read it on
1397        //         each step, the next step in the same turn uses the new value.
1398        //      2. Otherwise the latest user message's `controls.reasoning.effort`.
1399        //    The chosen value is then gated against the model profile, exactly
1400        //    as before, so unsupported `reasoning` params are never sent to
1401        //    non-thinking models like gpt-4o-mini.
1402        let handle_effort = self
1403            .reasoning_effort_handle
1404            .as_ref()
1405            .and_then(|handle| handle.get());
1406        let raw_reasoning_effort = handle_effort.or_else(|| {
1407            messages
1408                .iter()
1409                .rev()
1410                .find(|m| m.role == MessageRole::User)
1411                .and_then(|m| m.controls.as_ref())
1412                .and_then(|c| c.reasoning.as_ref())
1413                .and_then(|r| r.effort.clone())
1414        });
1415        let reasoning_effort = raw_reasoning_effort.filter(|effort| {
1416            // Skip "none" — it means "don't use reasoning"
1417            if effort.eq_ignore_ascii_case("none") {
1418                return false;
1419            }
1420            // Check model profile; if profile exists and reasoning is false, strip it.
1421            // Unknown models (no profile) pass through — let the API decide.
1422            let profile = crate::model_profiles::get_model_profile(
1423                &model_with_provider.provider_type,
1424                &model_with_provider.model,
1425            );
1426            match profile {
1427                Some(p) if !p.reasoning => {
1428                    tracing::warn!(
1429                        model = %model_with_provider.model,
1430                        effort = %effort,
1431                        "Stripping reasoning_effort: model does not support reasoning"
1432                    );
1433                    false
1434                }
1435                _ => true,
1436            }
1437        });
1438
1439        // Resolve the speed (service tier) from the latest user message's
1440        // `controls.speed`, gated against the selected model profile. Sending
1441        // a tier outside a model's advertised set causes provider-side 400s.
1442        // Unknown models pass through — let the API decide.
1443        let speed = messages
1444            .iter()
1445            .rev()
1446            .find(|m| m.role == MessageRole::User)
1447            .and_then(|m| m.controls.as_ref())
1448            .and_then(|c| c.speed.clone())
1449            .filter(|speed| {
1450                let profile = crate::model_profiles::get_model_profile(
1451                    &model_with_provider.provider_type,
1452                    &model_with_provider.model,
1453                );
1454                let Some(p) = profile else {
1455                    return true;
1456                };
1457                let Some(speed_config) = p.speed else {
1458                    tracing::warn!(
1459                        model = %model_with_provider.model,
1460                        speed = %speed,
1461                        "Stripping speed: model does not support service tiers"
1462                    );
1463                    return false;
1464                };
1465                let supported = speed_config.values.iter().any(|value| {
1466                    matches!(
1467                        (&value.value, speed.as_str()),
1468                        (crate::model::Speed::Flex, "flex")
1469                            | (crate::model::Speed::Default, "default")
1470                            | (crate::model::Speed::Priority, "priority")
1471                    )
1472                });
1473                if !supported {
1474                    tracing::warn!(
1475                        model = %model_with_provider.model,
1476                        speed = %speed,
1477                        "Stripping speed: model does not support requested service tier"
1478                    );
1479                }
1480                supported
1481            });
1482
1483        // Resolve verbosity from the latest user message's `controls.verbosity`,
1484        // gated the same way: a model whose profile has no verbosity config
1485        // never gets a `verbosity` field (sending it to an unsupported model is
1486        // a 400). Unknown models pass through — let the API decide.
1487        let verbosity = messages
1488            .iter()
1489            .rev()
1490            .find(|m| m.role == MessageRole::User)
1491            .and_then(|m| m.controls.as_ref())
1492            .and_then(|c| c.verbosity.clone())
1493            .filter(|verbosity| {
1494                let profile = crate::model_profiles::get_model_profile(
1495                    &model_with_provider.provider_type,
1496                    &model_with_provider.model,
1497                );
1498                match profile {
1499                    Some(p) if p.verbosity.is_none() => {
1500                        tracing::warn!(
1501                            model = %model_with_provider.model,
1502                            verbosity = %verbosity,
1503                            "Stripping verbosity: model does not support verbosity control"
1504                        );
1505                        false
1506                    }
1507                    _ => true,
1508                }
1509            });
1510
1511        // 9. Check for an in-flight partial assistant stream from a previous worker (EVE-532).
1512        // If found, apply the ContinuePartial recovery policy: finalize from accumulated
1513        // text (if non-empty) or restart clean (if empty/usable partial only).
1514        if let Some(ref store) = self.partial_stream_store {
1515            let turn_id_str = context.turn_id.to_string();
1516            match store.get_partial_stream(session_id, &turn_id_str).await {
1517                Ok(Some(partial)) if !partial.accumulated.is_empty() => {
1518                    // Finalize: emit completed from persisted accumulated text.
1519                    return self
1520                        .finalize_partial_stream(
1521                            session_id,
1522                            context,
1523                            partial,
1524                            iteration,
1525                            runtime_agent.max_iterations,
1526                            &runtime_agent.tools,
1527                        )
1528                        .await;
1529                }
1530                Ok(Some(_)) => {
1531                    // Empty accumulated: restart clean — fall through to normal LLM call.
1532                    // Emit reason.recovered { mode: Restart } for observability.
1533                    let recovery_ctx = EventContext::from_atom_context(context);
1534                    let _ = self
1535                        .event_emitter
1536                        .emit(EventRequest::new(
1537                            session_id,
1538                            recovery_ctx,
1539                            ReasonRecoveredData {
1540                                turn_id: context.turn_id,
1541                                mode: RecoveryMode::Restart,
1542                                accumulated_len: 0,
1543                            },
1544                        ))
1545                        .await;
1546                    tracing::info!(
1547                        session_id = %session_id,
1548                        turn_id = %context.turn_id,
1549                        "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1550                    );
1551                }
1552                Ok(None) => {} // No partial; normal first-run execution.
1553                Err(e) => {
1554                    // Best-effort: log and continue with normal execution.
1555                    tracing::warn!(
1556                        session_id = %session_id,
1557                        turn_id = %context.turn_id,
1558                        error = %e,
1559                        "ReasonAtom: partial-stream store error; proceeding with normal execution"
1560                    );
1561                }
1562            }
1563        }
1564
1565        // 10. Repair dangling tool calls (EVE-533): ensure every assistant tool_call
1566        // has a matching ToolResult before the LLM call. Consults durable_tool_results
1567        // when available to replay settled results or synthesize interrupted placeholders.
1568        let repair_event_context = EventContext::from_atom_context(context);
1569        let patched_messages = repair_dangling_tool_calls(
1570            &messages,
1571            self.durable_tool_result_store.as_deref(),
1572            self.event_emitter.as_ref(),
1573            session_id,
1574            &repair_event_context,
1575            &context.turn_id.to_string(),
1576        )
1577        .await;
1578
1579        // 9b. Let enabled capabilities build a prompt-facing model view from
1580        // lossless stored messages. Storage remains unchanged.
1581        let model_view_providers = crate::capabilities::collect_model_view_providers(
1582            &resolved_capability_configs,
1583            &self.capability_registry,
1584            Some(model_with_provider.model.as_str()),
1585        );
1586        let model_view_context = crate::capabilities::ModelViewContext {
1587            session_id,
1588            prior_usage: prior_usage.as_ref(),
1589        };
1590        let mut context_messages =
1591            model_view_providers.apply_model_view(patched_messages, &model_view_context);
1592        context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1593            &context_messages,
1594            stateful_response_continuation,
1595        );
1596
1597        // 9c. Append live dynamic facts (e.g. the current time) at the tail.
1598        // Collected fresh each request so values are current, and delivered as a
1599        // trailing user-role message so they never fold into the cached system
1600        // prompt. `volatile_suffix_len` tells the Anthropic driver to anchor its
1601        // message cache breakpoint *before* this block, so the volatile tail
1602        // rides uncached while the conversation prefix stays cached.
1603        let mut volatile_suffix_len = 0usize;
1604        {
1605            let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1606            let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1607                &resolved_capability_configs,
1608                &self.capability_registry,
1609                Some(model_with_provider.model.as_str()),
1610                &facts_ctx,
1611            );
1612            if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1613                context_messages.push(Message::user(block));
1614                volatile_suffix_len = 1;
1615            }
1616        }
1617
1618        // 10. Resolve images from image_file references (if any)
1619        //
1620        // Image resolution converts image_file content parts (which only contain UUIDs)
1621        // into actual base64-encoded image data that can be sent to LLMs.
1622        let resolved_images = self.resolve_images(&context_messages).await;
1623
1624        // 11. Build LLM messages
1625        let mut llm_messages = Vec::new();
1626
1627        // Add system prompt
1628        let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1629        if has_system_prompt {
1630            llm_messages.push(LlmMessage {
1631                role: LlmMessageRole::System,
1632                content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1633                tool_calls: None,
1634                tool_call_id: None,
1635                phase: None,
1636                thinking: None,
1637                thinking_signature: None,
1638            });
1639        }
1640
1641        // Build messages for llm.generation event (includes system message)
1642        let messages_for_event: Vec<Message> = if has_system_prompt {
1643            std::iter::once(Message::system(&runtime_agent.system_prompt))
1644                .chain(context_messages.iter().cloned())
1645                .collect()
1646        } else {
1647            context_messages.clone()
1648        };
1649
1650        // Add conversation messages with resolved images.
1651        // For user messages with an external_actor, prefix the first text part
1652        // with the actor's display label so the LLM knows who is speaking.
1653        // Skip error placeholder messages from prior failed turns — they add
1654        // no conversational value and inflate the request.
1655        let mut stripped_error_count = 0u32;
1656        for msg in &context_messages {
1657            if is_error_placeholder_message(msg) {
1658                stripped_error_count += 1;
1659                continue;
1660            }
1661            let mut llm_msg =
1662                crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1663            if msg.role == MessageRole::User
1664                && let Some(ref actor) = msg.external_actor
1665            {
1666                llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1667            }
1668            llm_messages.push(llm_msg);
1669        }
1670        if stripped_error_count > 0 {
1671            tracing::info!(
1672                session_id = %session_id,
1673                stripped_error_count,
1674                "ReasonAtom: stripped error placeholder messages from LLM input"
1675            );
1676        }
1677
1678        // Context reducers operate on prompt-facing copies and may select only
1679        // one side of a tool exchange at a window boundary. Stateless requests
1680        // must be self-contained; stateful Responses requests may retain
1681        // result-only deltas whose calls live behind `previous_response_id`.
1682        llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1683            llm_messages,
1684            stateful_response_continuation,
1685        );
1686
1687        // 12. Build LLM call config with reasoning effort and metadata
1688        let mut llm_config_builder =
1689            crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1690        if let Some(effort) = reasoning_effort.clone() {
1691            llm_config_builder = llm_config_builder.reasoning_effort(effort);
1692        }
1693        if let Some(speed) = speed {
1694            llm_config_builder = llm_config_builder.speed(speed);
1695        }
1696        if let Some(verbosity) = verbosity {
1697            llm_config_builder = llm_config_builder.verbosity(verbosity);
1698        }
1699
1700        // Inject embedder metadata first; system keys added below take precedence
1701        for (k, v) in &embedder_metadata {
1702            llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1703        }
1704
1705        // Add metadata for API tracking and debugging
1706        // These IDs help correlate API requests with Everruns entities
1707        // TypedId::to_string() produces prefixed format (e.g., "session_abc123")
1708        llm_config_builder = llm_config_builder
1709            .with_metadata("session_id", session_id.to_string())
1710            .with_metadata("harness_id", harness_id.to_string())
1711            .with_metadata("turn_id", context.turn_id.to_string())
1712            .with_metadata("exec_id", context.exec_id.to_string())
1713            .with_metadata("org_id", format!("org_{:032x}", org_id));
1714        if let Some(agent_id) = agent_id {
1715            llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1716        }
1717
1718        // Add model_id if we have one (not available for system default model)
1719        if let Some(model_id) = &resolved_model_id {
1720            llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1721        }
1722
1723        let llm_config = llm_config_builder
1724            .previous_response_id(previous_response_id.clone())
1725            .volatile_suffix_len(volatile_suffix_len)
1726            .build();
1727
1728        tracing::debug!(
1729            session_id = %session_id,
1730            turn_id = %context.turn_id,
1731            model = %runtime_agent.model,
1732            message_count = %llm_messages.len(),
1733            "ReasonAtom: calling LLM"
1734        );
1735
1736        // 13. Emit output.message.started event BEFORE starting LLM call
1737        // This allows UI to show a thinking indicator immediately
1738        let streaming_event_context = EventContext::from_atom_context(context);
1739
1740        // Arm output guardrails for this stream. Each guardrail sees the
1741        // assembled system prompt and its own per-capability config (already
1742        // borrowed in `guardrail_providers` above, so no second scan over
1743        // `resolved_capability_configs`). Guardrails that decline to arm —
1744        // e.g. the canary couldn't extract a long-enough sentence — are
1745        // skipped, leaving the streaming hot path entirely free of work.
1746        let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1747        for (cap_id, cfg, provider) in &guardrail_providers {
1748            let ctx = OutputGuardrailContext {
1749                system_prompt: &runtime_agent.system_prompt,
1750                config: cfg,
1751            };
1752            let guardrail_id = provider.id().to_string();
1753            if let Some(run) = provider.arm(&ctx) {
1754                armed_guardrails.push(ArmedGuardrail {
1755                    capability_id: (*cap_id).to_string(),
1756                    guardrail_id,
1757                    run,
1758                });
1759            }
1760        }
1761        let mut tripped: Option<TrippedGuardrail> = None;
1762        // Blocking post-generation guardrails need the full assistant message
1763        // before they can decide. When active, withhold text deltas until the
1764        // seam allows the finalized output so blocked tokens are never emitted
1765        // or persisted as output.message.delta events.
1766        let buffer_output_deltas = !post_output_providers.is_empty();
1767        // Allocate the public message id before the first lifecycle event so
1768        // started/delta/replaced/completed can be grouped without turn-level
1769        // heuristics. Each reasoning iteration reaches this point separately.
1770        let output_message_id = MessageId::new();
1771        tracing::info!(
1772            session_id = %session_id,
1773            turn_id = %context.turn_id,
1774            "ReasonAtom: emitting output.message.started event"
1775        );
1776        if let Err(e) = self
1777            .event_emitter
1778            .emit(EventRequest::new(
1779                session_id,
1780                streaming_event_context.clone(),
1781                OutputMessageStartedData {
1782                    turn_id: context.turn_id,
1783                    message_id: output_message_id,
1784                    model: Some(runtime_agent.model.clone()),
1785                    iteration: Some(iteration),
1786                    // Emitted before the LLM call — phase is not yet known, so the
1787                    // streamed hint starts `None` (treat as assistant text).
1788                    phase: None,
1789                },
1790            ))
1791            .await
1792        {
1793            tracing::warn!(
1794                session_id = %session_id,
1795                error = %e,
1796                "ReasonAtom: failed to emit output.message.started event"
1797            );
1798        } else {
1799            tracing::info!(
1800                session_id = %session_id,
1801                "ReasonAtom: output.message.started event emitted successfully"
1802            );
1803        }
1804
1805        // Also emit reason.thinking.started if extended thinking is enabled
1806        let thinking_enabled = reasoning_effort.is_some();
1807        if thinking_enabled {
1808            tracing::info!(
1809                session_id = %session_id,
1810                turn_id = %context.turn_id,
1811                "ReasonAtom: emitting reason.thinking.started event"
1812            );
1813            if let Err(e) = self
1814                .event_emitter
1815                .emit(EventRequest::new(
1816                    session_id,
1817                    streaming_event_context.clone(),
1818                    ReasonThinkingStartedData {
1819                        turn_id: context.turn_id,
1820                        model: Some(runtime_agent.model.clone()),
1821                    },
1822                ))
1823                .await
1824            {
1825                tracing::warn!(
1826                    session_id = %session_id,
1827                    error = %e,
1828                    "ReasonAtom: failed to emit reason.thinking.started event"
1829                );
1830            } else {
1831                tracing::info!(
1832                    session_id = %session_id,
1833                    "ReasonAtom: reason.thinking.started event emitted successfully"
1834                );
1835            }
1836        }
1837
1838        // Track LLM call timing
1839        let llm_start = Instant::now();
1840
1841        // Try LLM call with automatic compaction on RequestTooLarge.
1842        // Transient errors (429, 5xx) are retried at the driver level.
1843        // Stream-level errors are not retried here to avoid duplicate user-visible messages.
1844        let mut compaction_info: Option<LlmCompactionInfo> = None;
1845        let mut llm_messages_for_call = llm_messages.clone();
1846
1847        // 13b. Proactive compaction: check token budget BEFORE calling the LLM.
1848        // This avoids the latency of a RequestTooLarge round-trip.
1849        if let Some(ref config) = compaction_config {
1850            let context_window = crate::model_profiles::get_model_profile(
1851                &model_with_provider.provider_type,
1852                &model_with_provider.model,
1853            )
1854            .and_then(|p| p.limits.map(|l| l.context as usize))
1855            .unwrap_or(128_000);
1856
1857            if crate::capabilities::should_compact_proactively(
1858                &llm_messages_for_call,
1859                config,
1860                context_window,
1861            ) {
1862                use crate::capabilities::{
1863                    CompactionStrategy, aggressive_trim, apply_observation_masking,
1864                    estimate_total_tokens,
1865                };
1866                use crate::events::{
1867                    CompactionReason, CompactionStepData, ContextCompactedData,
1868                    ContextCompactingData,
1869                };
1870
1871                let messages_before = llm_messages_for_call.len();
1872                let cascade_start = Instant::now();
1873                let mut strategies_used: Vec<String> = Vec::new();
1874                let mut steps: Vec<CompactionStepData> = Vec::new();
1875
1876                tracing::info!(
1877                    session_id = %session_id,
1878                    strategy = %config.strategy,
1879                    messages = messages_before,
1880                    "ReasonAtom: proactive compaction triggered (budget threshold exceeded)"
1881                );
1882
1883                // Emit context.compacting event
1884                let _ = self
1885                    .event_emitter
1886                    .emit(EventRequest::new(
1887                        session_id,
1888                        streaming_event_context.clone(),
1889                        ContextCompactingData {
1890                            reason: CompactionReason::ProactiveBudget,
1891                            strategy: config.strategy.to_string(),
1892                            messages_before,
1893                        },
1894                    ))
1895                    .await;
1896
1897                let run_masking = matches!(
1898                    config.strategy,
1899                    CompactionStrategy::Auto | CompactionStrategy::ObservationMasking
1900                );
1901
1902                // Step 1: Observation masking (free)
1903                if run_masking {
1904                    let step_start = Instant::now();
1905                    let conversation_msgs = if has_system_prompt {
1906                        &llm_messages_for_call[1..]
1907                    } else {
1908                        &llm_messages_for_call[..]
1909                    };
1910
1911                    let masking_result =
1912                        apply_observation_masking(conversation_msgs, &config.observation_masking);
1913
1914                    if masking_result.masked_count > 0 {
1915                        let mut new_messages = Vec::new();
1916                        if has_system_prompt {
1917                            new_messages.push(llm_messages_for_call[0].clone());
1918                        }
1919                        new_messages.extend(masking_result.messages);
1920                        llm_messages_for_call = new_messages;
1921
1922                        let step_duration = step_start.elapsed().as_millis() as u64;
1923                        strategies_used.push("observation_masking".to_string());
1924                        steps.push(CompactionStepData {
1925                            strategy: "observation_masking".to_string(),
1926                            messages_after: llm_messages_for_call.len(),
1927                            duration_ms: step_duration,
1928                        });
1929                    }
1930                }
1931
1932                // Step 2: If still over budget after masking, apply aggressive trim
1933                let budget_tokens = (context_window as f32 * config.budget_percent) as usize;
1934                if estimate_total_tokens(&llm_messages_for_call) > budget_tokens {
1935                    let step_start = Instant::now();
1936                    llm_messages_for_call =
1937                        aggressive_trim(&llm_messages_for_call, budget_tokens, has_system_prompt);
1938
1939                    let step_duration = step_start.elapsed().as_millis() as u64;
1940                    strategies_used.push("aggressive_trim".to_string());
1941                    steps.push(CompactionStepData {
1942                        strategy: "aggressive_trim".to_string(),
1943                        messages_after: llm_messages_for_call.len(),
1944                        duration_ms: step_duration,
1945                    });
1946                }
1947
1948                let cascade_duration = cascade_start.elapsed().as_millis() as u64;
1949                let messages_after = llm_messages_for_call.len();
1950
1951                if !strategies_used.is_empty() {
1952                    let strategy_used = strategies_used.join("+");
1953
1954                    let _ = self
1955                        .event_emitter
1956                        .emit(EventRequest::new(
1957                            session_id,
1958                            streaming_event_context.clone(),
1959                            ContextCompactedData {
1960                                strategy_used: strategy_used.clone(),
1961                                messages_before,
1962                                messages_after,
1963                                duration_ms: cascade_duration,
1964                                steps,
1965                            },
1966                        ))
1967                        .await;
1968
1969                    tracing::info!(
1970                        session_id = %session_id,
1971                        strategy = %strategy_used,
1972                        messages_before,
1973                        messages_after,
1974                        duration_ms = cascade_duration,
1975                        "ReasonAtom: proactive compaction completed"
1976                    );
1977                }
1978            }
1979        }
1980
1981        // 14. Process stream with batched output.message.delta emissions
1982        // Batch deltas every 100ms to reduce event volume while providing real-time feedback
1983        const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1984        let retry_config = LlmRetryConfig::default();
1985        let mut stream_retry_metadata = RetryMetadata::default();
1986        // Best-effort streamed phase hint (EVE-774). Starts `None` ("not yet
1987        // classified — treat as assistant text") and is refined monotonically
1988        // once a provider reveals a native phase mid-stream. Declared outside the
1989        // retry loop so it is available to the post-loop guarded delta emission.
1990        let mut streamed_phase: Option<crate::message::ExecutionPhase> = None;
1991        let (
1992            text,
1993            thinking,
1994            thinking_signature,
1995            tool_calls,
1996            completion_metadata,
1997            time_to_first_token_ms,
1998            pending_delta,
1999        ) = 'stream_attempt: loop {
2000            let mut stream = match chat_driver
2001                .chat_completion_stream(llm_messages_for_call.clone(), &llm_config)
2002                .await
2003            {
2004                Ok(stream) => stream,
2005                Err(e) if e.is_request_too_large() => {
2006                    // Context too large — run compaction cascade
2007                    use crate::capabilities::{CompactionStrategy, apply_observation_masking};
2008                    use crate::events::{
2009                        CompactionReason, CompactionStepData, ContextCompactedData,
2010                        ContextCompactingData,
2011                    };
2012
2013                    let Some(config) = compaction_config.clone() else {
2014                        tracing::warn!(
2015                            session_id = %session_id,
2016                            turn_id = %context.turn_id,
2017                            "ReasonAtom: context too large and compaction capability is not enabled"
2018                        );
2019                        return Err(e);
2020                    };
2021                    let messages_before = llm_messages_for_call.len();
2022
2023                    tracing::info!(
2024                        session_id = %session_id,
2025                        turn_id = %context.turn_id,
2026                        strategy = %config.strategy,
2027                        messages = messages_before,
2028                        "ReasonAtom: context too large, attempting compaction"
2029                    );
2030
2031                    // Emit context.compacting event
2032                    let _ = self
2033                        .event_emitter
2034                        .emit(EventRequest::new(
2035                            session_id,
2036                            streaming_event_context.clone(),
2037                            ContextCompactingData {
2038                                reason: CompactionReason::RequestTooLarge,
2039                                strategy: config.strategy.to_string(),
2040                                messages_before,
2041                            },
2042                        ))
2043                        .await;
2044
2045                    let cascade_start = Instant::now();
2046                    let mut steps: Vec<CompactionStepData> = Vec::new();
2047                    let mut strategies_used: Vec<String> = Vec::new();
2048
2049                    // Determine which strategies to run based on config
2050                    let run_masking = matches!(
2051                        config.strategy,
2052                        CompactionStrategy::Auto | CompactionStrategy::ObservationMasking
2053                    );
2054                    let run_native = matches!(
2055                        config.strategy,
2056                        CompactionStrategy::Auto | CompactionStrategy::Native
2057                    ) && chat_driver.supports_compact();
2058                    let run_summarization = matches!(
2059                        config.strategy,
2060                        CompactionStrategy::Auto | CompactionStrategy::Summarization
2061                    );
2062
2063                    // Step 1: Observation masking (free, no LLM call)
2064                    if run_masking {
2065                        let step_start = Instant::now();
2066                        let conversation_msgs = if has_system_prompt {
2067                            &llm_messages_for_call[1..]
2068                        } else {
2069                            &llm_messages_for_call[..]
2070                        };
2071
2072                        let masking_result = apply_observation_masking(
2073                            conversation_msgs,
2074                            &config.observation_masking,
2075                        );
2076
2077                        if masking_result.masked_count > 0 {
2078                            let mut new_messages = Vec::new();
2079                            if has_system_prompt {
2080                                new_messages.push(llm_messages_for_call[0].clone());
2081                            }
2082                            new_messages.extend(masking_result.messages);
2083                            llm_messages_for_call = new_messages;
2084
2085                            let step_duration = step_start.elapsed().as_millis() as u64;
2086                            strategies_used.push("observation_masking".to_string());
2087                            steps.push(CompactionStepData {
2088                                strategy: "observation_masking".to_string(),
2089                                messages_after: llm_messages_for_call.len(),
2090                                duration_ms: step_duration,
2091                            });
2092
2093                            tracing::info!(
2094                                session_id = %session_id,
2095                                masked_count = masking_result.masked_count,
2096                                duration_ms = step_duration,
2097                                "ReasonAtom: observation masking applied"
2098                            );
2099                        }
2100                    }
2101
2102                    // Step 2: Native provider compaction
2103                    if run_native {
2104                        let step_start = Instant::now();
2105                        let messages_to_compact = if has_system_prompt {
2106                            &llm_messages_for_call[1..]
2107                        } else {
2108                            &llm_messages_for_call[..]
2109                        };
2110
2111                        let compact_input = messages_to_compact_input(messages_to_compact);
2112                        let input_count = compact_input.len();
2113
2114                        let compact_request = CompactRequest {
2115                            model: runtime_agent.model.clone(),
2116                            input: compact_input,
2117                            previous_response_id: previous_response_id.clone(),
2118                            instructions: if has_system_prompt {
2119                                Some(runtime_agent.system_prompt.clone())
2120                            } else {
2121                                None
2122                            },
2123                        };
2124
2125                        match chat_driver.compact(compact_request).await {
2126                            Ok(Some(compact_response)) => {
2127                                let (compacted_messages, compaction_items) =
2128                                    compact_output_to_messages(&compact_response.output);
2129
2130                                let input_tokens_after = compact_response
2131                                    .usage
2132                                    .as_ref()
2133                                    .and_then(|u| u.output_tokens);
2134
2135                                compaction_info = Some(LlmCompactionInfo::new(
2136                                    Some(input_count as u32),
2137                                    input_tokens_after,
2138                                    Some(step_start.elapsed().as_millis() as u64),
2139                                ));
2140
2141                                let mut compacted_llm_messages = Vec::new();
2142                                if has_system_prompt {
2143                                    compacted_llm_messages.push(llm_messages_for_call[0].clone());
2144                                }
2145                                compacted_llm_messages.extend(compacted_messages);
2146
2147                                for item in compaction_items {
2148                                    if let CompactInputItem::Compaction { encrypted_content } = item
2149                                    {
2150                                        compacted_llm_messages.push(LlmMessage {
2151                                            role: LlmMessageRole::System,
2152                                            content: LlmMessageContent::Text(format!(
2153                                                "[COMPACTED_CONTEXT:{encrypted_content}]"
2154                                            )),
2155                                            tool_calls: None,
2156                                            tool_call_id: None,
2157                                            phase: None,
2158                                            thinking: None,
2159                                            thinking_signature: None,
2160                                        });
2161                                    }
2162                                }
2163
2164                                llm_messages_for_call =
2165                                    crate::tool_call_integrity::retain_complete_llm_tool_exchanges(
2166                                        compacted_llm_messages,
2167                                    );
2168
2169                                let step_duration = step_start.elapsed().as_millis() as u64;
2170                                strategies_used.push("native".to_string());
2171                                steps.push(CompactionStepData {
2172                                    strategy: "native".to_string(),
2173                                    messages_after: llm_messages_for_call.len(),
2174                                    duration_ms: step_duration,
2175                                });
2176
2177                                tracing::info!(
2178                                    session_id = %session_id,
2179                                    duration_ms = step_duration,
2180                                    messages_after = llm_messages_for_call.len(),
2181                                    "ReasonAtom: native compaction applied"
2182                                );
2183                            }
2184                            Ok(None) | Err(_) => {
2185                                tracing::warn!(
2186                                    session_id = %session_id,
2187                                    "ReasonAtom: native compaction unavailable, continuing cascade"
2188                                );
2189                            }
2190                        }
2191                    }
2192
2193                    // Step 3: Summarization (if configured, and native didn't run or isn't available)
2194                    // Only run if we haven't done native compaction (which already compressed everything)
2195                    if run_summarization && !strategies_used.contains(&"native".to_string()) {
2196                        use crate::capabilities::{
2197                            build_summarization_prompt, compose_summary_with_recent,
2198                            format_messages_for_summarization,
2199                        };
2200
2201                        let step_start = Instant::now();
2202                        let conversation_msgs = if has_system_prompt {
2203                            &llm_messages_for_call[1..]
2204                        } else {
2205                            &llm_messages_for_call[..]
2206                        };
2207
2208                        // Keep the last few messages verbatim, summarize the rest
2209                        let keep_recent = 10.min(conversation_msgs.len());
2210                        let to_summarize =
2211                            &conversation_msgs[..conversation_msgs.len() - keep_recent];
2212                        let recent = &conversation_msgs[conversation_msgs.len() - keep_recent..];
2213
2214                        if !to_summarize.is_empty() {
2215                            let summary_prompt = build_summarization_prompt(&config.summarization);
2216                            let messages_text = format_messages_for_summarization(to_summarize);
2217
2218                            // Use the LLM to generate a summary
2219                            let summary_messages = vec![
2220                                LlmMessage {
2221                                    role: LlmMessageRole::System,
2222                                    content: LlmMessageContent::Text(summary_prompt),
2223                                    tool_calls: None,
2224                                    tool_call_id: None,
2225                                    phase: None,
2226                                    thinking: None,
2227                                    thinking_signature: None,
2228                                },
2229                                LlmMessage {
2230                                    role: LlmMessageRole::User,
2231                                    content: LlmMessageContent::Text(messages_text),
2232                                    tool_calls: None,
2233                                    tool_call_id: None,
2234                                    phase: None,
2235                                    thinking: None,
2236                                    thinking_signature: None,
2237                                },
2238                            ];
2239
2240                            let summary_config = crate::driver_registry::LlmCallConfig {
2241                                speed: None,
2242                                verbosity: None,
2243                                model: config
2244                                    .summarization
2245                                    .model
2246                                    .clone()
2247                                    .unwrap_or_else(|| runtime_agent.model.clone()),
2248                                temperature: Some(0.0),
2249                                max_tokens: Some(2000),
2250                                tools: vec![],
2251                                reasoning_effort: None,
2252                                metadata: HashMap::new(),
2253                                previous_response_id: None,
2254                                tool_search: None,
2255                                prompt_cache: None,
2256                                openrouter_routing: None,
2257                                parallel_tool_calls: None,
2258                                volatile_suffix_len: 0,
2259                            };
2260
2261                            match chat_driver
2262                                .chat_completion(summary_messages, &summary_config)
2263                                .await
2264                            {
2265                                Ok(response) => {
2266                                    let summary_text = response.text;
2267                                    let system_message =
2268                                        has_system_prompt.then(|| llm_messages_for_call[0].clone());
2269                                    llm_messages_for_call = compose_summary_with_recent(
2270                                        system_message,
2271                                        &summary_text,
2272                                        recent,
2273                                    );
2274
2275                                    let step_duration = step_start.elapsed().as_millis() as u64;
2276                                    strategies_used.push("summarization".to_string());
2277                                    steps.push(CompactionStepData {
2278                                        strategy: "summarization".to_string(),
2279                                        messages_after: llm_messages_for_call.len(),
2280                                        duration_ms: step_duration,
2281                                    });
2282
2283                                    tracing::info!(
2284                                        session_id = %session_id,
2285                                        duration_ms = step_duration,
2286                                        messages_after = llm_messages_for_call.len(),
2287                                        "ReasonAtom: summarization applied"
2288                                    );
2289                                }
2290                                Err(e) => {
2291                                    tracing::warn!(
2292                                        session_id = %session_id,
2293                                        error = %e,
2294                                        "ReasonAtom: summarization failed, continuing"
2295                                    );
2296                                }
2297                            }
2298                        }
2299                    }
2300
2301                    // Step 4: Aggressive trim (last resort — drop oldest messages)
2302                    // Only run if previous strategies didn't reduce context enough.
2303                    // Use a generous target (half the estimated original size).
2304                    if strategies_used.is_empty()
2305                        || llm_messages_for_call.len() > messages_before / 2
2306                    {
2307                        use crate::capabilities::aggressive_trim;
2308                        let step_start = Instant::now();
2309                        // Target: keep roughly half the messages by token budget
2310                        let estimated_total =
2311                            crate::capabilities::estimate_total_tokens(&llm_messages_for_call);
2312                        let target = estimated_total / 2;
2313                        let trimmed =
2314                            aggressive_trim(&llm_messages_for_call, target, has_system_prompt);
2315                        if trimmed.len() < llm_messages_for_call.len() {
2316                            llm_messages_for_call = trimmed;
2317                            let step_duration = step_start.elapsed().as_millis() as u64;
2318                            strategies_used.push("aggressive_trim".to_string());
2319                            steps.push(CompactionStepData {
2320                                strategy: "aggressive_trim".to_string(),
2321                                messages_after: llm_messages_for_call.len(),
2322                                duration_ms: step_duration,
2323                            });
2324                            tracing::info!(
2325                                session_id = %session_id,
2326                                messages_after = llm_messages_for_call.len(),
2327                                "ReasonAtom: aggressive trim applied (last resort)"
2328                            );
2329                        }
2330                    }
2331
2332                    let cascade_duration = cascade_start.elapsed().as_millis() as u64;
2333                    let messages_after = llm_messages_for_call.len();
2334
2335                    // Emit context.compacted event
2336                    let strategy_used = if strategies_used.is_empty() {
2337                        "none".to_string()
2338                    } else {
2339                        strategies_used.join("+")
2340                    };
2341
2342                    let _ = self
2343                        .event_emitter
2344                        .emit(EventRequest::new(
2345                            session_id,
2346                            streaming_event_context.clone(),
2347                            ContextCompactedData {
2348                                strategy_used: strategy_used.clone(),
2349                                messages_before,
2350                                messages_after,
2351                                duration_ms: cascade_duration,
2352                                steps,
2353                            },
2354                        ))
2355                        .await;
2356
2357                    tracing::info!(
2358                        session_id = %session_id,
2359                        strategy = %strategy_used,
2360                        messages_before,
2361                        messages_after,
2362                        duration_ms = cascade_duration,
2363                        "ReasonAtom: compaction cascade completed, retrying LLM call"
2364                    );
2365
2366                    chat_driver
2367                        .chat_completion_stream(llm_messages_for_call.clone(), &llm_config)
2368                        .await?
2369                }
2370                Err(e) => return Err(e),
2371            };
2372
2373            let mut text = String::new();
2374            let mut thinking = String::new();
2375            let mut thinking_signature: Option<String> = None;
2376            let mut tool_calls = Vec::new();
2377            let mut completion_metadata: Option<LlmCompletionMetadata> = None;
2378            let mut stream_has_output = false;
2379            let mut pending_delta = String::new();
2380            let mut pending_thinking_delta = String::new();
2381            let mut last_delta_emit = Instant::now();
2382            let mut last_thinking_delta_emit = Instant::now();
2383            let mut time_to_first_token_ms: Option<u64> = None;
2384
2385            // EVE-531: stall timeout + keepalive heartbeat for stream-liveness
2386            let stall_timeout = self
2387                .provider_stall_timeout
2388                .unwrap_or(std::time::Duration::from_secs(120));
2389            let mut stall_sleep = Box::pin(tokio::time::sleep(stall_timeout));
2390            let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
2391            keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2392            keepalive_ticker.tick().await; // consume immediate first tick
2393            let mut last_stream_heartbeat = Instant::now();
2394            // Tracks the wall-clock time of the last actual token received.
2395            // Updated only on content events; keepalive heartbeats use this
2396            // so the control plane can distinguish "alive/slow" from "making
2397            // progress" without conflating keepalive pings with real tokens.
2398            let mut last_token_at_unix: u64 = unix_now_secs();
2399
2400            loop {
2401                let event = tokio::select! {
2402                    biased;
2403                    next = stream.next() => match next {
2404                        Some(e) => e,
2405                        None => break,
2406                    },
2407                    _ = &mut stall_sleep => {
2408                        tracing::warn!(
2409                            session_id = %session_id,
2410                            turn_id = %context.turn_id,
2411                            stall_secs = stall_timeout.as_secs(),
2412                            "ReasonAtom: provider stream stall timeout"
2413                        );
2414                        return Err(AgentLoopError::llm(format!(
2415                            "provider stream stall: no tokens for {}s",
2416                            stall_timeout.as_secs()
2417                        )));
2418                    },
2419                    _ = keepalive_ticker.tick() => {
2420                        if let Some(ref hb) = self.stream_heartbeater {
2421                            hb.heartbeat(crate::traits::StreamProgress {
2422                                accumulated_len: text.len() + thinking.len(),
2423                                last_delta_at: last_token_at_unix,
2424                            })
2425                            .await;
2426                            last_stream_heartbeat = Instant::now();
2427                        }
2428                        continue;
2429                    },
2430                };
2431                let event = event?;
2432                let advances_stall_deadline = stream_event_advances_stall_deadline(&event);
2433                if advances_stall_deadline {
2434                    stall_sleep
2435                        .as_mut()
2436                        .reset(tokio::time::Instant::now() + stall_timeout);
2437                    last_token_at_unix = unix_now_secs();
2438                }
2439                match event {
2440                    LlmStreamEvent::TextDelta(delta) => {
2441                        if delta.is_empty() {
2442                            continue;
2443                        }
2444                        stream_has_output = true;
2445                        // Track time-to-first-token on first non-empty delta
2446                        if time_to_first_token_ms.is_none() {
2447                            let ttft = llm_start.elapsed().as_millis() as u64;
2448                            time_to_first_token_ms = Some(ttft);
2449                            tracing::info!(
2450                                session_id = %session_id,
2451                                time_to_first_token_ms = ttft,
2452                                "ReasonAtom: received first token from LLM"
2453                            );
2454                        }
2455                        text.push_str(&delta);
2456                        pending_delta.push_str(&delta);
2457
2458                        // Run output guardrails on the new accumulated text.
2459                        // Cheap by contract — runs in the streaming hot path.
2460                        // On block: suppress the pending delta (the bad text
2461                        // never reaches the client as a delta), record the
2462                        // trip, and break the loop. The replacement message is
2463                        // emitted below after the streaming block.
2464                        if !armed_guardrails.is_empty()
2465                            && let Some(t) =
2466                                evaluate_guardrails(&mut armed_guardrails, &text, &delta)
2467                        {
2468                            tracing::warn!(
2469                                session_id = %session_id,
2470                                turn_id = %context.turn_id,
2471                                guardrail_capability_id = %t.capability_id,
2472                                guardrail_id = %t.guardrail_id,
2473                                reason_code = %t.block.reason_code,
2474                                "ReasonAtom: output guardrail tripped, replacing assistant message"
2475                            );
2476                            pending_delta.clear();
2477                            tripped = Some(t);
2478                            break;
2479                        }
2480
2481                        // Emit batched delta if interval elapsed
2482                        if !buffer_output_deltas
2483                            && last_delta_emit.elapsed().as_millis() as u64
2484                                >= DELTA_BATCH_INTERVAL_MS
2485                            && !pending_delta.is_empty()
2486                        {
2487                            if let Err(e) = self
2488                                .event_emitter
2489                                .emit(EventRequest::new(
2490                                    session_id,
2491                                    streaming_event_context.clone(),
2492                                    OutputMessageDeltaData {
2493                                        turn_id: context.turn_id,
2494                                        message_id: output_message_id,
2495                                        delta: pending_delta.clone(),
2496                                        accumulated: text.clone(),
2497                                        phase: streamed_phase,
2498                                    },
2499                                ))
2500                                .await
2501                            {
2502                                tracing::warn!(
2503                                    session_id = %session_id,
2504                                    error = %e,
2505                                    "ReasonAtom: failed to emit output.message.delta event"
2506                                );
2507                            }
2508                            pending_delta.clear();
2509                            last_delta_emit = Instant::now();
2510                        }
2511                    }
2512                    LlmStreamEvent::ThinkingDelta(delta) => {
2513                        if delta.is_empty() {
2514                            continue;
2515                        }
2516                        stream_has_output = true;
2517                        if let Some(t) = append_guarded_thinking_delta(
2518                            &mut armed_guardrails,
2519                            &mut thinking,
2520                            &mut pending_thinking_delta,
2521                            &delta,
2522                        ) {
2523                            tracing::warn!(
2524                                session_id = %session_id,
2525                                guardrail_capability_id = %t.capability_id,
2526                                guardrail_id = %t.guardrail_id,
2527                                "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
2528                            );
2529                            tripped = Some(t);
2530                            break;
2531                        }
2532                        tracing::debug!(
2533                            session_id = %session_id,
2534                            delta_len = delta.len(),
2535                            total_thinking_len = thinking.len(),
2536                            "ReasonAtom: received ThinkingDelta from LLM"
2537                        );
2538
2539                        // Emit batched thinking delta if interval elapsed
2540                        if last_thinking_delta_emit.elapsed().as_millis() as u64
2541                            >= DELTA_BATCH_INTERVAL_MS
2542                            && !pending_thinking_delta.is_empty()
2543                        {
2544                            if let Err(e) = self
2545                                .event_emitter
2546                                .emit(EventRequest::new(
2547                                    session_id,
2548                                    streaming_event_context.clone(),
2549                                    ReasonThinkingDeltaData {
2550                                        turn_id: context.turn_id,
2551                                        delta: pending_thinking_delta.clone(),
2552                                        accumulated: thinking.clone(),
2553                                    },
2554                                ))
2555                                .await
2556                            {
2557                                tracing::warn!(
2558                                    session_id = %session_id,
2559                                    error = %e,
2560                                    "ReasonAtom: failed to emit reason.thinking.delta event"
2561                                );
2562                            }
2563                            pending_thinking_delta.clear();
2564                            last_thinking_delta_emit = Instant::now();
2565                        }
2566                    }
2567                    LlmStreamEvent::ThinkingSignature(signature) => {
2568                        stream_has_output = true;
2569                        // Capture the cryptographic signature for thinking content (required to send it back)
2570                        tracing::debug!(
2571                            session_id = %session_id,
2572                            signature_len = signature.len(),
2573                            "ReasonAtom: received ThinkingSignature from LLM"
2574                        );
2575                        thinking_signature = Some(signature);
2576                    }
2577                    LlmStreamEvent::ReasonItem {
2578                        provider,
2579                        model,
2580                        item_id,
2581                        encrypted_content,
2582                        summary,
2583                        token_count,
2584                    } => {
2585                        stream_has_output = true;
2586                        // Preserve the opaque artifact as the assistant message's
2587                        // thinking_signature so the next request can replay
2588                        // reasoning context, and emit a durable reason.item event
2589                        // for trace/session review. Plaintext reasoning content is
2590                        // never included.
2591                        if let Some(sig) = encrypted_content.as_ref() {
2592                            tracing::debug!(
2593                                session_id = %session_id,
2594                                signature_len = sig.len(),
2595                                provider = %provider,
2596                                item_id = %item_id,
2597                                "ReasonAtom: captured encrypted reasoning content from ReasonItem"
2598                            );
2599                            thinking_signature = Some(sig.clone());
2600                        }
2601                        if let Err(e) = self
2602                            .event_emitter
2603                            .emit(EventRequest::new(
2604                                session_id,
2605                                streaming_event_context.clone(),
2606                                ReasonItemData {
2607                                    turn_id: context.turn_id,
2608                                    provider,
2609                                    model,
2610                                    item_id,
2611                                    encrypted_content,
2612                                    summary,
2613                                    token_count,
2614                                },
2615                            ))
2616                            .await
2617                        {
2618                            tracing::warn!(
2619                                session_id = %session_id,
2620                                error = %e,
2621                                "ReasonAtom: failed to emit reason.item event"
2622                            );
2623                        }
2624                    }
2625                    LlmStreamEvent::ToolCalls(calls) => {
2626                        stream_has_output |= !calls.is_empty();
2627                        tool_calls = calls;
2628                    }
2629                    LlmStreamEvent::MessagePhase(phase) => {
2630                        // Provider revealed a native phase for the current
2631                        // assistant message mid-stream. Refine the streamed hint
2632                        // monotonically (never flip-flop, never back to None);
2633                        // subsequent output.message.delta events carry it. This is
2634                        // a hint only — it is NOT a completion signal and does not
2635                        // count as stream output. The completed Message.phase stays
2636                        // authoritative, and the hint is deliberately not derived
2637                        // from later tool-call presence (EVE-448 anti-pattern).
2638                        streamed_phase = crate::message::ExecutionPhase::refine_streamed_hint(
2639                            streamed_phase,
2640                            phase,
2641                        );
2642                    }
2643                    LlmStreamEvent::Done(metadata) => {
2644                        // Emit any remaining pending delta before completing,
2645                        // unless a post-generation guardrail must first inspect
2646                        // the finalized assistant text.
2647                        if !buffer_output_deltas
2648                            && !pending_delta.is_empty()
2649                            && let Err(e) = self
2650                                .event_emitter
2651                                .emit(EventRequest::new(
2652                                    session_id,
2653                                    streaming_event_context.clone(),
2654                                    OutputMessageDeltaData {
2655                                        turn_id: context.turn_id,
2656                                        message_id: output_message_id,
2657                                        delta: pending_delta.clone(),
2658                                        accumulated: text.clone(),
2659                                        phase: streamed_phase,
2660                                    },
2661                                ))
2662                                .await
2663                        {
2664                            tracing::warn!(
2665                                session_id = %session_id,
2666                                error = %e,
2667                                "ReasonAtom: failed to emit final output.message.delta event"
2668                            );
2669                        }
2670
2671                        // Emit any remaining pending thinking delta before completing
2672                        if !pending_thinking_delta.is_empty()
2673                            && let Err(e) = self
2674                                .event_emitter
2675                                .emit(EventRequest::new(
2676                                    session_id,
2677                                    streaming_event_context.clone(),
2678                                    ReasonThinkingDeltaData {
2679                                        turn_id: context.turn_id,
2680                                        delta: pending_thinking_delta.clone(),
2681                                        accumulated: thinking.clone(),
2682                                    },
2683                                ))
2684                                .await
2685                        {
2686                            tracing::warn!(
2687                                session_id = %session_id,
2688                                error = %e,
2689                                "ReasonAtom: failed to emit final reason.thinking.delta event"
2690                            );
2691                        }
2692
2693                        // Emit reason.thinking.completed if we had any thinking content
2694                        if !thinking.is_empty()
2695                            && let Err(e) = self
2696                                .event_emitter
2697                                .emit(EventRequest::new(
2698                                    session_id,
2699                                    streaming_event_context.clone(),
2700                                    ReasonThinkingCompletedData {
2701                                        turn_id: context.turn_id,
2702                                        thinking: thinking.clone(),
2703                                    },
2704                                ))
2705                                .await
2706                        {
2707                            tracing::warn!(
2708                                session_id = %session_id,
2709                                error = %e,
2710                                "ReasonAtom: failed to emit reason.thinking.completed event"
2711                            );
2712                        }
2713                        completion_metadata = Some(*metadata);
2714                        break;
2715                    }
2716                    LlmStreamEvent::Error(err) => {
2717                        // If we already collected valid tool calls or text before
2718                        // the error arrived, treat it as a partial success. This
2719                        // handles OpenAI Responses API behaviour where a trailing
2720                        // server_error can follow fully-streamed function calls.
2721                        let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
2722
2723                        if has_partial_output {
2724                            tracing::warn!(
2725                                session_id = %session_id,
2726                                error = %err,
2727                                tool_call_count = tool_calls.len(),
2728                                text_len = text.len(),
2729                                "ReasonAtom: trailing stream error after valid output — treating as partial success"
2730                            );
2731                            // Break out of the stream loop and use the output
2732                            // we already collected. completion_metadata will be
2733                            // None since we never got a Done event.
2734                            break;
2735                        }
2736
2737                        if should_retry_stream_error(
2738                            &err,
2739                            stream_retry_metadata.attempts,
2740                            retry_config.max_retries,
2741                            stream_has_output,
2742                        ) {
2743                            let wait_duration =
2744                                retry_config.calculate_backoff(stream_retry_metadata.attempts);
2745                            tracing::warn!(
2746                                session_id = %session_id,
2747                                turn_id = %context.turn_id,
2748                                attempt = stream_retry_metadata.attempts + 1,
2749                                max_retries = retry_config.max_retries,
2750                                wait_secs = wait_duration.as_secs_f64(),
2751                                error_code = err.code.as_deref().unwrap_or("none"),
2752                                error_status = err.status,
2753                                error = %err,
2754                                "ReasonAtom: transient stream error before output, retrying"
2755                            );
2756                            stream_retry_metadata.record_retry(wait_duration, None);
2757                            tokio::time::sleep(wait_duration).await;
2758                            continue 'stream_attempt;
2759                        }
2760
2761                        // No useful output collected — treat as a real failure.
2762                        let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2763                        let event_context = EventContext::from_atom_context(context).with_span(
2764                            trace_id.to_string(),
2765                            Uuid::now_v7().to_string(),
2766                            Some(reason_span_id.to_string()),
2767                        );
2768                        let tools_summary: Vec<ToolDefinitionSummary> =
2769                            runtime_agent.tools.iter().map(|t| t.into()).collect();
2770                        let generation_data = LlmGenerationData::failure(
2771                            messages_for_event.clone(),
2772                            tools_summary,
2773                            runtime_agent.model.clone(),
2774                            Some(model_with_provider.provider_type.to_string()),
2775                            err.to_string(),
2776                            Some(llm_duration_ms),
2777                            time_to_first_token_ms,
2778                        );
2779                        let _ = self
2780                            .event_emitter
2781                            .emit(EventRequest::new(
2782                                session_id,
2783                                event_context,
2784                                generation_data,
2785                            ))
2786                            .await;
2787                        return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
2788                    }
2789                }
2790                // Per-event heartbeat after processing the event, so accumulated_len
2791                // reflects the just-received tokens. Throttled to every 5s.
2792                if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2793                    && let Some(ref hb) = self.stream_heartbeater
2794                {
2795                    hb.heartbeat(crate::traits::StreamProgress {
2796                        accumulated_len: text.len() + thinking.len(),
2797                        last_delta_at: last_token_at_unix,
2798                    })
2799                    .await;
2800                    last_stream_heartbeat = Instant::now();
2801                }
2802            }
2803            if let Some(metadata) = completion_metadata.as_mut() {
2804                metadata.retry_metadata =
2805                    merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2806            }
2807
2808            break 'stream_attempt (
2809                text,
2810                thinking,
2811                thinking_signature,
2812                tool_calls,
2813                completion_metadata,
2814                time_to_first_token_ms,
2815                pending_delta,
2816            );
2817        };
2818        let (mut text, mut thinking, thinking_signature, mut tool_calls) =
2819            (text, thinking, thinking_signature, tool_calls);
2820
2821        // End-of-message output guardrail seam (EVE-573). Runs once on the
2822        // finalized assistant text after streaming completes, before the
2823        // message is built into context. Async, time-bounded, and fail-open
2824        // (each provider owns its own timeout). Skipped when the streaming seam
2825        // already tripped (the message is already a replacement), when there
2826        // are no post-generation providers, or when there is no assistant text.
2827        // A trip here reuses the same replacement/emit path as the streaming
2828        // seam below, so the original tokens are never persisted.
2829        if tripped.is_none() && !post_output_providers.is_empty() && !text.is_empty() {
2830            let ctx = PostGenerationOutputContext {
2831                system_prompt: &runtime_agent.system_prompt,
2832                message_text: &text,
2833                utility_llm_service: self.utility_llm_service.as_ref(),
2834            };
2835            tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2836        }
2837
2838        // Release buffered text only after post-generation guardrails allow it.
2839        // If they block, the replacement path below emits only sanitized text.
2840        if buffer_output_deltas
2841            && tripped.is_none()
2842            && !pending_delta.is_empty()
2843            && let Err(e) = self
2844                .event_emitter
2845                .emit(EventRequest::new(
2846                    session_id,
2847                    streaming_event_context.clone(),
2848                    OutputMessageDeltaData {
2849                        turn_id: context.turn_id,
2850                        message_id: output_message_id,
2851                        delta: pending_delta.clone(),
2852                        accumulated: text.clone(),
2853                        phase: streamed_phase,
2854                    },
2855                ))
2856                .await
2857        {
2858            tracing::warn!(
2859                session_id = %session_id,
2860                error = %e,
2861                "ReasonAtom: failed to emit guarded output.message.delta event"
2862            );
2863        }
2864
2865        // If a streaming output guardrail tripped, emit
2866        // output.message.replaced and overwrite the assistant output now so
2867        // every downstream event (llm.generation, output.message.completed)
2868        // carries the replacement instead of the model's withheld tokens.
2869        // The original tokens are never persisted or replayed.
2870        if let Some(ref t) = tripped {
2871            let replaced_event_context = EventContext::from_atom_context(context).with_span(
2872                trace_id.to_string(),
2873                Uuid::now_v7().to_string(),
2874                Some(reason_span_id.to_string()),
2875            );
2876            if let Err(e) = self
2877                .event_emitter
2878                .emit(EventRequest::new(
2879                    session_id,
2880                    replaced_event_context,
2881                    OutputMessageReplacedData {
2882                        turn_id: context.turn_id,
2883                        message_id: output_message_id,
2884                        guardrail_capability_id: t.capability_id.clone(),
2885                        guardrail_id: t.guardrail_id.clone(),
2886                        reason_code: t.block.reason_code.clone(),
2887                        replacement: t.block.replacement.clone(),
2888                    },
2889                ))
2890                .await
2891            {
2892                tracing::warn!(
2893                    session_id = %session_id,
2894                    error = %e,
2895                    "ReasonAtom: failed to emit output.message.replaced event"
2896                );
2897            }
2898            text = t.block.replacement.clone();
2899            tool_calls.clear();
2900            thinking.clear();
2901        }
2902
2903        // Tool-call repair seam (EVE-600). This is the smallest provider-agnostic
2904        // hook where a malformed tool call can still be intercepted: `tool_calls`
2905        // is finalized here but the assistant message and downstream events have
2906        // not been built yet. The opt-in `tool_call_repair` capability runs
2907        // deterministic local salvage on each call's `arguments` and emits a
2908        // `tool.call_repaired` event per malformed call. When the capability is
2909        // disabled (the default) this is a no-op and behavior is unchanged.
2910        if !tool_calls.is_empty() {
2911            self.repair_malformed_tool_calls(
2912                session_id,
2913                context,
2914                &resolved_capability_configs,
2915                &runtime_agent.tools,
2916                &mut tool_calls,
2917                iteration,
2918            )
2919            .await;
2920        }
2921
2922        let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2923
2924        // Extract response_id from completion metadata for chaining and OTel
2925        let response_id = completion_metadata
2926            .as_ref()
2927            .and_then(|meta| meta.response_id.clone());
2928
2929        // 15. Convert completion metadata to TokenUsage.
2930        //
2931        // Cost is tracked as two independent values: the provider's authoritative
2932        // inline cost when present (e.g. OpenRouter's usage.cost), and a price-table
2933        // estimate from the model profile computed whenever profile cost data
2934        // exists. Keeping both lets downstream consumers prefer the actual charge
2935        // while still reconciling estimate-vs-actual drift.
2936        let usage = completion_metadata.as_ref().and_then(|meta| {
2937            match (meta.prompt_tokens, meta.completion_tokens) {
2938                (Some(input), Some(output)) => {
2939                    let actual_cost_usd = meta.provider_cost_usd;
2940                    let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2941                        &model_with_provider.provider_type,
2942                        &runtime_agent.model,
2943                        input,
2944                        output,
2945                        meta.cache_read_tokens.unwrap_or(0),
2946                        meta.cache_creation_tokens.unwrap_or(0),
2947                    );
2948                    Some(
2949                        TokenUsage::with_cache(
2950                            input,
2951                            output,
2952                            meta.cache_read_tokens,
2953                            meta.cache_creation_tokens,
2954                        )
2955                        .with_cost(actual_cost_usd, estimated_cost_usd),
2956                    )
2957                }
2958                _ => None,
2959            }
2960        });
2961
2962        // 16. Emit llm.generation event (child of reason span)
2963        let event_context = EventContext::from_atom_context(context).with_span(
2964            trace_id.to_string(),
2965            Uuid::now_v7().to_string(),
2966            Some(reason_span_id.to_string()),
2967        );
2968        let tools_summary: Vec<ToolDefinitionSummary> =
2969            runtime_agent.tools.iter().map(|t| t.into()).collect();
2970        // Infer finish reasons from content
2971        let finish_reasons = if !tool_calls.is_empty() {
2972            Some(vec!["tool_calls".to_string()])
2973        } else {
2974            Some(vec!["stop".to_string()])
2975        };
2976        // Extract retry info from completion metadata (if retries occurred)
2977        let retry_info = completion_metadata
2978            .as_ref()
2979            .and_then(|meta| meta.retry_metadata.as_ref())
2980            .filter(|rm| rm.had_retries())
2981            .map(|rm| LlmRetryInfo {
2982                attempts: rm.attempts,
2983                total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2984            });
2985        // Build LlmGenerationData with retry and compaction info
2986        let mut generation_data = LlmGenerationData::success_with_retry(
2987            messages_for_event.clone(),
2988            tools_summary,
2989            Some(text.clone()).filter(|s| !s.is_empty()),
2990            tool_calls.clone(),
2991            runtime_agent.model.clone(),
2992            Some(model_with_provider.provider_type.to_string()),
2993            usage.clone(),
2994            Some(llm_duration_ms),
2995            time_to_first_token_ms,
2996            finish_reasons,
2997            response_id.clone(),
2998            retry_info,
2999        );
3000
3001        // Add compaction info if compaction was performed
3002        if let Some(info) = compaction_info {
3003            generation_data = generation_data.with_compaction(info);
3004        }
3005
3006        if let Some(request_options) =
3007            build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
3008        {
3009            generation_data = generation_data.with_request_options(request_options);
3010        }
3011
3012        if let Err(e) = self
3013            .event_emitter
3014            .emit(EventRequest::new(
3015                session_id,
3016                event_context,
3017                generation_data,
3018            ))
3019            .await
3020        {
3021            tracing::warn!(
3022                session_id = %session_id,
3023                error = %e,
3024                "ReasonAtom: failed to emit llm.generation event"
3025            );
3026        }
3027
3028        // 17. Build metadata with model and reasoning effort info
3029        let mut metadata = std::collections::HashMap::new();
3030        metadata.insert(
3031            "model".to_string(),
3032            serde_json::Value::String(runtime_agent.model.clone()),
3033        );
3034        if let Some(ref effort) = reasoning_effort {
3035            metadata.insert(
3036                "reasoning_effort".to_string(),
3037                serde_json::Value::String(effort.clone()),
3038            );
3039        }
3040        // Stamp the provider driver id and provider response id so the chat UI
3041        // can build a deep link to the provider's trace/logs for this message
3042        // (see ProviderTraceConfig). The resolved model carries the driver id,
3043        // not the concrete provider instance id, so the UI keys trace config by
3044        // driver. `response_id` is the provider's generation id (e.g.
3045        // OpenRouter's "gen-..."); absent for providers that do not return one.
3046        metadata.insert(
3047            "provider".to_string(),
3048            serde_json::Value::String(model_with_provider.provider_type.to_string()),
3049        );
3050        if let Some(ref rid) = response_id {
3051            metadata.insert(
3052                "response_id".to_string(),
3053                serde_json::Value::String(rid.clone()),
3054            );
3055        }
3056
3057        // 18. Store and emit output.message.completed event with metadata and usage.
3058        // Strip any synthetic `[time …]` annotation the model echoed from the
3059        // message_metadata model view before persisting/returning it (EVE-710),
3060        // so exact-output replies are not polluted by the injected prefix.
3061        let text = crate::capabilities::strip_leading_timestamp_annotations(&text);
3062        let has_tool_calls = !tool_calls.is_empty();
3063        let mut assistant_message = if has_tool_calls {
3064            Message::assistant_with_tools(&text, tool_calls.clone())
3065        } else {
3066            Message::assistant(&text)
3067        }
3068        .with_id(output_message_id);
3069        // Use the API-provided phase when available (preserving the provider's value),
3070        // otherwise derive from state: Commentary for intermediate iterations (with tool
3071        // calls), FinalAnswer for the completed response.
3072        assistant_message.phase = completion_metadata
3073            .as_ref()
3074            .and_then(|meta| meta.phase.as_deref())
3075            .and_then(crate::message::ExecutionPhase::from_provider_str)
3076            .or_else(|| {
3077                Some(crate::message::ExecutionPhase::from_has_tool_calls(
3078                    has_tool_calls,
3079                ))
3080            });
3081        assistant_message.metadata = Some(metadata);
3082        // Store thinking content and signature for extended thinking models
3083        // Both are required for subsequent API calls when thinking is enabled
3084        if !thinking.is_empty() {
3085            assistant_message.thinking = Some(thinking.clone());
3086            assistant_message.thinking_signature = thinking_signature.clone();
3087        }
3088        // Emit output.message.completed event (this stores the message as an event with proper turn context)
3089        // Include token usage for tracking (child of reason span)
3090        let message_event_context = EventContext::from_atom_context(context).with_span(
3091            trace_id.to_string(),
3092            Uuid::now_v7().to_string(),
3093            Some(reason_span_id.to_string()),
3094        );
3095        let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
3096        if let Some(ref u) = usage {
3097            output_message_data = output_message_data.with_usage(u.clone());
3098        }
3099        self.event_emitter
3100            .emit(EventRequest::new(
3101                session_id,
3102                message_event_context,
3103                output_message_data,
3104            ))
3105            .await?;
3106
3107        tracing::info!(
3108            session_id = %session_id,
3109            turn_id = %context.turn_id,
3110            has_tool_calls = %has_tool_calls,
3111            tool_count = %tool_calls.len(),
3112            "ReasonAtom: LLM call completed"
3113        );
3114
3115        Ok(ReasonResult {
3116            success: true,
3117            text,
3118            tool_calls,
3119            has_tool_calls,
3120            tool_definitions: runtime_agent.tools.clone(),
3121            max_iterations: runtime_agent.max_iterations,
3122            error: None,
3123            user_facing_error: None,
3124            error_disclosure: None,
3125            usage,
3126            output_message_id: Some(output_message_id),
3127            time_to_first_token_ms,
3128            response_id,
3129            locale: resolved_locale,
3130            network_access: runtime_agent.network_access.clone(),
3131            parallel_tool_calls: runtime_agent.parallel_tool_calls,
3132        })
3133    }
3134
3135    /// Finalize a partial assistant stream without making a new provider call (EVE-532).
3136    ///
3137    /// Emits `output.message.started`, `output.message.completed` from the persisted
3138    /// `accumulated` text, and `reason.recovered { mode: Finalize }`.
3139    async fn finalize_partial_stream(
3140        &self,
3141        session_id: SessionId,
3142        context: &AtomContext,
3143        partial: PartialStreamState,
3144        iteration: u32,
3145        max_iterations: usize,
3146        tool_definitions: &[ToolDefinition],
3147    ) -> Result<ReasonResult> {
3148        let event_context = EventContext::from_atom_context(context);
3149        let turn_id = context.turn_id;
3150        let message_id = partial.message_id;
3151
3152        // Signal that output is starting (keeps the streaming protocol intact).
3153        let _ = self
3154            .event_emitter
3155            .emit(EventRequest::new(
3156                session_id,
3157                event_context.clone(),
3158                OutputMessageStartedData {
3159                    turn_id,
3160                    message_id,
3161                    model: None,
3162                    iteration: Some(iteration),
3163                    // Recovery/finalize path reconstructs the started signal only;
3164                    // the streamed phase hint is unavailable here (None).
3165                    phase: None,
3166                },
3167            ))
3168            .await;
3169
3170        // Build the assistant message from accumulated text and persist via event.
3171        // Strip any echoed `[time …]` annotation the model produced (EVE-710).
3172        let accumulated =
3173            crate::capabilities::strip_leading_timestamp_annotations(&partial.accumulated);
3174        let assistant_message = Message::assistant(&accumulated).with_id(message_id);
3175        let output_message_id = message_id;
3176        self.event_emitter
3177            .emit(EventRequest::new(
3178                session_id,
3179                event_context.clone(),
3180                OutputMessageCompletedData::new(assistant_message),
3181            ))
3182            .await?;
3183
3184        // Emit observability event.
3185        let accumulated_len = accumulated.len();
3186        let _ = self
3187            .event_emitter
3188            .emit(EventRequest::new(
3189                session_id,
3190                event_context.clone(),
3191                ReasonRecoveredData {
3192                    turn_id,
3193                    mode: RecoveryMode::Finalize,
3194                    accumulated_len,
3195                },
3196            ))
3197            .await;
3198
3199        tracing::info!(
3200            session_id = %session_id,
3201            turn_id = %turn_id,
3202            accumulated_len,
3203            "ReasonAtom: finalized partial stream from persisted accumulated text"
3204        );
3205
3206        Ok(ReasonResult {
3207            success: true,
3208            text: accumulated,
3209            tool_calls: vec![],
3210            has_tool_calls: false,
3211            tool_definitions: tool_definitions.to_vec(),
3212            max_iterations,
3213            error: None,
3214            user_facing_error: None,
3215            error_disclosure: None,
3216            usage: None,
3217            output_message_id: Some(output_message_id),
3218            time_to_first_token_ms: None,
3219            response_id: None,
3220            locale: None,
3221            network_access: None,
3222            // Finalize path has no tool calls, so the preference is irrelevant.
3223            parallel_tool_calls: None,
3224        })
3225    }
3226
3227    /// Resolve model using priority chain: controls > session > agent > harness > system default
3228    /// Create LLM driver using the driver registry
3229    fn create_chat_driver(
3230        &self,
3231        model: &ResolvedModel,
3232    ) -> Result<crate::driver_registry::BoxedChatDriver> {
3233        self.driver_registry
3234            .create_chat_driver(&crate::llm_conversions::provider_config_from_resolved_model(model))
3235    }
3236
3237    /// Resolve image_file references to actual image data
3238    ///
3239    /// This method extracts all image_file IDs from the messages and resolves
3240    /// them to base64-encoded image data using the configured ImageResolver.
3241    ///
3242    /// # Returns
3243    ///
3244    /// A HashMap mapping image IDs to ResolvedImage data. If no ImageResolver
3245    /// is configured, or if resolution fails for some images, those images
3246    /// will simply be missing from the map (and converted to placeholder text).
3247    async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
3248        let mut resolved = HashMap::new();
3249
3250        // Check if we have an image resolver
3251        let resolver = match &self.image_resolver {
3252            Some(r) => r,
3253            None => return resolved,
3254        };
3255
3256        // Collect all unique image_file IDs from all messages
3257        let image_ids: Vec<Uuid> = messages
3258            .iter()
3259            .flat_map(crate::llm_conversions::extract_image_file_ids)
3260            .collect::<std::collections::HashSet<_>>()
3261            .into_iter()
3262            .collect();
3263
3264        if image_ids.is_empty() {
3265            return resolved;
3266        }
3267
3268        tracing::debug!(
3269            image_count = image_ids.len(),
3270            "ReasonAtom: resolving image_file references"
3271        );
3272
3273        // Resolve each image
3274        for image_id in image_ids {
3275            match resolver.resolve_image(image_id).await {
3276                Ok(Some(image)) => {
3277                    resolved.insert(image_id, image);
3278                }
3279                Ok(None) => {
3280                    tracing::warn!(
3281                        image_id = %image_id,
3282                        "ReasonAtom: image not found during resolution"
3283                    );
3284                }
3285                Err(e) => {
3286                    tracing::warn!(
3287                        image_id = %image_id,
3288                        error = %e,
3289                        "ReasonAtom: failed to resolve image"
3290                    );
3291                }
3292            }
3293        }
3294
3295        tracing::debug!(
3296            resolved_count = resolved.len(),
3297            "ReasonAtom: image resolution complete"
3298        );
3299
3300        resolved
3301    }
3302}
3303
3304// ============================================================================
3305// Tests
3306// ============================================================================
3307
3308#[cfg(test)]
3309mod tests {
3310    use super::*;
3311    use crate::driver_registry::{LlmCallConfig, PromptCacheConfig, PromptCacheStrategy};
3312    use std::collections::HashMap;
3313
3314    struct BlockWhenDeltaContains {
3315        needle: &'static str,
3316    }
3317
3318    impl crate::output_guardrail::OutputGuardrailRun for BlockWhenDeltaContains {
3319        fn check(
3320            &mut self,
3321            _accumulated: &str,
3322            delta: &str,
3323        ) -> crate::output_guardrail::GuardrailDecision {
3324            if delta.contains(self.needle) {
3325                crate::output_guardrail::GuardrailDecision::block("test_leak", "[blocked]")
3326            } else {
3327                crate::output_guardrail::GuardrailDecision::Pass
3328            }
3329        }
3330    }
3331
3332    fn test_armed_guardrail() -> ArmedGuardrail {
3333        ArmedGuardrail {
3334            capability_id: "test_capability".to_string(),
3335            guardrail_id: "test_guardrail".to_string(),
3336            run: Box::new(BlockWhenDeltaContains { needle: "secret" }),
3337        }
3338    }
3339
3340    #[test]
3341    fn test_append_guarded_thinking_delta_blocks_before_pending_emit() {
3342        let mut guardrails = vec![test_armed_guardrail()];
3343        let mut thinking = "safe ".to_string();
3344        let mut pending = "safe ".to_string();
3345
3346        let tripped = append_guarded_thinking_delta(
3347            &mut guardrails,
3348            &mut thinking,
3349            &mut pending,
3350            "secret instructions",
3351        )
3352        .expect("thinking delta should trip guardrail");
3353
3354        assert_eq!(tripped.capability_id, "test_capability");
3355        assert_eq!(tripped.guardrail_id, "test_guardrail");
3356        assert_eq!(tripped.block.reason_code, "test_leak");
3357        assert_eq!(tripped.block.replacement, "[blocked]");
3358        assert_eq!(thinking, "safe secret instructions");
3359        assert!(pending.is_empty());
3360    }
3361
3362    #[test]
3363    fn test_append_guarded_thinking_delta_allows_safe_pending_emit() {
3364        let mut guardrails = vec![test_armed_guardrail()];
3365        let mut thinking = String::new();
3366        let mut pending = String::new();
3367
3368        let tripped = append_guarded_thinking_delta(
3369            &mut guardrails,
3370            &mut thinking,
3371            &mut pending,
3372            "ordinary reasoning",
3373        );
3374
3375        assert!(tripped.is_none());
3376        assert_eq!(thinking, "ordinary reasoning");
3377        assert_eq!(pending, "ordinary reasoning");
3378    }
3379
3380    #[test]
3381    fn test_reason_result_default() {
3382        let result = ReasonResult::default();
3383        assert!(!result.success);
3384        assert!(result.text.is_empty());
3385        assert!(result.tool_calls.is_empty());
3386        assert!(!result.has_tool_calls);
3387        // Default derive gives 0, but serde deserialization gives 100 via default_max_iterations()
3388        assert_eq!(result.max_iterations, 0);
3389    }
3390
3391    #[test]
3392    fn test_reason_result_serde_default() {
3393        // Test that serde uses the default_max_iterations function
3394        let json = r#"{"success":true,"text":"","has_tool_calls":false}"#;
3395        let result: ReasonResult = serde_json::from_str(json).unwrap();
3396        assert_eq!(result.max_iterations, 500);
3397    }
3398
3399    #[test]
3400    fn test_capability_usage_snapshot_keeps_resolved_and_exposed_separate() {
3401        let registry = CapabilityRegistry::with_builtins();
3402        let tool = ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
3403            name: "demo_tool".to_string(),
3404            display_name: None,
3405            description: "demo".to_string(),
3406            parameters: json!({"type": "object"}),
3407            policy: crate::tool_types::ToolPolicy::Auto,
3408            category: None,
3409            deferrable: crate::tool_types::DeferrablePolicy::default(),
3410            hints: crate::tool_types::ToolHints::default(),
3411            full_parameters: None,
3412        })
3413        .with_capability_attribution("cap:demo", Some("Demo Capability"));
3414
3415        let records = capability_usage_snapshot_records(
3416            &registry,
3417            &[crate::AgentCapabilityConfig::new("current_time")],
3418            &[tool],
3419        );
3420
3421        assert!(records.iter().any(|record| {
3422            matches!(record.usage_kind, CapabilityUsageKind::Resolved)
3423                && record.capability_id == "current_time"
3424                && record.tool_name.is_none()
3425        }));
3426        assert!(records.iter().any(|record| {
3427            matches!(record.usage_kind, CapabilityUsageKind::Exposed)
3428                && record.capability_id == "cap:demo"
3429                && record.tool_name.as_deref() == Some("demo_tool")
3430        }));
3431    }
3432
3433    #[test]
3434    fn stream_stall_deadline_ignores_empty_keepalive_events() {
3435        assert!(!stream_event_advances_stall_deadline(
3436            &LlmStreamEvent::TextDelta(String::new())
3437        ));
3438        assert!(!stream_event_advances_stall_deadline(
3439            &LlmStreamEvent::ThinkingDelta(String::new())
3440        ));
3441        assert!(!stream_event_advances_stall_deadline(
3442            &LlmStreamEvent::ThinkingSignature("signature".to_string())
3443        ));
3444        assert!(!stream_event_advances_stall_deadline(
3445            &LlmStreamEvent::ReasonItem {
3446                provider: "openai".to_string(),
3447                model: None,
3448                item_id: "item_1".to_string(),
3449                encrypted_content: None,
3450                summary: vec![String::new()],
3451                token_count: Some(0),
3452            }
3453        ));
3454    }
3455
3456    #[test]
3457    fn stream_stall_deadline_advances_on_output_progress() {
3458        assert!(stream_event_advances_stall_deadline(
3459            &LlmStreamEvent::TextDelta("hello".to_string())
3460        ));
3461        assert!(stream_event_advances_stall_deadline(
3462            &LlmStreamEvent::ThinkingDelta("thinking".to_string())
3463        ));
3464        assert!(stream_event_advances_stall_deadline(
3465            &LlmStreamEvent::ReasonItem {
3466                provider: "openai".to_string(),
3467                model: Some("gpt-5.4".to_string()),
3468                item_id: "item_1".to_string(),
3469                encrypted_content: Some("encrypted".to_string()),
3470                summary: vec![],
3471                token_count: None,
3472            }
3473        ));
3474        assert!(stream_event_advances_stall_deadline(
3475            &LlmStreamEvent::ReasonItem {
3476                provider: "openai".to_string(),
3477                model: None,
3478                item_id: "item_2".to_string(),
3479                encrypted_content: None,
3480                summary: vec!["summary".to_string()],
3481                token_count: None,
3482            }
3483        ));
3484        assert!(stream_event_advances_stall_deadline(
3485            &LlmStreamEvent::ReasonItem {
3486                provider: "openai".to_string(),
3487                model: None,
3488                item_id: "item_3".to_string(),
3489                encrypted_content: None,
3490                summary: vec![],
3491                token_count: Some(1),
3492            }
3493        ));
3494        assert!(stream_event_advances_stall_deadline(
3495            &LlmStreamEvent::ToolCalls(vec![ToolCall {
3496                id: "call_1".to_string(),
3497                name: "demo".to_string(),
3498                arguments: json!({}),
3499            }])
3500        ));
3501    }
3502
3503    #[tokio::test]
3504    async fn test_repair_dangling_tool_calls_no_tool_calls() {
3505        use crate::events::EventContext;
3506        use crate::typed_id::SessionId;
3507        let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
3508        let emitter = crate::traits::NoopEventEmitter;
3509        let session_id = SessionId::new();
3510        let ctx = EventContext::empty();
3511        let patched =
3512            repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3513                .await;
3514        assert_eq!(patched.len(), 2);
3515    }
3516
3517    #[tokio::test]
3518    async fn test_repair_dangling_tool_calls_with_result() {
3519        use crate::events::EventContext;
3520        use crate::typed_id::SessionId;
3521        let tool_call = ToolCall {
3522            id: "call_123".to_string(),
3523            name: "get_weather".to_string(),
3524            arguments: serde_json::json!({"city": "NYC"}),
3525        };
3526
3527        let messages = vec![
3528            Message::user("What's the weather?"),
3529            Message::assistant_with_tools("Let me check", vec![tool_call]),
3530            Message::tool_result("call_123", Some(serde_json::json!({"temp": 72})), None),
3531        ];
3532
3533        let emitter = crate::traits::NoopEventEmitter;
3534        let session_id = SessionId::new();
3535        let ctx = EventContext::empty();
3536        let patched =
3537            repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3538                .await;
3539        assert_eq!(patched.len(), 3);
3540    }
3541
3542    #[tokio::test]
3543    async fn test_repair_dangling_tool_calls_missing_result_no_store() {
3544        use crate::events::EventContext;
3545        use crate::typed_id::SessionId;
3546        let tool_call = ToolCall {
3547            id: "call_456".to_string(),
3548            name: "search_web".to_string(),
3549            arguments: serde_json::json!({"query": "rust"}),
3550        };
3551
3552        let messages = vec![
3553            Message::user("Search for rust"),
3554            Message::assistant_with_tools("Searching...", vec![tool_call]),
3555            Message::user("Actually, never mind"),
3556        ];
3557
3558        let emitter = crate::traits::NoopEventEmitter;
3559        let session_id = SessionId::new();
3560        let ctx = EventContext::empty();
3561        let patched =
3562            repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3563                .await;
3564        // Should have added a cancelled result
3565        assert_eq!(patched.len(), 4);
3566        assert_eq!(patched[2].role, MessageRole::ToolResult);
3567        assert_eq!(patched[2].tool_call_id(), Some("call_456"));
3568    }
3569
3570    #[tokio::test]
3571    async fn test_repair_dangling_tool_calls_settled_result_replayed() {
3572        use crate::events::EventContext;
3573        use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3574        use crate::typed_id::SessionId;
3575
3576        struct MockSettledStore;
3577        #[async_trait::async_trait]
3578        impl DurableToolResultStore for MockSettledStore {
3579            async fn try_claim_tool_call(
3580                &self,
3581                _: &str,
3582                _: &str,
3583                _: &str,
3584                _: &str,
3585            ) -> crate::error::Result<ToolCallClaimResult> {
3586                Ok(ToolCallClaimResult::Claimed {
3587                    claim_token: uuid::Uuid::new_v4(),
3588                })
3589            }
3590            async fn settle_tool_call(
3591                &self,
3592                _: &str,
3593                _: &str,
3594                _: serde_json::Value,
3595                _: &str,
3596                _: uuid::Uuid,
3597            ) -> crate::error::Result<bool> {
3598                Ok(true)
3599            }
3600            async fn get_tool_call_status(
3601                &self,
3602                _turn_id: &str,
3603                _tool_call_id: &str,
3604            ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3605                Ok(Some(DurableToolCallStatus::Settled {
3606                    result_json: serde_json::json!({
3607                        "tool_call_id": "call_789",
3608                        "result": {"answer": 42},
3609                        "error": null,
3610                        "images": null,
3611                        "connection_required": null,
3612                        "raw_output": null
3613                    }),
3614                }))
3615            }
3616        }
3617
3618        let tool_call = ToolCall {
3619            id: "call_789".to_string(),
3620            name: "compute".to_string(),
3621            arguments: serde_json::json!({"x": 21}),
3622        };
3623        let messages = vec![
3624            Message::user("Compute"),
3625            Message::assistant_with_tools("Computing...", vec![tool_call]),
3626        ];
3627
3628        let store = MockSettledStore;
3629        let emitter = crate::traits::NoopEventEmitter;
3630        let session_id = SessionId::new();
3631        let ctx = EventContext::empty();
3632        let patched = repair_dangling_tool_calls(
3633            &messages,
3634            Some(&store as &dyn DurableToolResultStore),
3635            &emitter,
3636            session_id,
3637            &ctx,
3638            "turn_01",
3639        )
3640        .await;
3641
3642        // Settled result should be replayed (not cancelled message)
3643        assert_eq!(patched.len(), 3);
3644        assert_eq!(patched[2].role, MessageRole::ToolResult);
3645        assert_eq!(patched[2].tool_call_id(), Some("call_789"));
3646    }
3647
3648    #[tokio::test]
3649    async fn test_repair_dangling_tool_calls_interrupted_result_replayed() {
3650        use crate::events::EventContext;
3651        use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3652        use crate::typed_id::SessionId;
3653
3654        struct MockInterruptedStore;
3655        #[async_trait::async_trait]
3656        impl DurableToolResultStore for MockInterruptedStore {
3657            async fn try_claim_tool_call(
3658                &self,
3659                _: &str,
3660                _: &str,
3661                _: &str,
3662                _: &str,
3663            ) -> crate::error::Result<ToolCallClaimResult> {
3664                Ok(ToolCallClaimResult::Claimed {
3665                    claim_token: uuid::Uuid::new_v4(),
3666                })
3667            }
3668            async fn settle_tool_call(
3669                &self,
3670                _: &str,
3671                _: &str,
3672                _: serde_json::Value,
3673                _: &str,
3674                _: uuid::Uuid,
3675            ) -> crate::error::Result<bool> {
3676                Ok(true)
3677            }
3678            async fn get_tool_call_status(
3679                &self,
3680                _turn_id: &str,
3681                _tool_call_id: &str,
3682            ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3683                Ok(Some(DurableToolCallStatus::Interrupted {
3684                    result_json: None,
3685                }))
3686            }
3687        }
3688
3689        let tool_call = ToolCall {
3690            id: "call_int".to_string(),
3691            name: "slow_op".to_string(),
3692            arguments: serde_json::json!({}),
3693        };
3694        let messages = vec![
3695            Message::user("Do it"),
3696            Message::assistant_with_tools("Doing...", vec![tool_call]),
3697        ];
3698
3699        let store = MockInterruptedStore;
3700        let emitter = crate::traits::NoopEventEmitter;
3701        let session_id = SessionId::new();
3702        let ctx = EventContext::empty();
3703        let patched = repair_dangling_tool_calls(
3704            &messages,
3705            Some(&store as &dyn DurableToolResultStore),
3706            &emitter,
3707            session_id,
3708            &ctx,
3709            "turn_01",
3710        )
3711        .await;
3712
3713        assert_eq!(patched.len(), 3);
3714        let repair = &patched[2];
3715        assert_eq!(repair.role, MessageRole::ToolResult);
3716        assert_eq!(repair.tool_call_id(), Some("call_int"));
3717        // Interrupted replay uses the stored error or fallback text; must contain "interrupted"
3718        let content = format!("{:?}", repair);
3719        assert!(
3720            content.contains("interrupted") || content.contains("not complete"),
3721            "expected interrupted message, got: {content}"
3722        );
3723    }
3724
3725    #[tokio::test]
3726    async fn test_repair_dangling_tool_calls_running_synthesized() {
3727        use crate::events::EventContext;
3728        use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3729        use crate::typed_id::SessionId;
3730
3731        struct MockRunningStore;
3732        #[async_trait::async_trait]
3733        impl DurableToolResultStore for MockRunningStore {
3734            async fn try_claim_tool_call(
3735                &self,
3736                _: &str,
3737                _: &str,
3738                _: &str,
3739                _: &str,
3740            ) -> crate::error::Result<ToolCallClaimResult> {
3741                Ok(ToolCallClaimResult::Claimed {
3742                    claim_token: uuid::Uuid::new_v4(),
3743                })
3744            }
3745            async fn settle_tool_call(
3746                &self,
3747                _: &str,
3748                _: &str,
3749                _: serde_json::Value,
3750                _: &str,
3751                _: uuid::Uuid,
3752            ) -> crate::error::Result<bool> {
3753                Ok(true)
3754            }
3755            async fn get_tool_call_status(
3756                &self,
3757                _turn_id: &str,
3758                _tool_call_id: &str,
3759            ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3760                Ok(Some(DurableToolCallStatus::Running))
3761            }
3762        }
3763
3764        let tool_call = ToolCall {
3765            id: "call_run".to_string(),
3766            name: "long_job".to_string(),
3767            arguments: serde_json::json!({}),
3768        };
3769        let messages = vec![
3770            Message::user("Start job"),
3771            Message::assistant_with_tools("Starting...", vec![tool_call]),
3772        ];
3773
3774        let store = MockRunningStore;
3775        let emitter = crate::traits::NoopEventEmitter;
3776        let session_id = SessionId::new();
3777        let ctx = EventContext::empty();
3778        let patched = repair_dangling_tool_calls(
3779            &messages,
3780            Some(&store as &dyn DurableToolResultStore),
3781            &emitter,
3782            session_id,
3783            &ctx,
3784            "turn_01",
3785        )
3786        .await;
3787
3788        assert_eq!(patched.len(), 3);
3789        let repair = &patched[2];
3790        assert_eq!(repair.role, MessageRole::ToolResult);
3791        assert_eq!(repair.tool_call_id(), Some("call_run"));
3792        // Running stale claim must warn "uncertain; do not retry automatically"
3793        let content = format!("{:?}", repair);
3794        assert!(
3795            content.contains("uncertain") || content.contains("do not retry"),
3796            "expected uncertain/do-not-retry message, got: {content}"
3797        );
3798    }
3799
3800    #[tokio::test]
3801    async fn test_repair_dangling_tool_calls_store_error_unknown() {
3802        use crate::error::AgentLoopError;
3803        use crate::events::EventContext;
3804        use crate::traits::{DurableToolResultStore, ToolCallClaimResult};
3805        use crate::typed_id::SessionId;
3806
3807        struct MockErrorStore;
3808        #[async_trait::async_trait]
3809        impl DurableToolResultStore for MockErrorStore {
3810            async fn try_claim_tool_call(
3811                &self,
3812                _: &str,
3813                _: &str,
3814                _: &str,
3815                _: &str,
3816            ) -> crate::error::Result<ToolCallClaimResult> {
3817                Ok(ToolCallClaimResult::Claimed {
3818                    claim_token: uuid::Uuid::new_v4(),
3819                })
3820            }
3821            async fn settle_tool_call(
3822                &self,
3823                _: &str,
3824                _: &str,
3825                _: serde_json::Value,
3826                _: &str,
3827                _: uuid::Uuid,
3828            ) -> crate::error::Result<bool> {
3829                Ok(true)
3830            }
3831            async fn get_tool_call_status(
3832                &self,
3833                _turn_id: &str,
3834                _tool_call_id: &str,
3835            ) -> crate::error::Result<Option<crate::traits::DurableToolCallStatus>> {
3836                Err(AgentLoopError::tool("simulated store failure"))
3837            }
3838        }
3839
3840        let tool_call = ToolCall {
3841            id: "call_err".to_string(),
3842            name: "risky_op".to_string(),
3843            arguments: serde_json::json!({}),
3844        };
3845        let messages = vec![
3846            Message::user("Do risky op"),
3847            Message::assistant_with_tools("On it...", vec![tool_call]),
3848        ];
3849
3850        let store = MockErrorStore;
3851        let emitter = crate::traits::NoopEventEmitter;
3852        let session_id = SessionId::new();
3853        let ctx = EventContext::empty();
3854        let patched = repair_dangling_tool_calls(
3855            &messages,
3856            Some(&store as &dyn DurableToolResultStore),
3857            &emitter,
3858            session_id,
3859            &ctx,
3860            "turn_01",
3861        )
3862        .await;
3863
3864        assert_eq!(patched.len(), 3);
3865        let repair = &patched[2];
3866        assert_eq!(repair.role, MessageRole::ToolResult);
3867        assert_eq!(repair.tool_call_id(), Some("call_err"));
3868        // Store error must NOT say "safe to retry"
3869        let content = format!("{:?}", repair);
3870        assert!(
3871            !content.contains("safe to retry"),
3872            "store error must not say 'safe to retry', got: {content}"
3873        );
3874        assert!(
3875            content.contains("do not retry") || content.contains("status unknown"),
3876            "expected do-not-retry/status-unknown message, got: {content}"
3877        );
3878    }
3879
3880    #[test]
3881    fn test_build_request_options_for_openai_prompt_cache() {
3882        let config = LlmCallConfig {
3883            speed: None,
3884            verbosity: None,
3885            model: "gpt-5.4".to_string(),
3886            temperature: None,
3887            max_tokens: None,
3888            tools: vec![],
3889            reasoning_effort: None,
3890            metadata: HashMap::new(),
3891            previous_response_id: Some("resp_123".to_string()),
3892            tool_search: None,
3893            prompt_cache: Some(PromptCacheConfig {
3894                enabled: true,
3895                strategy: PromptCacheStrategy::Auto,
3896                gemini_cached_content: None,
3897            }),
3898            openrouter_routing: None,
3899            parallel_tool_calls: None,
3900            volatile_suffix_len: 0,
3901        };
3902
3903        let request_options = build_request_options(&config, "openai").unwrap();
3904        assert_eq!(
3905            request_options
3906                .prompt_cache
3907                .and_then(|info| info.provider_mode),
3908            Some("prompt_cache_key".to_string())
3909        );
3910        assert_eq!(
3911            request_options.provider_options.get("openai"),
3912            Some(&json!({ "previous_response_id": true }))
3913        );
3914    }
3915
3916    #[test]
3917    fn test_build_request_options_for_gemini_explicit_cache() {
3918        let config = LlmCallConfig {
3919            speed: None,
3920            verbosity: None,
3921            model: "gemini-2.5-pro".to_string(),
3922            temperature: None,
3923            max_tokens: None,
3924            tools: vec![],
3925            reasoning_effort: None,
3926            metadata: HashMap::new(),
3927            previous_response_id: None,
3928            tool_search: None,
3929            prompt_cache: Some(PromptCacheConfig {
3930                enabled: true,
3931                strategy: PromptCacheStrategy::Auto,
3932                gemini_cached_content: Some("cachedContents/demo-cache".to_string()),
3933            }),
3934            openrouter_routing: None,
3935            parallel_tool_calls: None,
3936            volatile_suffix_len: 0,
3937        };
3938
3939        let request_options = build_request_options(&config, "gemini").unwrap();
3940        assert_eq!(
3941            request_options
3942                .prompt_cache
3943                .and_then(|info| info.provider_mode),
3944            Some("cached_content".to_string())
3945        );
3946        assert_eq!(
3947            request_options.provider_options.get("gemini"),
3948            Some(&json!({ "cached_content": true }))
3949        );
3950    }
3951
3952    #[test]
3953    fn test_build_request_options_omits_gemini_cache_flag_when_disabled() {
3954        let config = LlmCallConfig {
3955            speed: None,
3956            verbosity: None,
3957            model: "gemini-2.5-pro".to_string(),
3958            temperature: None,
3959            max_tokens: None,
3960            tools: vec![],
3961            reasoning_effort: None,
3962            metadata: HashMap::new(),
3963            previous_response_id: None,
3964            tool_search: None,
3965            prompt_cache: Some(PromptCacheConfig {
3966                enabled: false,
3967                strategy: PromptCacheStrategy::Auto,
3968                gemini_cached_content: Some("cachedContents/demo-cache".to_string()),
3969            }),
3970            openrouter_routing: None,
3971            parallel_tool_calls: None,
3972            volatile_suffix_len: 0,
3973        };
3974
3975        assert!(build_request_options(&config, "gemini").is_none());
3976    }
3977
3978    #[test]
3979    fn system_keys_override_embedder_keys_in_metadata() {
3980        // Pins the injection order: embedder keys inserted first, then system
3981        // keys overwrite any collision. A harness author cannot shadow session_id,
3982        // turn_id, etc. by including them in embedder_metadata.
3983        let embedder_metadata: HashMap<String, String> = [
3984            ("session_id".to_string(), "attacker_value".to_string()),
3985            ("custom_key".to_string(), "custom_value".to_string()),
3986        ]
3987        .into();
3988
3989        let mut metadata: HashMap<String, String> = HashMap::new();
3990
3991        // Inject embedder metadata first (mirrors execute_single_turn logic)
3992        for (k, v) in &embedder_metadata {
3993            metadata.insert(k.clone(), v.clone());
3994        }
3995
3996        // System key injected second — must overwrite
3997        metadata.insert("session_id".to_string(), "real_session_id".to_string());
3998
3999        assert_eq!(
4000            metadata.get("session_id").map(String::as_str),
4001            Some("real_session_id"),
4002            "system key must overwrite embedder key with same name"
4003        );
4004        assert_eq!(
4005            metadata.get("custom_key").map(String::as_str),
4006            Some("custom_value"),
4007            "non-colliding embedder key must be preserved"
4008        );
4009    }
4010
4011    // =========================================================================
4012    // ContinuePartial recovery tests (EVE-532)
4013    // =========================================================================
4014
4015    use crate::traits::{NoopPartialStreamStore, PartialStreamState, PartialStreamStore};
4016
4017    struct MockPartialStore(Option<PartialStreamState>);
4018
4019    #[async_trait::async_trait]
4020    impl PartialStreamStore for MockPartialStore {
4021        async fn get_partial_stream(
4022            &self,
4023            _session_id: crate::typed_id::SessionId,
4024            _turn_id: &str,
4025        ) -> crate::error::Result<Option<PartialStreamState>> {
4026            Ok(self.0.clone())
4027        }
4028    }
4029
4030    #[tokio::test]
4031    async fn test_noop_partial_stream_store_returns_none() {
4032        let store = NoopPartialStreamStore;
4033        let result = store
4034            .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4035            .await
4036            .unwrap();
4037        assert!(result.is_none());
4038    }
4039
4040    #[tokio::test]
4041    async fn test_partial_stream_store_returns_accumulated_when_partial_exists() {
4042        let message_id = MessageId::new();
4043        let store = MockPartialStore(Some(PartialStreamState {
4044            message_id,
4045            accumulated: "partial text so far".to_string(),
4046        }));
4047        let result = store
4048            .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4049            .await
4050            .unwrap();
4051        let partial = result.unwrap();
4052        assert_eq!(partial.message_id, message_id);
4053        assert_eq!(partial.accumulated, "partial text so far");
4054    }
4055
4056    #[tokio::test]
4057    async fn test_partial_stream_store_returns_empty_when_started_no_delta() {
4058        let store = MockPartialStore(Some(PartialStreamState {
4059            message_id: MessageId::new(),
4060            accumulated: String::new(),
4061        }));
4062        let result = store
4063            .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4064            .await
4065            .unwrap();
4066        assert!(result.unwrap().accumulated.is_empty());
4067    }
4068
4069    // ====================================================================
4070    // Tool-call repair capability integration (EVE-600)
4071    // ====================================================================
4072
4073    mod tool_call_repair_integration {
4074        use super::*;
4075        use crate::capabilities::{CapabilityRegistry, TOOL_CALL_REPAIR_CAPABILITY_ID};
4076        use crate::events::{EventData, EventRequest};
4077        use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
4078        use crate::traits::EventEmitter;
4079        use crate::typed_id::{MessageId, SessionId, TurnId};
4080        use std::sync::Mutex;
4081
4082        /// EventEmitter that records every emitted request for assertions.
4083        #[derive(Default)]
4084        struct RecordingEmitter {
4085            events: Mutex<Vec<EventRequest>>,
4086        }
4087
4088        #[async_trait::async_trait]
4089        impl EventEmitter for RecordingEmitter {
4090            async fn emit(
4091                &self,
4092                request: EventRequest,
4093            ) -> crate::error::Result<crate::events::Event> {
4094                let event = request
4095                    .clone()
4096                    .into_event(crate::typed_id::EventId::new(), 0);
4097                self.events.lock().unwrap().push(request);
4098                Ok(event)
4099            }
4100        }
4101
4102        impl RecordingEmitter {
4103            fn repaired_events(&self) -> Vec<(String, String)> {
4104                self.events
4105                    .lock()
4106                    .unwrap()
4107                    .iter()
4108                    .filter_map(|r| match &r.data {
4109                        EventData::ToolCallRepaired(d) => {
4110                            Some((d.tool_call_id.clone(), d.outcome.clone()))
4111                        }
4112                        _ => None,
4113                    })
4114                    .collect()
4115            }
4116        }
4117
4118        fn ctx() -> AtomContext {
4119            AtomContext::new(SessionId::new(), TurnId::new(), MessageId::new())
4120        }
4121
4122        fn read_file_tool() -> ToolDefinition {
4123            ToolDefinition::Builtin(BuiltinTool {
4124                name: "read_file".to_string(),
4125                display_name: None,
4126                description: "read".to_string(),
4127                parameters: json!({
4128                    "type": "object",
4129                    "properties": { "path": { "type": "string" } },
4130                    "required": ["path"]
4131                }),
4132                policy: ToolPolicy::Auto,
4133                category: None,
4134                deferrable: Default::default(),
4135                hints: Default::default(),
4136                full_parameters: None,
4137            })
4138        }
4139
4140        fn malformed_call() -> ToolCall {
4141            // Driver passed the raw arg string through as a JSON string (could
4142            // not parse it because of prose + single quotes).
4143            ToolCall {
4144                id: "call_1".to_string(),
4145                name: "read_file".to_string(),
4146                arguments: json!("here you go: {'path': '/foo'}"),
4147            }
4148        }
4149
4150        fn enabled_configs() -> Vec<crate::AgentCapabilityConfig> {
4151            vec![crate::AgentCapabilityConfig::new(
4152                TOOL_CALL_REPAIR_CAPABILITY_ID,
4153            )]
4154        }
4155
4156        async fn run(
4157            configs: &[crate::AgentCapabilityConfig],
4158            tools: &[ToolDefinition],
4159            calls: &mut [ToolCall],
4160            emitter: &RecordingEmitter,
4161        ) {
4162            run_at(configs, tools, calls, emitter, 1).await;
4163        }
4164
4165        async fn run_at(
4166            configs: &[crate::AgentCapabilityConfig],
4167            tools: &[ToolDefinition],
4168            calls: &mut [ToolCall],
4169            emitter: &RecordingEmitter,
4170            iteration: u32,
4171        ) {
4172            let registry = CapabilityRegistry::with_builtins();
4173            apply_tool_call_repair(
4174                &registry,
4175                emitter,
4176                SessionId::new(),
4177                &ctx(),
4178                configs,
4179                tools,
4180                calls,
4181                iteration,
4182            )
4183            .await;
4184        }
4185
4186        #[tokio::test]
4187        async fn malformed_call_is_repaired_and_turn_proceeds() {
4188            let emitter = RecordingEmitter::default();
4189            let tools = vec![read_file_tool()];
4190            let mut calls = vec![malformed_call()];
4191
4192            run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4193
4194            // Arguments are now a clean object: the act phase can proceed.
4195            assert_eq!(calls[0].arguments, json!({ "path": "/foo" }));
4196            assert_eq!(
4197                emitter.repaired_events(),
4198                vec![("call_1".to_string(), "local-salvage".to_string())]
4199            );
4200        }
4201
4202        #[tokio::test]
4203        async fn observability_event_fires_with_outcome_label() {
4204            let emitter = RecordingEmitter::default();
4205            let tools = vec![read_file_tool()];
4206            let mut calls = vec![malformed_call()];
4207
4208            run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4209
4210            let repaired = emitter.repaired_events();
4211            assert_eq!(repaired.len(), 1, "exactly one repair event");
4212            assert_eq!(repaired[0].1, "local-salvage");
4213        }
4214
4215        #[tokio::test]
4216        async fn attempt_cap_honored_and_falls_through_to_error_path() {
4217            // Un-salvageable garbage with max_reprompts=0 → gave-up, args unchanged.
4218            let emitter = RecordingEmitter::default();
4219            let tools = vec![read_file_tool()];
4220            let original = json!("totally not json and no braces at all");
4221            let mut calls = vec![ToolCall {
4222                id: "call_2".to_string(),
4223                name: "read_file".to_string(),
4224                arguments: original.clone(),
4225            }];
4226            let configs = vec![crate::AgentCapabilityConfig::with_config(
4227                TOOL_CALL_REPAIR_CAPABILITY_ID,
4228                json!({ "max_reprompts": 0 }),
4229            )];
4230
4231            run(&configs, &tools, &mut calls, &emitter).await;
4232
4233            // Arguments untouched: today's error path still applies downstream.
4234            assert_eq!(calls[0].arguments, original);
4235            assert_eq!(
4236                emitter.repaired_events(),
4237                vec![("call_2".to_string(), "gave-up".to_string())]
4238            );
4239        }
4240
4241        #[tokio::test]
4242        async fn unsalvageable_with_remaining_attempts_labels_reprompt() {
4243            let emitter = RecordingEmitter::default();
4244            let tools = vec![read_file_tool()];
4245            let mut calls = vec![ToolCall {
4246                id: "call_3".to_string(),
4247                name: "read_file".to_string(),
4248                arguments: json!("no json here"),
4249            }];
4250            // Default max_reprompts (1) → first failure is labelled re-prompt.
4251            run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4252            assert_eq!(
4253                emitter.repaired_events(),
4254                vec![("call_3".to_string(), "re-prompt".to_string())]
4255            );
4256        }
4257
4258        #[tokio::test]
4259        async fn attempt_cap_transitions_to_gave_up_on_later_iteration() {
4260            // The bounded re-prompt is driven by the per-turn reason iteration
4261            // (prior_attempts = iteration - 1). With the default cap of 1, an
4262            // unsalvageable call is `re-prompt` on iteration 1 but `gave-up` once
4263            // the turn has already spent an iteration, so the cap actually engages.
4264            let tools = vec![read_file_tool()];
4265            let garbage = json!("no json here");
4266
4267            let first = RecordingEmitter::default();
4268            let mut calls1 = vec![ToolCall {
4269                id: "call_a".to_string(),
4270                name: "read_file".to_string(),
4271                arguments: garbage.clone(),
4272            }];
4273            run_at(&enabled_configs(), &tools, &mut calls1, &first, 1).await;
4274            assert_eq!(
4275                first.repaired_events(),
4276                vec![("call_a".to_string(), "re-prompt".to_string())]
4277            );
4278
4279            let later = RecordingEmitter::default();
4280            let mut calls2 = vec![ToolCall {
4281                id: "call_a".to_string(),
4282                name: "read_file".to_string(),
4283                arguments: garbage,
4284            }];
4285            run_at(&enabled_configs(), &tools, &mut calls2, &later, 2).await;
4286            assert_eq!(
4287                later.repaired_events(),
4288                vec![("call_a".to_string(), "gave-up".to_string())]
4289            );
4290        }
4291
4292        #[tokio::test]
4293        async fn disabled_capability_produces_no_drift() {
4294            // No tool_call_repair in the resolved set: arguments are left exactly
4295            // as the driver produced them and no event is emitted.
4296            let emitter = RecordingEmitter::default();
4297            let tools = vec![read_file_tool()];
4298            let original = malformed_call().arguments;
4299            let mut calls = vec![malformed_call()];
4300
4301            run(&[], &tools, &mut calls, &emitter).await;
4302
4303            assert_eq!(calls[0].arguments, original);
4304            assert!(emitter.repaired_events().is_empty());
4305        }
4306
4307        #[tokio::test]
4308        async fn well_formed_call_is_untouched_and_silent() {
4309            let emitter = RecordingEmitter::default();
4310            let tools = vec![read_file_tool()];
4311            let mut calls = vec![ToolCall {
4312                id: "call_4".to_string(),
4313                name: "read_file".to_string(),
4314                arguments: json!({ "path": "/already/good" }),
4315            }];
4316
4317            run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4318
4319            assert_eq!(calls[0].arguments, json!({ "path": "/already/good" }));
4320            assert!(emitter.repaired_events().is_empty());
4321        }
4322    }
4323}