1use futures::StreamExt;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Instant;
24use uuid::Uuid;
25
26fn add_compaction_cost(usage: &mut TokenUsage, compaction_cost: f64) {
27 let generation_cost = usage.effective_cost_usd();
28 usage.effective_cost_usd = Some(generation_cost.unwrap_or(0.0) + compaction_cost);
29 if let Some(actual_cost) = usage.actual_cost_usd.as_mut() {
30 *actual_cost += compaction_cost;
31 }
32}
33
34use super::ExecutionContext;
35use crate::annotation_hook::{collect_annotations, verify_annotations};
36use crate::capabilities::CapabilityRegistry;
37use crate::driver_registry::{LlmMessage, LlmMessageContent, LlmMessageRole, LlmStreamEvent};
38use crate::error::{AgentLoopError, Result};
39use crate::events::{
40 CapabilityUsageData, EventContext, EventRequest, LlmCompactionInfo, LlmGenerationData,
41 LlmRetryInfo, OutputMessageCompletedData, OutputMessageDeltaData, OutputMessageReplacedData,
42 OutputMessageStartedData, ReasonCompletedData, ReasonItemData, ReasonRecoveredData,
43 ReasonStartedData, ReasonThinkingCompletedData, ReasonThinkingDeltaData,
44 ReasonThinkingStartedData, RecoveryMode, TokenUsage, ToolDefinitionSummary,
45};
46use crate::llm_retry::{
47 LlmRetryConfig, RetryMetadata, is_transient_error_message, remaining_retry_time,
48 reserve_retry_wait,
49};
50use crate::message::{ContentPart, Message, MessageRole};
51use crate::message_retriever::MessageRetriever;
52use crate::output_guardrail::{
53 ArmedGuardrail, OutputGuardrailContext, PostGenerationOutputContext, evaluate_guardrails,
54 evaluate_post_generation_guardrails, post_generation_guardrail_text,
55};
56use crate::phase_effects::{PhaseEffectEmitter, PhaseEffectSink};
57use crate::runtime_context::{AssembledTurnContext, TurnContextRequest, TurnContextResolver};
58use crate::tool_types::{ToolCall, ToolDefinition};
59use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId};
60use crate::{ErrorDisclosure, UserFacingError, UserFacingErrorContext};
61use crate::{
62 durability::DurableToolResultStore, durability::PartialStreamState,
63 durability::PartialStreamStore, event_emitter::EventEmitter, image_services::ImageResolver,
64 image_services::ResolvedImage,
65};
66use everruns_provider::reasoning::{ReasoningContentPart, ReasoningText};
67
68mod compaction;
69mod error_policy;
70mod observability;
71mod output_hooks;
72mod reasoning_updates;
73mod request_controls;
74mod stream_state;
75mod transcript;
76
77use compaction::{
78 ProactiveCompactionContext, ReactiveCompactionContext, apply_proactive_compaction,
79 apply_reactive_compaction,
80};
81use error_policy::{
82 error_disclosure_override, filter_response_text, is_error_placeholder_message,
83 resolve_error_disclosure,
84};
85use observability::{build_request_options, capability_usage_snapshot_records};
86use output_hooks::collect_output_hooks;
87use request_controls::resolve_request_controls;
88use stream_state::{
89 StreamReplayState, StreamTermination, advances_stall_deadline, append_guarded_thinking_delta,
90 inspect_guarded_reasoning_item, merge_retry_metadata,
91};
92use transcript::repair_dangling_tool_calls;
93
94fn client_visible_guardrail_text(
99 text: &str,
100 streamed_reasoning: &str,
101 reasoning: &[ReasoningContentPart],
102 citation_annotations: &[crate::message::TextAnnotation],
103) -> String {
104 let mut guarded = streamed_reasoning.to_string();
105 if guarded.is_empty() {
106 for item_text in reasoning
107 .iter()
108 .filter_map(ReasoningContentPart::display_text)
109 {
110 if !guarded.is_empty() {
111 guarded.push_str("\n\n");
112 }
113 guarded.push_str(&item_text);
114 }
115 }
116
117 let prose = post_generation_guardrail_text(text, citation_annotations);
118 if !guarded.is_empty() && !prose.is_empty() {
119 guarded.push_str("\n\n");
120 }
121 guarded.push_str(&prose);
122 guarded
123}
124
125#[allow(clippy::too_many_arguments)]
128async fn apply_finalized_tool_calls_hooks(
129 capability_registry: &CapabilityRegistry,
130 event_emitter: &dyn EventEmitter,
131 session_id: SessionId,
132 context: &ExecutionContext,
133 resolved_capability_configs: &[crate::CapabilityRef],
134 tool_definitions: &[ToolDefinition],
135 tool_calls: &mut [ToolCall],
136 iteration: u32,
137) {
138 let hook_context = crate::finalized_tool_calls::FinalizedToolCallsContext {
139 event_emitter,
140 session_id,
141 execution_context: context,
142 tool_definitions,
143 iteration,
144 };
145 for config in resolved_capability_configs {
146 let Some(capability) = capability_registry.get(config.capability_id()) else {
147 continue;
148 };
149 if let Some(hook) = capability.finalized_tool_calls_hook(config.config_value()) {
150 hook.apply(&hook_context, tool_calls).await;
151 }
152 }
153}
154
155fn unix_now_secs() -> u64 {
156 std::time::SystemTime::now()
157 .duration_since(std::time::UNIX_EPOCH)
158 .unwrap_or_default()
159 .as_secs()
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ReasonInput {
165 pub context: ExecutionContext,
167 pub harness_id: HarnessId,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub agent_id: Option<AgentId>,
172 #[serde(default)]
174 pub org_id: i64,
175 #[serde(default)]
179 pub mcp_tool_definitions: Vec<ToolDefinition>,
180 #[serde(skip_serializing_if = "Option::is_none")]
183 pub previous_response_id: Option<String>,
184 #[serde(default = "default_iteration")]
187 pub iteration: u32,
188}
189
190fn default_iteration() -> u32 {
191 1
192}
193
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct ReasonResult {
197 pub success: bool,
199 pub text: String,
201 #[serde(default)]
203 pub tool_calls: Vec<ToolCall>,
204 pub has_tool_calls: bool,
206 #[serde(default)]
208 pub tool_definitions: Vec<ToolDefinition>,
209 #[serde(default = "default_max_iterations")]
211 pub max_iterations: usize,
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub error: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub user_facing_error: Option<UserFacingError>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub error_disclosure: Option<ErrorDisclosure>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub usage: Option<TokenUsage>,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 pub output_message_id: Option<MessageId>,
229 #[serde(skip_serializing_if = "Option::is_none")]
231 pub time_to_first_token_ms: Option<u64>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub response_id: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub finish_reason: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub locale: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub network_access: Option<crate::network_access::NetworkAccessList>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub parallel_tool_calls: Option<bool>,
249}
250
251fn default_max_iterations() -> usize {
252 500
253}
254
255pub struct ReasonAtom {
274 context_resolver: Arc<dyn TurnContextResolver>,
275 message_retriever: Arc<dyn MessageRetriever>,
276 capability_registry: CapabilityRegistry,
277 event_emitter: PhaseEffectEmitter<dyn PhaseEffectSink>,
278 image_resolver: Option<Arc<dyn ImageResolver>>,
280 stream_heartbeater: Option<Arc<dyn crate::durability::StreamHeartbeater>>,
282 provider_stall_timeout: Option<std::time::Duration>,
284 provider_retry_config: LlmRetryConfig,
286 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
288 partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
290 reasoning_effort_handle: Option<crate::tool_context::ReasoningEffortHandle>,
294 utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
298 schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
304 compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
306}
307
308impl ReasonAtom {
309 pub fn new(
311 context_resolver: impl TurnContextResolver + 'static,
312 message_retriever: impl MessageRetriever + 'static,
313 capability_registry: CapabilityRegistry,
314 event_emitter: impl PhaseEffectSink + 'static,
315 ) -> Self {
316 Self {
317 context_resolver: Arc::new(context_resolver),
318 message_retriever: Arc::new(message_retriever),
319 capability_registry,
320 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
321 image_resolver: None,
322 stream_heartbeater: None,
323 provider_stall_timeout: None,
324 provider_retry_config: LlmRetryConfig::default(),
325 durable_tool_result_store: None,
326 partial_stream_store: None,
327 reasoning_effort_handle: None,
328 utility_llm_service: None,
329 schedule_store: None,
330 compaction_checkpoint_store: None,
331 }
332 }
333
334 pub fn with_schedule_store(
337 mut self,
338 store: Arc<dyn crate::session_services::SessionScheduleStore>,
339 ) -> Self {
340 self.schedule_store = Some(store);
341 self
342 }
343
344 pub fn with_compaction_checkpoint_store(
345 mut self,
346 store: Arc<dyn crate::CompactionCheckpointStore>,
347 ) -> Self {
348 self.compaction_checkpoint_store = Some(store);
349 self
350 }
351
352 fn collect_llm_error_hooks(
358 &self,
359 resolved_capability_configs: &[crate::CapabilityRef],
360 ) -> Vec<(
361 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
362 serde_json::Value,
363 )> {
364 resolved_capability_configs
365 .iter()
366 .filter_map(|cfg| {
367 let cap = self.capability_registry.get(cfg.capability_id())?;
368 let hook = cap.llm_error_hook()?;
369 Some((hook, cfg.config_value().clone()))
370 })
371 .collect()
372 }
373
374 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
387 self.image_resolver = Some(resolver);
388 self
389 }
390
391 pub fn with_stream_heartbeater(
393 mut self,
394 heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
395 ) -> Self {
396 self.stream_heartbeater = Some(heartbeater);
397 self
398 }
399
400 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
403 self.provider_stall_timeout = Some(timeout);
404 self
405 }
406
407 pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
409 self.provider_retry_config = config;
410 self
411 }
412
413 pub fn with_durable_tool_result_store(
419 mut self,
420 store: Arc<dyn DurableToolResultStore>,
421 ) -> Self {
422 self.durable_tool_result_store = Some(store);
423 self
424 }
425
426 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
428 self.partial_stream_store = Some(store);
429 self
430 }
431
432 pub fn with_reasoning_effort_handle(
439 mut self,
440 handle: crate::tool_context::ReasoningEffortHandle,
441 ) -> Self {
442 self.reasoning_effort_handle = Some(handle);
443 self
444 }
445
446 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
449 self.utility_llm_service = Some(service);
450 self
451 }
452}
453
454impl ReasonAtom {
455 pub fn name(&self) -> &'static str {
457 "reason"
458 }
459
460 pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
462 self.execute_inner(input, None).await
463 }
464}
465
466impl ReasonAtom {
467 pub async fn execute_with_assembled_context(
472 &self,
473 input: ReasonInput,
474 assembled: AssembledTurnContext,
475 ) -> Result<ReasonResult> {
476 self.execute_inner(input, Some(assembled)).await
477 }
478
479 async fn emit_capability_usage_snapshot(
480 &self,
481 session_id: SessionId,
482 context: &ExecutionContext,
483 resolved_capability_configs: &[crate::CapabilityRef],
484 tool_definitions: &[ToolDefinition],
485 ) {
486 let records = capability_usage_snapshot_records(
487 &self.capability_registry,
488 resolved_capability_configs,
489 tool_definitions,
490 );
491 if records.is_empty() {
492 return;
493 }
494
495 if let Err(error) = self
496 .event_emitter
497 .emit(EventRequest::new(
498 session_id,
499 EventContext::from_execution_context(context),
500 CapabilityUsageData { records },
501 ))
502 .await
503 {
504 tracing::warn!(
505 session_id = %session_id,
506 error = %error,
507 "ReasonAtom: failed to emit capability.usage event"
508 );
509 }
510 }
511
512 async fn apply_finalized_tool_call_hooks(
515 &self,
516 session_id: SessionId,
517 context: &ExecutionContext,
518 resolved_capability_configs: &[crate::CapabilityRef],
519 tool_definitions: &[ToolDefinition],
520 tool_calls: &mut [ToolCall],
521 iteration: u32,
522 ) {
523 apply_finalized_tool_calls_hooks(
524 &self.capability_registry,
525 self.event_emitter.as_ref(),
526 session_id,
527 context,
528 resolved_capability_configs,
529 tool_definitions,
530 tool_calls,
531 iteration,
532 )
533 .await;
534 }
535
536 async fn execute_inner(
537 &self,
538 input: ReasonInput,
539 assembled: Option<AssembledTurnContext>,
540 ) -> Result<ReasonResult> {
541 let ReasonInput {
542 context,
543 harness_id,
544 agent_id,
545 org_id,
546 mcp_tool_definitions,
547 previous_response_id,
548 iteration,
549 } = input;
550
551 tracing::info!(
552 session_id = %context.session_id,
553 turn_id = %context.turn_id,
554 exec_id = %context.exec_id,
555 harness_id = %harness_id,
556 agent_id = ?agent_id,
557 mcp_tools_count = %mcp_tool_definitions.len(),
558 "ReasonAtom: starting LLM call"
559 );
560
561 let trace_id = context.turn_id.to_string();
569 let reason_span_id = Uuid::now_v7().to_string();
570 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
574 trace_id.clone(),
575 reason_span_id.clone(),
576 Some(parent_span_id.clone()),
577 );
578
579 let reason_start = Instant::now();
581
582 if let Err(e) = self
584 .event_emitter
585 .emit(EventRequest::new(
586 context.session_id,
587 event_context.clone(),
588 ReasonStartedData {
589 harness_id,
590 agent_id,
591 metadata: None, },
593 ))
594 .await
595 {
596 tracing::warn!(
597 session_id = %context.session_id,
598 error = %e,
599 "ReasonAtom: failed to emit reason.started event"
600 );
601 }
602
603 let assembled = match assembled {
607 Some(assembled) => Ok(assembled),
608 None => {
609 self.context_resolver
610 .resolve_turn_context(TurnContextRequest {
611 session_id: context.session_id,
612 harness_id,
613 agent_id,
614 mcp_tool_definitions: mcp_tool_definitions.clone(),
615 })
616 .await
617 }
618 };
619
620 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
621 Ok(assembled) => {
622 let error_disclosure = resolve_error_disclosure(
623 &self.capability_registry,
624 &assembled.resolved_capability_configs,
625 error_disclosure_override(&assembled.messages).as_deref(),
626 );
627 let error_hooks =
631 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
632 let error_context = UserFacingErrorContext::default()
633 .with_provider(assembled.model.provider_type.to_string())
634 .with_model_id(assembled.model.model.clone());
635 let call_result = self
636 .execute_llm_call(
637 context.session_id,
638 harness_id,
639 agent_id,
640 org_id,
641 &context,
642 &trace_id,
643 &reason_span_id,
644 previous_response_id,
645 iteration,
646 assembled,
647 )
648 .await;
649 (error_disclosure, error_context, error_hooks, call_result)
650 }
651 Err(error) => (
652 ErrorDisclosure::default(),
653 UserFacingErrorContext::default(),
654 Vec::new(),
655 Err(error),
656 ),
657 };
658
659 let result = match call_result {
661 Ok(result) => {
662 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
664
665 let completed_context = EventContext::from_execution_context(&context).with_span(
667 trace_id.clone(),
668 reason_span_id.clone(), Some(parent_span_id.clone()),
670 );
671 if let Err(e) = self
672 .event_emitter
673 .emit(EventRequest::new(
674 context.session_id,
675 completed_context,
676 ReasonCompletedData::success(
677 &result.text,
678 result.has_tool_calls,
679 result.tool_calls.len() as u32,
680 Some(reason_duration_ms),
681 result.usage.clone(),
682 ),
683 ))
684 .await
685 {
686 tracing::warn!(
687 session_id = %context.session_id,
688 error = %e,
689 "ReasonAtom: failed to emit reason.completed event"
690 );
691 }
692 result
693 }
694 Err(e) => {
695 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
697
698 tracing::warn!(
701 session_id = %context.session_id,
702 turn_id = %context.turn_id,
703 error = %e,
704 "ReasonAtom: LLM call failed"
705 );
706
707 let error_msg = e.to_string();
708 let mut source_error = e.user_facing_error(error_context);
709
710 let is_transient = e.is_transient_llm_error()
717 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
718
719 if !is_transient && !error_hooks.is_empty() {
725 let services = crate::llm_error_hook::LlmErrorHookServices {
726 schedule_store: self.schedule_store.clone(),
727 };
728 for (hook, config) in &error_hooks {
729 let outcome = {
730 let ctx = crate::llm_error_hook::LlmErrorContext {
731 session_id: context.session_id,
732 error_code: &source_error.code,
733 error_fields: &source_error.fields,
734 config,
735 services: &services,
736 };
737 hook.on_llm_error(&ctx).await
738 };
739 for (key, value) in outcome.extra_error_fields {
740 source_error = source_error.with_field(key, value);
741 }
742 }
743 }
744
745 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
746 let user_error_text = user_error.fallback_message();
747
748 let mut output_message_id = None;
749
750 if !is_transient {
751 let mut error_message = Message::assistant(&user_error_text);
753 let mut metadata = std::collections::HashMap::new();
754 user_error.apply_to_message_metadata(&mut metadata);
755 UserFacingError::apply_disclosure_to_message_metadata(
756 &mut metadata,
757 error_disclosure,
758 &source_error.code,
759 );
760 error_message.metadata = Some(metadata);
761
762 output_message_id = Some(error_message.id);
763
764 let error_msg_context = EventContext::from_execution_context(&context)
767 .with_span(
768 trace_id.clone(),
769 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
772 if let Err(emit_err) = self
773 .event_emitter
774 .emit(EventRequest::new(
775 context.session_id,
776 error_msg_context,
777 OutputMessageCompletedData::new(error_message)
778 .with_user_facing_error(&user_error)
779 .with_error_disclosure(error_disclosure),
780 ))
781 .await
782 {
783 tracing::warn!(
784 session_id = %context.session_id,
785 error = %emit_err,
786 "ReasonAtom: failed to emit output.message.completed event for error"
787 );
788 }
789 } else {
790 tracing::info!(
791 session_id = %context.session_id,
792 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
793 );
794 }
795
796 let completed_context = EventContext::from_execution_context(&context).with_span(
798 trace_id.clone(),
799 reason_span_id.clone(), Some(parent_span_id.clone()),
801 );
802 if let Err(emit_err) = self
803 .event_emitter
804 .emit(EventRequest::new(
805 context.session_id,
806 completed_context,
807 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
808 ))
809 .await
810 {
811 tracing::warn!(
812 session_id = %context.session_id,
813 error = %emit_err,
814 "ReasonAtom: failed to emit reason.completed event"
815 );
816 }
817
818 ReasonResult {
819 success: false,
820 text: user_error_text,
821 tool_calls: vec![],
822 has_tool_calls: false,
823 tool_definitions: vec![],
824 max_iterations: default_max_iterations(),
825 error: Some(error_msg.clone()),
826 user_facing_error: Some(user_error),
827 error_disclosure: Some(error_disclosure),
828 usage: None,
829 output_message_id,
830 time_to_first_token_ms: None,
831 response_id: None,
832 finish_reason: error_msg
833 .to_ascii_lowercase()
834 .contains("model refused")
835 .then(|| "refusal".to_string()),
836 locale: None,
837 network_access: None,
838 parallel_tool_calls: None,
839 }
840 }
841 };
842
843 Ok(result)
844 }
845
846 #[allow(clippy::too_many_arguments)]
848 async fn execute_llm_call(
849 &self,
850 session_id: SessionId,
851 harness_id: HarnessId,
852 agent_id: Option<AgentId>,
853 org_id: i64,
854 context: &ExecutionContext,
855 trace_id: &str,
856 reason_span_id: &str,
857 previous_response_id: Option<String>,
858 iteration: u32,
859 assembled: AssembledTurnContext,
860 ) -> Result<ReasonResult> {
861 let prior_usage = assembled.cumulative_usage();
862 let mut messages = assembled.messages;
863 let mut message_source_sequence = assembled.message_source_sequence;
864 let model_with_provider = assembled.model;
865 let resolved_model_id = assembled.resolved_model_id;
866 let resolved_locale = assembled.resolved_locale;
867 let compaction_policy = assembled.compaction_policy;
868 let resolved_capability_configs = assembled.resolved_capability_configs;
869 let runtime_agent = assembled.runtime_agent;
870 let embedder_metadata = assembled.embedder_metadata;
871
872 self.emit_capability_usage_snapshot(
873 session_id,
874 context,
875 &resolved_capability_configs,
876 &runtime_agent.tools,
877 )
878 .await;
879
880 let output_hooks =
881 collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
882 let guardrail_providers = output_hooks.streaming;
883 let post_output_providers = output_hooks.post_generation;
884 let annotation_providers = output_hooks.annotations;
885 let citation_verifiers = output_hooks.citation_verifiers;
886
887 let chat_driver = Arc::clone(&model_with_provider.driver);
889 let stateful_response_continuation =
890 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
891 let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
892 let mut checkpoint_suffix_message_count = 0usize;
893 let native_reasoning_compaction = compaction_policy.as_ref().is_none_or(|policy| {
894 matches!(
895 policy.settings().strategy,
896 crate::compaction_policy::CompactionStrategy::Native
897 | crate::compaction_policy::CompactionStrategy::Auto
898 ) && chat_driver.supports_compact()
899 });
900
901 if compaction_policy.is_some()
902 && let Some(store) = self.compaction_checkpoint_store.as_ref()
903 && let Some(checkpoint) = store
904 .get_latest(
905 session_id,
906 model_with_provider.provider_type.as_str(),
907 &model_with_provider.model,
908 )
909 .await?
910 && checkpoint.is_compatible(
911 model_with_provider.provider_type.as_str(),
912 &model_with_provider.model,
913 )
914 && (native_reasoning_compaction || !matches!(
917 &checkpoint.payload,
918 crate::CompactionCheckpointPayload::ProviderOpaque {
919 context: crate::ProviderOpaqueContext::OpenResponsesCompact {
920 reasoning_state: Some(_), ..
921 }
922 }
923 ))
924 {
925 let filters = crate::capabilities::collect_message_filters_only(
926 &resolved_capability_configs,
927 &self.capability_registry,
928 );
929 let mut query =
930 crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
931 filters.apply_message_filters(&mut query);
932 let history = self.message_retriever.load_filtered_history(query).await?;
933 messages = history.messages;
934 checkpoint_suffix_message_count = messages.len();
935 filters.apply_post_load_filters(&mut messages);
936 if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
937 messages.insert(
938 0,
939 Message::system(format!(
940 "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
941 )),
942 );
943 }
944 message_source_sequence = history.source_sequence.or(message_source_sequence);
945 restored_checkpoint = Some(checkpoint);
946 }
947
948 let controls = resolve_request_controls(
949 &messages,
950 self.reasoning_effort_handle.as_ref(),
951 &model_with_provider.provider_type,
952 &model_with_provider.model,
953 );
954 let reasoning_effort = controls.reasoning_effort;
955 let speed = controls.speed;
956 let verbosity = controls.verbosity;
957 let checkpoint_reasoning =
958 restored_checkpoint
959 .as_ref()
960 .and_then(|checkpoint| match &checkpoint.payload {
961 crate::CompactionCheckpointPayload::ProviderOpaque {
962 context:
963 crate::ProviderOpaqueContext::OpenResponsesCompact {
964 reasoning_state, ..
965 },
966 } => reasoning_state.as_ref(),
967 _ => None,
968 });
969 let mut reasoning_replay = reasoning_updates::prepare(
970 &messages,
971 model_with_provider.provider_type.as_str(),
972 &model_with_provider.model,
973 reasoning_effort,
974 self.reasoning_effort_handle
975 .as_ref()
976 .and_then(crate::tool_context::ReasoningEffortHandle::get),
977 checkpoint_reasoning,
978 )
979 .filter(|_| native_reasoning_compaction);
980
981 if let Some(ref store) = self.partial_stream_store {
985 let turn_id_str = context.turn_id.to_string();
986 match store.get_partial_stream(session_id, &turn_id_str).await {
987 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
988 return self
990 .finalize_partial_stream(
991 session_id,
992 context,
993 partial,
994 iteration,
995 &runtime_agent,
996 &resolved_capability_configs,
997 )
998 .await;
999 }
1000 Ok(Some(partial)) => {
1001 if let (Some(replay), Some(mut saved)) =
1002 (reasoning_replay.as_mut(), partial.reasoning_state)
1003 {
1004 saved.pending = saved.effective;
1007 replay.state = saved;
1008 }
1009 let recovery_ctx = EventContext::from_execution_context(context);
1012 let _ = self
1013 .event_emitter
1014 .emit(EventRequest::new(
1015 session_id,
1016 recovery_ctx,
1017 ReasonRecoveredData {
1018 turn_id: context.turn_id,
1019 mode: RecoveryMode::Restart,
1020 accumulated_len: 0,
1021 },
1022 ))
1023 .await;
1024 tracing::info!(
1025 session_id = %session_id,
1026 turn_id = %context.turn_id,
1027 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1028 );
1029 }
1030 Ok(None) => {} Err(e) => {
1032 if reasoning_replay.is_some() {
1033 return Err(e);
1034 }
1035 tracing::warn!(
1037 session_id = %session_id,
1038 turn_id = %context.turn_id,
1039 error = %e,
1040 "ReasonAtom: partial-stream store error; proceeding with normal execution"
1041 );
1042 }
1043 }
1044 }
1045
1046 let repair_event_context = EventContext::from_execution_context(context);
1050 let patched_messages = repair_dangling_tool_calls(
1051 &messages,
1052 self.durable_tool_result_store.as_deref(),
1053 self.event_emitter.as_ref(),
1054 session_id,
1055 &repair_event_context,
1056 &context.turn_id.to_string(),
1057 )
1058 .await;
1059 let raw_tool_result_bytes = compaction_policy
1060 .as_ref()
1061 .map(|policy| policy.total_tool_result_bytes(&patched_messages))
1062 .unwrap_or(0);
1063
1064 let model_view_providers = crate::capabilities::collect_model_view_providers(
1067 &resolved_capability_configs,
1068 &self.capability_registry,
1069 Some(model_with_provider.model.as_str()),
1070 );
1071 let model_view_context = crate::capabilities::ModelViewContext {
1072 session_id,
1073 prior_usage: prior_usage.as_ref(),
1074 };
1075 let mut context_messages =
1076 model_view_providers.apply_model_view(patched_messages, &model_view_context);
1077 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1078 &context_messages,
1079 stateful_response_continuation || restored_checkpoint.is_some(),
1080 );
1081
1082 let mut volatile_suffix_len = 0usize;
1089 {
1090 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1091 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1092 &resolved_capability_configs,
1093 &self.capability_registry,
1094 Some(model_with_provider.model.as_str()),
1095 &facts_ctx,
1096 );
1097 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1098 context_messages.push(Message::user(block));
1099 volatile_suffix_len = 1;
1100 }
1101 }
1102
1103 let resolved_images = self.resolve_images(&context_messages).await;
1108
1109 let mut llm_messages = Vec::new();
1111
1112 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1114 if has_system_prompt {
1115 llm_messages.push(LlmMessage {
1116 role: LlmMessageRole::System,
1117 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1118 tool_calls: None,
1119 tool_call_id: None,
1120 phase: None,
1121 reasoning: Vec::new(),
1122 configuration_update: None,
1123 });
1124 }
1125
1126 let messages_for_event: Vec<Message> = if has_system_prompt {
1128 std::iter::once(Message::system(&runtime_agent.system_prompt))
1129 .chain(context_messages.iter().cloned())
1130 .collect()
1131 } else {
1132 context_messages.clone()
1133 };
1134
1135 let mut stripped_error_count = 0u32;
1141 for msg in &context_messages {
1142 if is_error_placeholder_message(msg) {
1143 stripped_error_count += 1;
1144 continue;
1145 }
1146 let mut llm_msg =
1147 crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1148 llm_msg.configuration_update = reasoning_replay
1149 .as_ref()
1150 .and_then(|replay| replay.transitions.get(&msg.id).copied());
1151 if msg.role == MessageRole::User
1152 && let Some(ref actor) = msg.external_actor
1153 {
1154 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1155 }
1156 llm_messages.push(llm_msg);
1157 }
1158 if stripped_error_count > 0 {
1159 tracing::info!(
1160 session_id = %session_id,
1161 stripped_error_count,
1162 "ReasonAtom: stripped error placeholder messages from LLM input"
1163 );
1164 }
1165
1166 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1171 llm_messages,
1172 stateful_response_continuation || restored_checkpoint.is_some(),
1173 );
1174
1175 let mut llm_config_builder =
1177 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1178 if let Some(effort) = reasoning_effort {
1179 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1180 }
1181 if let Some(speed) = speed {
1182 llm_config_builder = llm_config_builder.speed(speed);
1183 }
1184 if let Some(verbosity) = verbosity {
1185 llm_config_builder = llm_config_builder.verbosity(verbosity);
1186 }
1187
1188 for (k, v) in &embedder_metadata {
1190 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1191 }
1192
1193 llm_config_builder = llm_config_builder
1197 .with_metadata("session_id", session_id.to_string())
1198 .with_metadata("harness_id", harness_id.to_string())
1199 .with_metadata("turn_id", context.turn_id.to_string())
1200 .with_metadata("exec_id", context.exec_id.to_string())
1201 .with_metadata("org_id", format!("org_{:032x}", org_id));
1202 if let Some(agent_id) = agent_id {
1203 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1204 }
1205
1206 if let Some(model_id) = &resolved_model_id {
1208 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1209 }
1210
1211 let mut llm_config = llm_config_builder
1212 .previous_response_id(previous_response_id.clone())
1213 .volatile_suffix_len(volatile_suffix_len)
1214 .build();
1215 if let Some(replay) = &reasoning_replay {
1216 llm_config.reasoning_effort = replay.state.baseline;
1217 llm_config.reasoning_state = Some(replay.state.clone());
1218 if replay.reset_continuation {
1219 llm_config.previous_response_id = None;
1220 }
1221 } else if messages
1222 .iter()
1223 .rev()
1224 .find(|message| {
1225 message.role == MessageRole::Agent && !is_error_placeholder_message(message)
1226 })
1227 .and_then(|message| message.metadata.as_ref())
1228 .is_some_and(|metadata| metadata.contains_key(reasoning_updates::STATE_KEY))
1229 {
1230 llm_config.previous_response_id = None;
1233 }
1234 if let Some(checkpoint) = restored_checkpoint.as_ref()
1235 && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1236 &checkpoint.payload
1237 {
1238 llm_config.previous_response_id = None;
1239 llm_config.provider_opaque_context = Some(context.clone());
1240 }
1241
1242 tracing::debug!(
1243 session_id = %session_id,
1244 turn_id = %context.turn_id,
1245 model = %runtime_agent.model,
1246 message_count = %llm_messages.len(),
1247 "ReasonAtom: calling LLM"
1248 );
1249
1250 let streaming_event_context = EventContext::from_execution_context(context);
1253
1254 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1261 for (cap_id, cfg, provider) in &guardrail_providers {
1262 let ctx = OutputGuardrailContext {
1263 system_prompt: &runtime_agent.system_prompt,
1264 config: cfg,
1265 };
1266 let guardrail_id = provider.id().to_string();
1267 if let Some(run) = provider.arm(&ctx) {
1268 armed_guardrails.push(ArmedGuardrail {
1269 capability_id: cap_id.clone(),
1270 guardrail_id,
1271 run,
1272 });
1273 }
1274 }
1275 let buffer_output_deltas = !post_output_providers.is_empty();
1280 let output_message_id = MessageId::new();
1284 tracing::info!(
1285 session_id = %session_id,
1286 turn_id = %context.turn_id,
1287 "ReasonAtom: emitting output.message.started event"
1288 );
1289 if let Err(e) = self
1290 .event_emitter
1291 .emit(EventRequest::new(
1292 session_id,
1293 streaming_event_context.clone(),
1294 OutputMessageStartedData {
1295 reasoning_state: llm_config.reasoning_state.clone(),
1296 turn_id: context.turn_id,
1297 message_id: output_message_id,
1298 model: Some(runtime_agent.model.clone()),
1299 iteration: Some(iteration),
1300 phase: None,
1303 },
1304 ))
1305 .await
1306 {
1307 if llm_config.reasoning_state.is_some() {
1308 return Err(e);
1309 }
1310 tracing::warn!(
1311 session_id = %session_id,
1312 error = %e,
1313 "ReasonAtom: failed to emit output.message.started event"
1314 );
1315 } else {
1316 tracing::info!(
1317 session_id = %session_id,
1318 "ReasonAtom: output.message.started event emitted successfully"
1319 );
1320 }
1321
1322 let thinking_enabled = reasoning_effort.is_some();
1324 if thinking_enabled {
1325 tracing::info!(
1326 session_id = %session_id,
1327 turn_id = %context.turn_id,
1328 "ReasonAtom: emitting reason.thinking.started event"
1329 );
1330 if let Err(e) = self
1331 .event_emitter
1332 .emit(EventRequest::new(
1333 session_id,
1334 streaming_event_context.clone(),
1335 ReasonThinkingStartedData {
1336 turn_id: context.turn_id,
1337 model: Some(runtime_agent.model.clone()),
1338 },
1339 ))
1340 .await
1341 {
1342 tracing::warn!(
1343 session_id = %session_id,
1344 error = %e,
1345 "ReasonAtom: failed to emit reason.thinking.started event"
1346 );
1347 } else {
1348 tracing::info!(
1349 session_id = %session_id,
1350 "ReasonAtom: reason.thinking.started event emitted successfully"
1351 );
1352 }
1353 }
1354
1355 let llm_start = Instant::now();
1357
1358 let mut compaction_info: Option<LlmCompactionInfo> = None;
1362 let mut llm_messages_for_call = llm_messages.clone();
1363
1364 if let Some(policy) = compaction_policy.as_deref() {
1365 compaction_info = apply_proactive_compaction(
1366 ProactiveCompactionContext {
1367 chat_driver: chat_driver.as_ref(),
1368 policy,
1369 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1370 event_emitter: self.event_emitter.as_ref(),
1371 event_context: &streaming_event_context,
1372 session_id,
1373 message_source_sequence,
1374 provider_type: model_with_provider.provider_type.as_str(),
1375 model: &model_with_provider.model,
1376 system_prompt: has_system_prompt
1377 .then_some(runtime_agent.system_prompt.as_str()),
1378 stateful_response_continuation,
1379 checkpoint_restored: restored_checkpoint.is_some(),
1380 checkpoint_suffix_message_count,
1381 raw_tool_result_bytes,
1382 prior_usage: prior_usage.as_ref(),
1383 },
1384 &mut llm_messages_for_call,
1385 &mut llm_config,
1386 )
1387 .await?;
1388 }
1389
1390 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1393 let retry_config = self.provider_retry_config.clone();
1394 let has_provider_executed_tools = llm_config
1398 .openrouter_routing
1399 .as_ref()
1400 .is_some_and(|routing| !routing.server_tools.is_empty());
1401 let mut stream_retry_metadata = RetryMetadata::default();
1402 let mut retry_started_at = None;
1403 let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1408 let (
1409 text,
1410 thinking,
1411 reasoning,
1412 tool_calls,
1413 completion_metadata,
1414 time_to_first_token_ms,
1415 pending_delta,
1416 mut tripped,
1417 ) = 'stream_attempt: loop {
1418 let stream_result = if let Some(remaining) =
1419 remaining_retry_time(&retry_config, retry_started_at)
1420 {
1421 match tokio::time::timeout(
1422 remaining,
1423 chat_driver.chat_completion_stream(
1424 &crate::ProviderEndpoint::default(),
1425 llm_messages_for_call.clone(),
1426 &llm_config,
1427 ),
1428 )
1429 .await
1430 {
1431 Ok(result) => result,
1432 Err(_) => {
1433 return Err(AgentLoopError::llm_kind(
1434 crate::error::LlmErrorKind::Unavailable,
1435 format!(
1436 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1437 stream_retry_metadata.attempts,
1438 retry_config.max_retry_elapsed.as_secs_f64()
1439 ),
1440 )
1441 .with_retry_metadata(&stream_retry_metadata));
1442 }
1443 }
1444 } else {
1445 chat_driver
1446 .chat_completion_stream(
1447 &crate::ProviderEndpoint::default(),
1448 llm_messages_for_call.clone(),
1449 &llm_config,
1450 )
1451 .await
1452 };
1453 let mut stream = match stream_result {
1454 Ok(stream) => stream,
1455 Err(e) if e.is_request_too_large() => {
1456 let Some(policy) = compaction_policy.as_deref() else {
1457 tracing::warn!(
1458 session_id = %session_id,
1459 turn_id = %context.turn_id,
1460 "ReasonAtom: context too large and compaction capability is not enabled"
1461 );
1462 return Err(e);
1463 };
1464 let outcome = apply_reactive_compaction(
1465 ReactiveCompactionContext {
1466 chat_driver: chat_driver.as_ref(),
1467 policy,
1468 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1469 event_emitter: self.event_emitter.as_ref(),
1470 event_context: &streaming_event_context,
1471 session_id,
1472 message_source_sequence,
1473 provider_type: model_with_provider.provider_type.as_str(),
1474 model: &model_with_provider.model,
1475 summarization_model_fallback: &runtime_agent.model,
1476 system_prompt: has_system_prompt
1477 .then_some(runtime_agent.system_prompt.as_str()),
1478 stateful_response_continuation,
1479 },
1480 &mut llm_messages_for_call,
1481 &mut llm_config,
1482 )
1483 .await?;
1484 let Some(outcome) = outcome else {
1485 return Err(e);
1486 };
1487 if outcome.generation_info.is_some() {
1488 compaction_info = outcome.generation_info;
1489 }
1490
1491 chat_driver
1492 .chat_completion_stream(
1493 &crate::ProviderEndpoint::default(),
1494 llm_messages_for_call.clone(),
1495 &llm_config,
1496 )
1497 .await?
1498 }
1499 Err(e)
1500 if e.is_transient_llm_error()
1501 && !e.llm_retry_handled()
1502 && !has_provider_executed_tools
1503 && stream_retry_metadata.attempts < retry_config.max_retries =>
1504 {
1505 let proposed_wait =
1506 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1507 let Some(wait_duration) =
1508 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1509 else {
1510 return Err(AgentLoopError::llm_kind(
1511 e.llm_error_kind()
1512 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1513 format!(
1514 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1515 stream_retry_metadata.attempts
1516 ),
1517 )
1518 .with_retry_metadata(&stream_retry_metadata));
1519 };
1520 tracing::warn!(
1521 session_id = %session_id,
1522 turn_id = %context.turn_id,
1523 attempt = stream_retry_metadata.attempts + 1,
1524 max_retries = retry_config.max_retries,
1525 wait_secs = wait_duration.as_secs_f64(),
1526 error = %e,
1527 "ReasonAtom: transient provider failure before stream, retrying"
1528 );
1529 stream_retry_metadata.record_retry(wait_duration, None);
1530 tokio::time::sleep(wait_duration).await;
1531 continue 'stream_attempt;
1532 }
1533 Err(e) => return Err(e),
1534 };
1535
1536 let mut text = String::new();
1537 let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1541 let mut thinking = String::new();
1543 let mut tool_calls = Vec::new();
1544 let mut termination = StreamTermination::Exhausted;
1545 let mut replay_state = StreamReplayState::for_request(has_provider_executed_tools);
1546 let mut pending_delta = String::new();
1547 let mut pending_thinking_delta = String::new();
1548 let mut last_delta_emit = Instant::now();
1549 let mut last_thinking_delta_emit = Instant::now();
1550 let mut time_to_first_token_ms: Option<u64> = None;
1551
1552 let stall_timeout = self
1554 .provider_stall_timeout
1555 .unwrap_or(std::time::Duration::from_secs(120));
1556 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1557 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1558 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1559 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1560 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1561 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1563 let mut last_token_at_unix: u64 = unix_now_secs();
1568
1569 loop {
1570 let event = tokio::select! {
1571 biased;
1572 next = stream.next() => match next {
1573 Some(e) => e,
1574 None => break,
1575 },
1576 _ = &mut stall_sleep => {
1577 let stall_error =
1587 crate::driver_registry::LlmStreamError::new(format!(
1588 "provider stream stall: no tokens for {}s",
1589 stall_timeout.as_secs()
1590 ));
1591 tracing::warn!(
1592 session_id = %session_id,
1593 turn_id = %context.turn_id,
1594 stall_secs = stall_timeout.as_secs(),
1595 "ReasonAtom: provider stream stall timeout"
1596 );
1597 if replay_state.should_retry(
1598 &stall_error,
1599 stream_retry_metadata.attempts,
1600 retry_config.max_retries,
1601 ) {
1602 let proposed_wait = retry_config
1603 .calculate_backoff(stream_retry_metadata.attempts);
1604 let Some(wait_duration) = reserve_retry_wait(
1605 &retry_config,
1606 &mut retry_started_at,
1607 proposed_wait,
1608 ) else {
1609 return Err(AgentLoopError::llm_kind(
1610 crate::error::LlmErrorKind::Unavailable,
1611 format!(
1612 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1613 stall_error.message,
1614 stream_retry_metadata.attempts
1615 ),
1616 )
1617 .with_retry_metadata(&stream_retry_metadata));
1618 };
1619 tracing::warn!(
1620 session_id = %session_id,
1621 turn_id = %context.turn_id,
1622 attempt = stream_retry_metadata.attempts + 1,
1623 max_retries = retry_config.max_retries,
1624 wait_secs = wait_duration.as_secs_f64(),
1625 "ReasonAtom: provider stream stall, retrying"
1626 );
1627 stream_retry_metadata.record_retry(wait_duration, None);
1628 tokio::time::sleep(wait_duration).await;
1629 continue 'stream_attempt;
1630 }
1631 return Err(AgentLoopError::llm(stall_error.message));
1632 },
1633 _ = keepalive_ticker.tick() => {
1634 if let Some(ref hb) = self.stream_heartbeater {
1635 hb.heartbeat(crate::durability::StreamProgress {
1636 accumulated_len: text.len() + thinking.len(),
1637 last_delta_at: last_token_at_unix,
1638 })
1639 .await;
1640 last_stream_heartbeat = Instant::now();
1641 }
1642 continue;
1643 },
1644 };
1645 let event = event?;
1646 replay_state.observe(&event);
1647 let advanced_stall_deadline = advances_stall_deadline(&event);
1648 if advanced_stall_deadline {
1649 stall_sleep
1650 .as_mut()
1651 .reset(tokio::time::Instant::now() + stall_timeout);
1652 last_token_at_unix = unix_now_secs();
1653 }
1654 match event {
1655 LlmStreamEvent::TextDelta(delta) => {
1656 if delta.is_empty() {
1657 continue;
1658 }
1659 if time_to_first_token_ms.is_none() {
1661 let ttft = llm_start.elapsed().as_millis() as u64;
1662 time_to_first_token_ms = Some(ttft);
1663 tracing::info!(
1664 session_id = %session_id,
1665 time_to_first_token_ms = ttft,
1666 "ReasonAtom: received first token from LLM"
1667 );
1668 }
1669 text.push_str(&delta);
1670 pending_delta.push_str(&delta);
1671
1672 if !armed_guardrails.is_empty()
1679 && let Some(t) =
1680 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1681 {
1682 tracing::warn!(
1683 session_id = %session_id,
1684 turn_id = %context.turn_id,
1685 guardrail_capability_id = %t.capability_id,
1686 guardrail_id = %t.guardrail_id,
1687 reason_code = %t.block.reason_code,
1688 "ReasonAtom: output guardrail tripped, replacing assistant message"
1689 );
1690 pending_delta.clear();
1691 termination = StreamTermination::GuardrailBlocked(t);
1692 break;
1693 }
1694
1695 if !buffer_output_deltas
1697 && last_delta_emit.elapsed().as_millis() as u64
1698 >= DELTA_BATCH_INTERVAL_MS
1699 && !pending_delta.is_empty()
1700 {
1701 if let Err(e) = self
1702 .event_emitter
1703 .emit(EventRequest::new(
1704 session_id,
1705 streaming_event_context.clone(),
1706 OutputMessageDeltaData {
1707 turn_id: context.turn_id,
1708 message_id: output_message_id,
1709 delta: pending_delta.clone(),
1710 accumulated: text.clone(),
1711 phase: streamed_phase,
1712 },
1713 ))
1714 .await
1715 {
1716 tracing::warn!(
1717 session_id = %session_id,
1718 error = %e,
1719 "ReasonAtom: failed to emit output.message.delta event"
1720 );
1721 }
1722 pending_delta.clear();
1723 last_delta_emit = Instant::now();
1724 }
1725 }
1726 LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1727 if delta.is_empty() {
1728 continue;
1729 }
1730 if let Some(t) = append_guarded_thinking_delta(
1731 &mut armed_guardrails,
1732 &mut thinking,
1733 &mut pending_thinking_delta,
1734 &delta,
1735 ) {
1736 tracing::warn!(
1737 session_id = %session_id,
1738 guardrail_capability_id = %t.capability_id,
1739 guardrail_id = %t.guardrail_id,
1740 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1741 );
1742 termination = StreamTermination::GuardrailBlocked(t);
1743 break;
1744 }
1745 tracing::debug!(
1746 session_id = %session_id,
1747 delta_len = delta.len(),
1748 total_thinking_len = thinking.len(),
1749 "ReasonAtom: received ThinkingDelta from LLM"
1750 );
1751
1752 if last_thinking_delta_emit.elapsed().as_millis() as u64
1754 >= DELTA_BATCH_INTERVAL_MS
1755 && !pending_thinking_delta.is_empty()
1756 {
1757 if let Err(e) = self
1758 .event_emitter
1759 .emit(EventRequest::new(
1760 session_id,
1761 streaming_event_context.clone(),
1762 ReasonThinkingDeltaData {
1763 turn_id: context.turn_id,
1764 delta: pending_thinking_delta.clone(),
1765 accumulated: thinking.clone(),
1766 },
1767 ))
1768 .await
1769 {
1770 tracing::warn!(
1771 session_id = %session_id,
1772 error = %e,
1773 "ReasonAtom: failed to emit reason.thinking.delta event"
1774 );
1775 }
1776 pending_thinking_delta.clear();
1777 last_thinking_delta_emit = Instant::now();
1778 }
1779 }
1780 LlmStreamEvent::ReasoningItem(item) => {
1781 if let Some(t) = inspect_guarded_reasoning_item(
1782 &mut armed_guardrails,
1783 &mut thinking,
1784 &item,
1785 ) {
1786 tracing::warn!(
1787 session_id = %session_id,
1788 guardrail_capability_id = %t.capability_id,
1789 guardrail_id = %t.guardrail_id,
1790 "ReasonAtom: output guardrail tripped on completed reasoning item, replacing assistant message"
1791 );
1792 termination = StreamTermination::GuardrailBlocked(t);
1793 break;
1794 }
1795 tracing::debug!(
1799 session_id = %session_id,
1800 provider = %item.provider,
1801 item_id = ?item.item_id,
1802 has_signature = item.signature.is_some(),
1803 has_encrypted = item.encrypted.is_some(),
1804 "ReasonAtom: captured reasoning artifact"
1805 );
1806 reasoning.push(item);
1807 }
1808 LlmStreamEvent::ToolCalls(calls) => {
1809 tool_calls = calls;
1810 }
1811 LlmStreamEvent::MessagePhase(phase) => {
1812 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1821 streamed_phase,
1822 phase,
1823 );
1824 }
1825 LlmStreamEvent::Done(metadata) => {
1826 if !buffer_output_deltas
1830 && !pending_delta.is_empty()
1831 && let Err(e) = self
1832 .event_emitter
1833 .emit(EventRequest::new(
1834 session_id,
1835 streaming_event_context.clone(),
1836 OutputMessageDeltaData {
1837 turn_id: context.turn_id,
1838 message_id: output_message_id,
1839 delta: pending_delta.clone(),
1840 accumulated: text.clone(),
1841 phase: streamed_phase,
1842 },
1843 ))
1844 .await
1845 {
1846 tracing::warn!(
1847 session_id = %session_id,
1848 error = %e,
1849 "ReasonAtom: failed to emit final output.message.delta event"
1850 );
1851 }
1852
1853 if !pending_thinking_delta.is_empty()
1855 && let Err(e) = self
1856 .event_emitter
1857 .emit(EventRequest::new(
1858 session_id,
1859 streaming_event_context.clone(),
1860 ReasonThinkingDeltaData {
1861 turn_id: context.turn_id,
1862 delta: pending_thinking_delta.clone(),
1863 accumulated: thinking.clone(),
1864 },
1865 ))
1866 .await
1867 {
1868 tracing::warn!(
1869 session_id = %session_id,
1870 error = %e,
1871 "ReasonAtom: failed to emit final reason.thinking.delta event"
1872 );
1873 }
1874
1875 if !thinking.is_empty()
1877 && let Err(e) = self
1878 .event_emitter
1879 .emit(EventRequest::new(
1880 session_id,
1881 streaming_event_context.clone(),
1882 ReasonThinkingCompletedData {
1883 turn_id: context.turn_id,
1884 thinking: thinking.clone(),
1885 },
1886 ))
1887 .await
1888 {
1889 tracing::warn!(
1890 session_id = %session_id,
1891 error = %e,
1892 "ReasonAtom: failed to emit reason.thinking.completed event"
1893 );
1894 }
1895 termination = StreamTermination::Completed(metadata);
1896 break;
1897 }
1898 LlmStreamEvent::Error(err) => {
1899 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
1904
1905 if has_partial_output {
1906 tracing::warn!(
1907 session_id = %session_id,
1908 error = %err,
1909 tool_call_count = tool_calls.len(),
1910 text_len = text.len(),
1911 "ReasonAtom: trailing stream error after valid output — treating as partial success"
1912 );
1913 termination = StreamTermination::PartialSuccess;
1917 break;
1918 }
1919
1920 if replay_state.should_retry(
1921 &err,
1922 stream_retry_metadata.attempts,
1923 retry_config.max_retries,
1924 ) {
1925 let proposed_wait =
1926 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1927 let Some(wait_duration) = reserve_retry_wait(
1928 &retry_config,
1929 &mut retry_started_at,
1930 proposed_wait,
1931 ) else {
1932 return Err(AgentLoopError::llm_kind(
1933 err.kind(),
1934 format!(
1935 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1936 stream_retry_metadata.attempts
1937 ),
1938 )
1939 .with_retry_metadata(&stream_retry_metadata));
1940 };
1941 tracing::warn!(
1942 session_id = %session_id,
1943 turn_id = %context.turn_id,
1944 attempt = stream_retry_metadata.attempts + 1,
1945 max_retries = retry_config.max_retries,
1946 wait_secs = wait_duration.as_secs_f64(),
1947 error_code = err.code.as_deref().unwrap_or("none"),
1948 error_status = err.status,
1949 error = %err,
1950 "ReasonAtom: transient stream error before output, retrying"
1951 );
1952 stream_retry_metadata.record_retry(wait_duration, None);
1953 tokio::time::sleep(wait_duration).await;
1954 continue 'stream_attempt;
1955 }
1956
1957 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
1959 let event_context = EventContext::from_execution_context(context)
1960 .with_span(
1961 trace_id.to_string(),
1962 Uuid::now_v7().to_string(),
1963 Some(reason_span_id.to_string()),
1964 );
1965 let tools_summary: Vec<ToolDefinitionSummary> =
1966 runtime_agent.tools.iter().map(|t| t.into()).collect();
1967 let generation_data = LlmGenerationData::failure(
1968 messages_for_event.clone(),
1969 tools_summary,
1970 runtime_agent.model.clone(),
1971 Some(model_with_provider.provider_type.to_string()),
1972 err.to_string(),
1973 Some(llm_duration_ms),
1974 time_to_first_token_ms,
1975 );
1976 let _ = self
1977 .event_emitter
1978 .emit(EventRequest::new(
1979 session_id,
1980 event_context,
1981 generation_data,
1982 ))
1983 .await;
1984 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
1985 }
1986 }
1987 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
1990 && let Some(ref hb) = self.stream_heartbeater
1991 {
1992 hb.heartbeat(crate::durability::StreamProgress {
1993 accumulated_len: text.len() + thinking.len(),
1994 last_delta_at: last_token_at_unix,
1995 })
1996 .await;
1997 last_stream_heartbeat = Instant::now();
1998 }
1999 }
2000 let (mut completion_metadata, tripped) = termination.into_parts();
2001 if let Some(metadata) = completion_metadata.as_mut() {
2002 metadata.retry_metadata =
2003 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2004 }
2005
2006 break 'stream_attempt (
2007 text,
2008 thinking,
2009 reasoning,
2010 tool_calls,
2011 completion_metadata,
2012 time_to_first_token_ms,
2013 pending_delta,
2014 tripped,
2015 );
2016 };
2017 let (mut text, mut thinking, mut reasoning, mut tool_calls) =
2018 (text, thinking, reasoning, tool_calls);
2019
2020 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
2031 if tripped.is_none()
2032 && !annotation_providers.is_empty()
2033 && !text.is_empty()
2034 && tool_calls.is_empty()
2035 {
2036 text = filter_response_text(
2039 &self.capability_registry,
2040 &resolved_capability_configs,
2041 text,
2042 );
2043 let collected = collect_annotations(
2044 &annotation_providers,
2045 &runtime_agent.system_prompt,
2046 &text,
2047 &messages,
2048 self.utility_llm_service.as_ref(),
2049 )
2050 .await;
2051 text = collected.text;
2052 citation_annotations = collected.annotations;
2053
2054 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
2057 let guarded_output = client_visible_guardrail_text(
2058 &text,
2059 &thinking,
2060 &reasoning,
2061 &citation_annotations,
2062 );
2063 let ctx = PostGenerationOutputContext {
2064 system_prompt: &runtime_agent.system_prompt,
2065 message_text: &guarded_output,
2066 utility_llm_service: self.utility_llm_service.as_ref(),
2067 };
2068 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2069 }
2070
2071 if tripped.is_none()
2074 && !citation_annotations.is_empty()
2075 && !citation_verifiers.is_empty()
2076 {
2077 citation_annotations = verify_annotations(
2078 &citation_verifiers,
2079 &text,
2080 self.utility_llm_service.as_ref(),
2081 citation_annotations,
2082 )
2083 .await;
2084 }
2085 }
2086
2087 if tripped.is_none()
2090 && citation_annotations.is_empty()
2091 && !post_output_providers.is_empty()
2092 && (!text.is_empty() || !thinking.is_empty() || !reasoning.is_empty())
2093 {
2094 let guarded_output = client_visible_guardrail_text(&text, &thinking, &reasoning, &[]);
2095 let ctx = PostGenerationOutputContext {
2096 system_prompt: &runtime_agent.system_prompt,
2097 message_text: &guarded_output,
2098 utility_llm_service: self.utility_llm_service.as_ref(),
2099 };
2100 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2101 }
2102
2103 if tripped.is_some() {
2104 citation_annotations.clear();
2105 }
2106
2107 if tripped.is_none() {
2111 for item in &reasoning {
2112 if let Err(e) = self
2113 .event_emitter
2114 .emit(EventRequest::new(
2115 session_id,
2116 streaming_event_context.clone(),
2117 ReasonItemData {
2118 turn_id: context.turn_id,
2119 provider: item.provider.clone(),
2120 model: Some(llm_config.model.clone()),
2121 item_id: item.item_id.clone().unwrap_or_default(),
2122 summary: item
2123 .display_text()
2124 .filter(|_| !matches!(item.text, Some(ReasoningText::Plain { .. })))
2125 .into_iter()
2126 .collect(),
2127 token_count: item.tokens,
2128 },
2129 ))
2130 .await
2131 {
2132 tracing::warn!(
2133 session_id = %session_id,
2134 error = %e,
2135 "ReasonAtom: failed to emit reason.item event"
2136 );
2137 }
2138 }
2139 }
2140
2141 if buffer_output_deltas
2144 && tripped.is_none()
2145 && !pending_delta.is_empty()
2146 && let Err(e) = self
2147 .event_emitter
2148 .emit(EventRequest::new(
2149 session_id,
2150 streaming_event_context.clone(),
2151 OutputMessageDeltaData {
2152 turn_id: context.turn_id,
2153 message_id: output_message_id,
2154 delta: pending_delta.clone(),
2155 accumulated: text.clone(),
2156 phase: streamed_phase,
2157 },
2158 ))
2159 .await
2160 {
2161 tracing::warn!(
2162 session_id = %session_id,
2163 error = %e,
2164 "ReasonAtom: failed to emit guarded output.message.delta event"
2165 );
2166 }
2167
2168 if let Some(ref t) = tripped {
2174 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2175 trace_id.to_string(),
2176 Uuid::now_v7().to_string(),
2177 Some(reason_span_id.to_string()),
2178 );
2179 if let Err(e) = self
2180 .event_emitter
2181 .emit(EventRequest::new(
2182 session_id,
2183 replaced_event_context,
2184 OutputMessageReplacedData {
2185 turn_id: context.turn_id,
2186 message_id: output_message_id,
2187 guardrail_capability_id: t.capability_id.clone(),
2188 guardrail_id: t.guardrail_id.clone(),
2189 reason_code: t.block.reason_code.clone(),
2190 replacement: t.block.replacement.clone(),
2191 },
2192 ))
2193 .await
2194 {
2195 tracing::warn!(
2196 session_id = %session_id,
2197 error = %e,
2198 "ReasonAtom: failed to emit output.message.replaced event"
2199 );
2200 }
2201 text = t.block.replacement.clone();
2202 tool_calls.clear();
2203 thinking.clear();
2204 reasoning.clear();
2205 }
2206
2207 if !tool_calls.is_empty() {
2211 self.apply_finalized_tool_call_hooks(
2212 session_id,
2213 context,
2214 &resolved_capability_configs,
2215 &runtime_agent.tools,
2216 &mut tool_calls,
2217 iteration,
2218 )
2219 .await;
2220 }
2221
2222 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2223
2224 let response_id = completion_metadata
2226 .as_ref()
2227 .and_then(|meta| meta.response_id.clone());
2228 let finish_reason = completion_metadata
2229 .as_ref()
2230 .and_then(|meta| meta.finish_reason.clone());
2231
2232 let usage = completion_metadata.as_ref().and_then(|meta| {
2240 match (meta.prompt_tokens, meta.completion_tokens) {
2241 (Some(input), Some(output)) => {
2242 let actual_cost_usd = meta.provider_cost_usd;
2243 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2244 &model_with_provider.provider_type,
2245 &runtime_agent.model,
2246 input,
2247 output,
2248 meta.cache_read_tokens.unwrap_or(0),
2249 meta.cache_creation_tokens.unwrap_or(0),
2250 );
2251 Some(
2252 TokenUsage::with_cache(
2253 input,
2254 output,
2255 meta.cache_read_tokens,
2256 meta.cache_creation_tokens,
2257 )
2258 .with_cost(actual_cost_usd, estimated_cost_usd),
2259 )
2260 }
2261 _ => None,
2262 }
2263 });
2264
2265 let event_context = EventContext::from_execution_context(context).with_span(
2267 trace_id.to_string(),
2268 Uuid::now_v7().to_string(),
2269 Some(reason_span_id.to_string()),
2270 );
2271 let tools_summary: Vec<ToolDefinitionSummary> =
2272 runtime_agent.tools.iter().map(|t| t.into()).collect();
2273 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2274 if tool_calls.is_empty() {
2275 "stop".to_string()
2276 } else {
2277 "tool_calls".to_string()
2278 }
2279 })]);
2280 let retry_info = completion_metadata
2282 .as_ref()
2283 .and_then(|meta| meta.retry_metadata.as_ref())
2284 .filter(|rm| rm.had_retries())
2285 .map(|rm| LlmRetryInfo {
2286 attempts: rm.attempts,
2287 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2288 });
2289 let mut generation_data = LlmGenerationData::success_with_retry(
2291 messages_for_event.clone(),
2292 tools_summary,
2293 Some(text.clone()).filter(|s| !s.is_empty()),
2294 tool_calls.clone(),
2295 runtime_agent.model.clone(),
2296 Some(model_with_provider.provider_type.to_string()),
2297 usage.clone(),
2298 Some(llm_duration_ms),
2299 time_to_first_token_ms,
2300 finish_reasons,
2301 response_id.clone(),
2302 retry_info,
2303 );
2304
2305 if let Some(info) = compaction_info {
2311 if let Some(compaction_cost) = info.cost_usd {
2312 match generation_data.metadata.usage.as_mut() {
2313 Some(usage) => {
2314 add_compaction_cost(usage, compaction_cost);
2315 }
2316 None => {
2321 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2322 input_tokens: 0,
2323 output_tokens: 0,
2324 cache_read_tokens: None,
2325 cache_creation_tokens: None,
2326 actual_cost_usd: Some(compaction_cost),
2327 estimated_cost_usd: None,
2328 effective_cost_usd: None,
2329 });
2330 }
2331 }
2332 }
2333 generation_data = generation_data.with_compaction(info);
2334 }
2335
2336 if let Some(request_options) =
2337 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2338 {
2339 generation_data = generation_data.with_request_options(request_options);
2340 }
2341
2342 if let Err(e) = self
2343 .event_emitter
2344 .emit(EventRequest::new(
2345 session_id,
2346 event_context,
2347 generation_data,
2348 ))
2349 .await
2350 {
2351 tracing::warn!(
2352 session_id = %session_id,
2353 error = %e,
2354 "ReasonAtom: failed to emit llm.generation event"
2355 );
2356 }
2357
2358 let mut metadata = std::collections::HashMap::new();
2360 metadata.insert(
2361 "model".to_string(),
2362 serde_json::Value::String(runtime_agent.model.clone()),
2363 );
2364 if let Some(state) = &llm_config.reasoning_state {
2365 metadata.insert(
2366 reasoning_updates::STATE_KEY.to_string(),
2367 serde_json::json!(state),
2368 );
2369 }
2370 if let Some(effort) = llm_config
2371 .reasoning_state
2372 .as_ref()
2373 .and_then(|state| state.effective)
2374 .or(reasoning_effort)
2375 {
2376 metadata.insert(
2377 "reasoning_effort".to_string(),
2378 serde_json::Value::String(effort.as_str().to_string()),
2379 );
2380 }
2381 metadata.insert(
2388 "provider".to_string(),
2389 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2390 );
2391 if let Some(ref rid) = response_id {
2392 metadata.insert(
2393 "response_id".to_string(),
2394 serde_json::Value::String(rid.clone()),
2395 );
2396 }
2397
2398 let text = filter_response_text(
2402 &self.capability_registry,
2403 &resolved_capability_configs,
2404 text,
2405 );
2406 let has_tool_calls = !tool_calls.is_empty();
2407 let mut assistant_message = if has_tool_calls {
2408 Message::assistant_with_tools(&text, tool_calls.clone())
2409 } else {
2410 Message::assistant(&text)
2411 }
2412 .with_id(output_message_id);
2413 if !citation_annotations.is_empty() {
2416 for part in assistant_message.content.iter_mut() {
2417 if let crate::message::ContentPart::Text(t) = part {
2418 t.annotations = std::mem::take(&mut citation_annotations);
2419 break;
2420 }
2421 }
2422 }
2423 let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2427 let provider_phase = completion_metadata
2431 .as_ref()
2432 .and_then(|meta| meta.phase.as_deref())
2433 .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2434 let (phase, phase_source) = match provider_phase {
2435 Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2436 None => (
2437 everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2438 everruns_provider::PhaseSource::Derived,
2439 ),
2440 };
2441 assistant_message.phase = Some(phase);
2442 assistant_message.phase_source = Some(phase_source);
2443 assistant_message.metadata = Some(metadata);
2444 if reasoning.is_empty() && !thinking.is_empty() {
2453 reasoning.push(
2454 ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2455 ReasoningText::Plain {
2456 text: thinking.clone(),
2457 },
2458 ),
2459 );
2460 }
2461 if !reasoning.is_empty() {
2462 let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2463 content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2464 content.append(&mut assistant_message.content);
2465 assistant_message.content = content;
2466 }
2467 let message_event_context = EventContext::from_execution_context(context).with_span(
2470 trace_id.to_string(),
2471 Uuid::now_v7().to_string(),
2472 Some(reason_span_id.to_string()),
2473 );
2474 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2475 if let Some(ref u) = usage {
2476 output_message_data = output_message_data.with_usage(u.clone());
2477 }
2478 self.event_emitter
2479 .emit(EventRequest::new(
2480 session_id,
2481 message_event_context,
2482 output_message_data,
2483 ))
2484 .await?;
2485
2486 tracing::info!(
2487 session_id = %session_id,
2488 turn_id = %context.turn_id,
2489 has_tool_calls = %has_tool_calls,
2490 tool_count = %tool_calls.len(),
2491 "ReasonAtom: LLM call completed"
2492 );
2493
2494 Ok(ReasonResult {
2495 success: true,
2496 text,
2497 tool_calls,
2498 has_tool_calls,
2499 tool_definitions: runtime_agent.tools.clone(),
2500 max_iterations: runtime_agent.max_iterations,
2501 error: None,
2502 user_facing_error: None,
2503 error_disclosure: None,
2504 usage,
2505 output_message_id: Some(output_message_id),
2506 time_to_first_token_ms,
2507 response_id,
2508 finish_reason,
2509 locale: resolved_locale,
2510 network_access: runtime_agent.network_access.clone(),
2511 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2512 })
2513 }
2514
2515 async fn finalize_partial_stream(
2520 &self,
2521 session_id: SessionId,
2522 context: &ExecutionContext,
2523 partial: PartialStreamState,
2524 iteration: u32,
2525 runtime_agent: &crate::RuntimeAgent,
2526 resolved_capability_configs: &[crate::CapabilityRef],
2527 ) -> Result<ReasonResult> {
2528 let event_context = EventContext::from_execution_context(context);
2529 let turn_id = context.turn_id;
2530 let message_id = partial.message_id;
2531
2532 let _ = self
2534 .event_emitter
2535 .emit(EventRequest::new(
2536 session_id,
2537 event_context.clone(),
2538 OutputMessageStartedData {
2539 reasoning_state: partial.reasoning_state.clone(),
2540 turn_id,
2541 message_id,
2542 model: None,
2543 iteration: Some(iteration),
2544 phase: None,
2547 },
2548 ))
2549 .await;
2550
2551 let accumulated = filter_response_text(
2554 &self.capability_registry,
2555 resolved_capability_configs,
2556 partial.accumulated,
2557 );
2558 let mut assistant_message = Message::assistant(&accumulated).with_id(message_id);
2559 if let Some(state) = partial.reasoning_state {
2560 assistant_message.metadata = Some(HashMap::from([
2561 ("model".into(), serde_json::json!("gpt-6-astra")),
2562 ("provider".into(), serde_json::json!("openai")),
2563 (
2564 reasoning_updates::STATE_KEY.into(),
2565 serde_json::json!(state),
2566 ),
2567 (
2568 "reasoning_effort".into(),
2569 serde_json::json!(state.effective),
2570 ),
2571 ]));
2572 }
2573 let output_message_id = message_id;
2574 self.event_emitter
2575 .emit(EventRequest::new(
2576 session_id,
2577 event_context.clone(),
2578 OutputMessageCompletedData::new(assistant_message),
2579 ))
2580 .await?;
2581
2582 let accumulated_len = accumulated.len();
2584 let _ = self
2585 .event_emitter
2586 .emit(EventRequest::new(
2587 session_id,
2588 event_context.clone(),
2589 ReasonRecoveredData {
2590 turn_id,
2591 mode: RecoveryMode::Finalize,
2592 accumulated_len,
2593 },
2594 ))
2595 .await;
2596
2597 tracing::info!(
2598 session_id = %session_id,
2599 turn_id = %turn_id,
2600 accumulated_len,
2601 "ReasonAtom: finalized partial stream from persisted accumulated text"
2602 );
2603
2604 Ok(ReasonResult {
2605 success: true,
2606 text: accumulated,
2607 tool_calls: vec![],
2608 has_tool_calls: false,
2609 tool_definitions: runtime_agent.tools.clone(),
2610 max_iterations: runtime_agent.max_iterations,
2611 error: None,
2612 user_facing_error: None,
2613 error_disclosure: None,
2614 usage: None,
2615 output_message_id: Some(output_message_id),
2616 time_to_first_token_ms: None,
2617 response_id: None,
2618 finish_reason: Some("stop".to_string()),
2619 locale: None,
2620 network_access: None,
2621 parallel_tool_calls: None,
2623 })
2624 }
2625
2626 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2637 let mut resolved = HashMap::new();
2638
2639 let resolver = match &self.image_resolver {
2641 Some(r) => r,
2642 None => return resolved,
2643 };
2644
2645 let image_ids: Vec<Uuid> = messages
2647 .iter()
2648 .flat_map(crate::llm_conversions::extract_image_file_ids)
2649 .collect::<std::collections::HashSet<_>>()
2650 .into_iter()
2651 .collect();
2652
2653 if image_ids.is_empty() {
2654 return resolved;
2655 }
2656
2657 tracing::debug!(
2658 image_count = image_ids.len(),
2659 "ReasonAtom: resolving image_file references"
2660 );
2661
2662 for image_id in image_ids {
2664 match resolver.resolve_image(image_id).await {
2665 Ok(Some(image)) => {
2666 resolved.insert(image_id, image);
2667 }
2668 Ok(None) => {
2669 tracing::warn!(
2670 image_id = %image_id,
2671 "ReasonAtom: image not found during resolution"
2672 );
2673 }
2674 Err(e) => {
2675 tracing::warn!(
2676 image_id = %image_id,
2677 error = %e,
2678 "ReasonAtom: failed to resolve image"
2679 );
2680 }
2681 }
2682 }
2683
2684 tracing::debug!(
2685 resolved_count = resolved.len(),
2686 "ReasonAtom: image resolution complete"
2687 );
2688
2689 resolved
2690 }
2691}
2692
2693#[cfg(test)]
2698mod tests;