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 if let Some(context) = runtime_agent.conversation_context.as_ref()
1112 && !context.is_empty()
1113 {
1114 context_messages.insert(0, Message::user(context.clone()));
1115 }
1116
1117 let resolved_images = self.resolve_images(&context_messages).await;
1122
1123 let mut llm_messages = Vec::new();
1125
1126 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1128 if has_system_prompt {
1129 llm_messages.push(LlmMessage {
1130 role: LlmMessageRole::System,
1131 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1132 tool_calls: None,
1133 tool_call_id: None,
1134 phase: None,
1135 reasoning: Vec::new(),
1136 configuration_update: None,
1137 });
1138 }
1139
1140 let messages_for_event: Vec<Message> = if has_system_prompt {
1142 std::iter::once(Message::system(&runtime_agent.system_prompt))
1143 .chain(context_messages.iter().cloned())
1144 .collect()
1145 } else {
1146 context_messages.clone()
1147 };
1148
1149 let mut stripped_error_count = 0u32;
1155 for msg in &context_messages {
1156 if is_error_placeholder_message(msg) {
1157 stripped_error_count += 1;
1158 continue;
1159 }
1160 let mut llm_msg =
1161 crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1162 llm_msg.configuration_update = reasoning_replay
1163 .as_ref()
1164 .and_then(|replay| replay.transitions.get(&msg.id).copied());
1165 if msg.role == MessageRole::User
1166 && let Some(ref actor) = msg.external_actor
1167 {
1168 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1169 }
1170 llm_messages.push(llm_msg);
1171 }
1172 if stripped_error_count > 0 {
1173 tracing::info!(
1174 session_id = %session_id,
1175 stripped_error_count,
1176 "ReasonAtom: stripped error placeholder messages from LLM input"
1177 );
1178 }
1179
1180 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1185 llm_messages,
1186 stateful_response_continuation || restored_checkpoint.is_some(),
1187 );
1188
1189 let mut llm_config_builder =
1191 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1192 if let Some(effort) = reasoning_effort {
1193 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1194 }
1195 if let Some(speed) = speed {
1196 llm_config_builder = llm_config_builder.speed(speed);
1197 }
1198 if let Some(verbosity) = verbosity {
1199 llm_config_builder = llm_config_builder.verbosity(verbosity);
1200 }
1201
1202 for (k, v) in &embedder_metadata {
1204 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1205 }
1206
1207 llm_config_builder = llm_config_builder
1211 .with_metadata("session_id", session_id.to_string())
1212 .with_metadata("harness_id", harness_id.to_string())
1213 .with_metadata("turn_id", context.turn_id.to_string())
1214 .with_metadata("exec_id", context.exec_id.to_string())
1215 .with_metadata("org_id", format!("org_{:032x}", org_id));
1216 if let Some(agent_id) = agent_id {
1217 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1218 }
1219
1220 if let Some(model_id) = &resolved_model_id {
1222 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1223 }
1224
1225 let mut llm_config = llm_config_builder
1226 .previous_response_id(previous_response_id.clone())
1227 .volatile_suffix_len(volatile_suffix_len)
1228 .build();
1229 if let Some(replay) = &reasoning_replay {
1230 llm_config.reasoning_effort = replay.state.baseline;
1231 llm_config.reasoning_state = Some(replay.state.clone());
1232 if replay.reset_continuation {
1233 llm_config.previous_response_id = None;
1234 }
1235 } else if messages
1236 .iter()
1237 .rev()
1238 .find(|message| {
1239 message.role == MessageRole::Agent && !is_error_placeholder_message(message)
1240 })
1241 .and_then(|message| message.metadata.as_ref())
1242 .is_some_and(|metadata| metadata.contains_key(reasoning_updates::STATE_KEY))
1243 {
1244 llm_config.previous_response_id = None;
1247 }
1248 if let Some(checkpoint) = restored_checkpoint.as_ref()
1249 && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1250 &checkpoint.payload
1251 {
1252 llm_config.previous_response_id = None;
1253 llm_config.provider_opaque_context = Some(context.clone());
1254 }
1255
1256 tracing::debug!(
1257 session_id = %session_id,
1258 turn_id = %context.turn_id,
1259 model = %runtime_agent.model,
1260 message_count = %llm_messages.len(),
1261 "ReasonAtom: calling LLM"
1262 );
1263
1264 let streaming_event_context = EventContext::from_execution_context(context);
1267
1268 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1275 for (cap_id, cfg, provider) in &guardrail_providers {
1276 let ctx = OutputGuardrailContext {
1277 system_prompt: &runtime_agent.system_prompt,
1278 config: cfg,
1279 };
1280 let guardrail_id = provider.id().to_string();
1281 if let Some(run) = provider.arm(&ctx) {
1282 armed_guardrails.push(ArmedGuardrail {
1283 capability_id: cap_id.clone(),
1284 guardrail_id,
1285 run,
1286 });
1287 }
1288 }
1289 let buffer_output_deltas = !post_output_providers.is_empty();
1294 let output_message_id = MessageId::new();
1298 tracing::info!(
1299 session_id = %session_id,
1300 turn_id = %context.turn_id,
1301 "ReasonAtom: emitting output.message.started event"
1302 );
1303 if let Err(e) = self
1304 .event_emitter
1305 .emit(EventRequest::new(
1306 session_id,
1307 streaming_event_context.clone(),
1308 OutputMessageStartedData {
1309 reasoning_state: llm_config.reasoning_state.clone(),
1310 turn_id: context.turn_id,
1311 message_id: output_message_id,
1312 model: Some(runtime_agent.model.clone()),
1313 iteration: Some(iteration),
1314 phase: None,
1317 },
1318 ))
1319 .await
1320 {
1321 if llm_config.reasoning_state.is_some() {
1322 return Err(e);
1323 }
1324 tracing::warn!(
1325 session_id = %session_id,
1326 error = %e,
1327 "ReasonAtom: failed to emit output.message.started event"
1328 );
1329 } else {
1330 tracing::info!(
1331 session_id = %session_id,
1332 "ReasonAtom: output.message.started event emitted successfully"
1333 );
1334 }
1335
1336 let thinking_enabled = reasoning_effort.is_some();
1338 if thinking_enabled {
1339 tracing::info!(
1340 session_id = %session_id,
1341 turn_id = %context.turn_id,
1342 "ReasonAtom: emitting reason.thinking.started event"
1343 );
1344 if let Err(e) = self
1345 .event_emitter
1346 .emit(EventRequest::new(
1347 session_id,
1348 streaming_event_context.clone(),
1349 ReasonThinkingStartedData {
1350 turn_id: context.turn_id,
1351 model: Some(runtime_agent.model.clone()),
1352 },
1353 ))
1354 .await
1355 {
1356 tracing::warn!(
1357 session_id = %session_id,
1358 error = %e,
1359 "ReasonAtom: failed to emit reason.thinking.started event"
1360 );
1361 } else {
1362 tracing::info!(
1363 session_id = %session_id,
1364 "ReasonAtom: reason.thinking.started event emitted successfully"
1365 );
1366 }
1367 }
1368
1369 let llm_start = Instant::now();
1371
1372 let mut compaction_info: Option<LlmCompactionInfo> = None;
1376 let mut llm_messages_for_call = llm_messages.clone();
1377
1378 if let Some(policy) = compaction_policy.as_deref() {
1379 compaction_info = apply_proactive_compaction(
1380 ProactiveCompactionContext {
1381 chat_driver: chat_driver.as_ref(),
1382 policy,
1383 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1384 event_emitter: self.event_emitter.as_ref(),
1385 event_context: &streaming_event_context,
1386 session_id,
1387 message_source_sequence,
1388 provider_type: model_with_provider.provider_type.as_str(),
1389 model: &model_with_provider.model,
1390 system_prompt: has_system_prompt
1391 .then_some(runtime_agent.system_prompt.as_str()),
1392 stateful_response_continuation,
1393 checkpoint_restored: restored_checkpoint.is_some(),
1394 checkpoint_suffix_message_count,
1395 raw_tool_result_bytes,
1396 prior_usage: prior_usage.as_ref(),
1397 },
1398 &mut llm_messages_for_call,
1399 &mut llm_config,
1400 )
1401 .await?;
1402 }
1403
1404 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1407 let retry_config = self.provider_retry_config.clone();
1408 let has_provider_executed_tools = llm_config
1412 .openrouter_routing
1413 .as_ref()
1414 .is_some_and(|routing| !routing.server_tools.is_empty());
1415 let mut stream_retry_metadata = RetryMetadata::default();
1416 let mut retry_started_at = None;
1417 let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1422 let (
1423 text,
1424 thinking,
1425 reasoning,
1426 tool_calls,
1427 completion_metadata,
1428 time_to_first_token_ms,
1429 pending_delta,
1430 mut tripped,
1431 ) = 'stream_attempt: loop {
1432 let stream_result = if let Some(remaining) =
1433 remaining_retry_time(&retry_config, retry_started_at)
1434 {
1435 match tokio::time::timeout(
1436 remaining,
1437 chat_driver.chat_completion_stream(
1438 &crate::ProviderEndpoint::default(),
1439 llm_messages_for_call.clone(),
1440 &llm_config,
1441 ),
1442 )
1443 .await
1444 {
1445 Ok(result) => result,
1446 Err(_) => {
1447 return Err(AgentLoopError::llm_kind(
1448 crate::error::LlmErrorKind::Unavailable,
1449 format!(
1450 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1451 stream_retry_metadata.attempts,
1452 retry_config.max_retry_elapsed.as_secs_f64()
1453 ),
1454 )
1455 .with_retry_metadata(&stream_retry_metadata));
1456 }
1457 }
1458 } else {
1459 chat_driver
1460 .chat_completion_stream(
1461 &crate::ProviderEndpoint::default(),
1462 llm_messages_for_call.clone(),
1463 &llm_config,
1464 )
1465 .await
1466 };
1467 let mut stream = match stream_result {
1468 Ok(stream) => stream,
1469 Err(e) if e.is_request_too_large() => {
1470 let Some(policy) = compaction_policy.as_deref() else {
1471 tracing::warn!(
1472 session_id = %session_id,
1473 turn_id = %context.turn_id,
1474 "ReasonAtom: context too large and compaction capability is not enabled"
1475 );
1476 return Err(e);
1477 };
1478 let outcome = apply_reactive_compaction(
1479 ReactiveCompactionContext {
1480 chat_driver: chat_driver.as_ref(),
1481 policy,
1482 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1483 event_emitter: self.event_emitter.as_ref(),
1484 event_context: &streaming_event_context,
1485 session_id,
1486 message_source_sequence,
1487 provider_type: model_with_provider.provider_type.as_str(),
1488 model: &model_with_provider.model,
1489 summarization_model_fallback: &runtime_agent.model,
1490 system_prompt: has_system_prompt
1491 .then_some(runtime_agent.system_prompt.as_str()),
1492 stateful_response_continuation,
1493 },
1494 &mut llm_messages_for_call,
1495 &mut llm_config,
1496 )
1497 .await?;
1498 let Some(outcome) = outcome else {
1499 return Err(e);
1500 };
1501 if outcome.generation_info.is_some() {
1502 compaction_info = outcome.generation_info;
1503 }
1504
1505 chat_driver
1506 .chat_completion_stream(
1507 &crate::ProviderEndpoint::default(),
1508 llm_messages_for_call.clone(),
1509 &llm_config,
1510 )
1511 .await?
1512 }
1513 Err(e)
1514 if e.is_transient_llm_error()
1515 && !e.llm_retry_handled()
1516 && !has_provider_executed_tools
1517 && stream_retry_metadata.attempts < retry_config.max_retries =>
1518 {
1519 let proposed_wait =
1520 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1521 let Some(wait_duration) =
1522 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1523 else {
1524 return Err(AgentLoopError::llm_kind(
1525 e.llm_error_kind()
1526 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1527 format!(
1528 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1529 stream_retry_metadata.attempts
1530 ),
1531 )
1532 .with_retry_metadata(&stream_retry_metadata));
1533 };
1534 tracing::warn!(
1535 session_id = %session_id,
1536 turn_id = %context.turn_id,
1537 attempt = stream_retry_metadata.attempts + 1,
1538 max_retries = retry_config.max_retries,
1539 wait_secs = wait_duration.as_secs_f64(),
1540 error = %e,
1541 "ReasonAtom: transient provider failure before stream, retrying"
1542 );
1543 stream_retry_metadata.record_retry(wait_duration, None);
1544 tokio::time::sleep(wait_duration).await;
1545 continue 'stream_attempt;
1546 }
1547 Err(e) => return Err(e),
1548 };
1549
1550 let mut text = String::new();
1551 let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1555 let mut thinking = String::new();
1557 let mut tool_calls = Vec::new();
1558 let mut termination = StreamTermination::Exhausted;
1559 let mut replay_state = StreamReplayState::for_request(has_provider_executed_tools);
1560 let mut pending_delta = String::new();
1561 let mut pending_thinking_delta = String::new();
1562 let mut last_delta_emit = Instant::now();
1563 let mut last_thinking_delta_emit = Instant::now();
1564 let mut time_to_first_token_ms: Option<u64> = None;
1565
1566 let stall_timeout = self
1568 .provider_stall_timeout
1569 .unwrap_or(std::time::Duration::from_secs(120));
1570 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1571 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1572 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1573 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1574 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1575 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1577 let mut last_token_at_unix: u64 = unix_now_secs();
1582
1583 loop {
1584 let event = tokio::select! {
1585 biased;
1586 next = stream.next() => match next {
1587 Some(e) => e,
1588 None => break,
1589 },
1590 _ = &mut stall_sleep => {
1591 let stall_error =
1601 crate::driver_registry::LlmStreamError::new(format!(
1602 "provider stream stall: no tokens for {}s",
1603 stall_timeout.as_secs()
1604 ));
1605 tracing::warn!(
1606 session_id = %session_id,
1607 turn_id = %context.turn_id,
1608 stall_secs = stall_timeout.as_secs(),
1609 "ReasonAtom: provider stream stall timeout"
1610 );
1611 if replay_state.should_retry(
1612 &stall_error,
1613 stream_retry_metadata.attempts,
1614 retry_config.max_retries,
1615 ) {
1616 let proposed_wait = retry_config
1617 .calculate_backoff(stream_retry_metadata.attempts);
1618 let Some(wait_duration) = reserve_retry_wait(
1619 &retry_config,
1620 &mut retry_started_at,
1621 proposed_wait,
1622 ) else {
1623 return Err(AgentLoopError::llm_kind(
1624 crate::error::LlmErrorKind::Unavailable,
1625 format!(
1626 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1627 stall_error.message,
1628 stream_retry_metadata.attempts
1629 ),
1630 )
1631 .with_retry_metadata(&stream_retry_metadata));
1632 };
1633 tracing::warn!(
1634 session_id = %session_id,
1635 turn_id = %context.turn_id,
1636 attempt = stream_retry_metadata.attempts + 1,
1637 max_retries = retry_config.max_retries,
1638 wait_secs = wait_duration.as_secs_f64(),
1639 "ReasonAtom: provider stream stall, retrying"
1640 );
1641 stream_retry_metadata.record_retry(wait_duration, None);
1642 tokio::time::sleep(wait_duration).await;
1643 continue 'stream_attempt;
1644 }
1645 return Err(AgentLoopError::llm(stall_error.message));
1646 },
1647 _ = keepalive_ticker.tick() => {
1648 if let Some(ref hb) = self.stream_heartbeater {
1649 hb.heartbeat(crate::durability::StreamProgress {
1650 accumulated_len: text.len() + thinking.len(),
1651 last_delta_at: last_token_at_unix,
1652 })
1653 .await;
1654 last_stream_heartbeat = Instant::now();
1655 }
1656 continue;
1657 },
1658 };
1659 let event = event?;
1660 replay_state.observe(&event);
1661 let advanced_stall_deadline = advances_stall_deadline(&event);
1662 if advanced_stall_deadline {
1663 stall_sleep
1664 .as_mut()
1665 .reset(tokio::time::Instant::now() + stall_timeout);
1666 last_token_at_unix = unix_now_secs();
1667 }
1668 match event {
1669 LlmStreamEvent::TextDelta(delta) => {
1670 if delta.is_empty() {
1671 continue;
1672 }
1673 if time_to_first_token_ms.is_none() {
1675 let ttft = llm_start.elapsed().as_millis() as u64;
1676 time_to_first_token_ms = Some(ttft);
1677 tracing::info!(
1678 session_id = %session_id,
1679 time_to_first_token_ms = ttft,
1680 "ReasonAtom: received first token from LLM"
1681 );
1682 }
1683 text.push_str(&delta);
1684 pending_delta.push_str(&delta);
1685
1686 if !armed_guardrails.is_empty()
1693 && let Some(t) =
1694 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1695 {
1696 tracing::warn!(
1697 session_id = %session_id,
1698 turn_id = %context.turn_id,
1699 guardrail_capability_id = %t.capability_id,
1700 guardrail_id = %t.guardrail_id,
1701 reason_code = %t.block.reason_code,
1702 "ReasonAtom: output guardrail tripped, replacing assistant message"
1703 );
1704 pending_delta.clear();
1705 termination = StreamTermination::GuardrailBlocked(t);
1706 break;
1707 }
1708
1709 if !buffer_output_deltas
1711 && last_delta_emit.elapsed().as_millis() as u64
1712 >= DELTA_BATCH_INTERVAL_MS
1713 && !pending_delta.is_empty()
1714 {
1715 if let Err(e) = self
1716 .event_emitter
1717 .emit(EventRequest::new(
1718 session_id,
1719 streaming_event_context.clone(),
1720 OutputMessageDeltaData {
1721 turn_id: context.turn_id,
1722 message_id: output_message_id,
1723 delta: pending_delta.clone(),
1724 accumulated: text.clone(),
1725 phase: streamed_phase,
1726 },
1727 ))
1728 .await
1729 {
1730 tracing::warn!(
1731 session_id = %session_id,
1732 error = %e,
1733 "ReasonAtom: failed to emit output.message.delta event"
1734 );
1735 }
1736 pending_delta.clear();
1737 last_delta_emit = Instant::now();
1738 }
1739 }
1740 LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1741 if delta.is_empty() {
1742 continue;
1743 }
1744 if let Some(t) = append_guarded_thinking_delta(
1745 &mut armed_guardrails,
1746 &mut thinking,
1747 &mut pending_thinking_delta,
1748 &delta,
1749 ) {
1750 tracing::warn!(
1751 session_id = %session_id,
1752 guardrail_capability_id = %t.capability_id,
1753 guardrail_id = %t.guardrail_id,
1754 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1755 );
1756 termination = StreamTermination::GuardrailBlocked(t);
1757 break;
1758 }
1759 tracing::debug!(
1760 session_id = %session_id,
1761 delta_len = delta.len(),
1762 total_thinking_len = thinking.len(),
1763 "ReasonAtom: received ThinkingDelta from LLM"
1764 );
1765
1766 if last_thinking_delta_emit.elapsed().as_millis() as u64
1768 >= DELTA_BATCH_INTERVAL_MS
1769 && !pending_thinking_delta.is_empty()
1770 {
1771 if let Err(e) = self
1772 .event_emitter
1773 .emit(EventRequest::new(
1774 session_id,
1775 streaming_event_context.clone(),
1776 ReasonThinkingDeltaData {
1777 turn_id: context.turn_id,
1778 delta: pending_thinking_delta.clone(),
1779 accumulated: thinking.clone(),
1780 },
1781 ))
1782 .await
1783 {
1784 tracing::warn!(
1785 session_id = %session_id,
1786 error = %e,
1787 "ReasonAtom: failed to emit reason.thinking.delta event"
1788 );
1789 }
1790 pending_thinking_delta.clear();
1791 last_thinking_delta_emit = Instant::now();
1792 }
1793 }
1794 LlmStreamEvent::ReasoningItem(item) => {
1795 if let Some(t) = inspect_guarded_reasoning_item(
1796 &mut armed_guardrails,
1797 &mut thinking,
1798 &item,
1799 ) {
1800 tracing::warn!(
1801 session_id = %session_id,
1802 guardrail_capability_id = %t.capability_id,
1803 guardrail_id = %t.guardrail_id,
1804 "ReasonAtom: output guardrail tripped on completed reasoning item, replacing assistant message"
1805 );
1806 termination = StreamTermination::GuardrailBlocked(t);
1807 break;
1808 }
1809 tracing::debug!(
1813 session_id = %session_id,
1814 provider = %item.provider,
1815 item_id = ?item.item_id,
1816 has_signature = item.signature.is_some(),
1817 has_encrypted = item.encrypted.is_some(),
1818 "ReasonAtom: captured reasoning artifact"
1819 );
1820 reasoning.push(item);
1821 }
1822 LlmStreamEvent::ToolCalls(calls) => {
1823 tool_calls = calls;
1824 }
1825 LlmStreamEvent::MessagePhase(phase) => {
1826 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1835 streamed_phase,
1836 phase,
1837 );
1838 }
1839 LlmStreamEvent::Done(metadata) => {
1840 if !buffer_output_deltas
1844 && !pending_delta.is_empty()
1845 && let Err(e) = self
1846 .event_emitter
1847 .emit(EventRequest::new(
1848 session_id,
1849 streaming_event_context.clone(),
1850 OutputMessageDeltaData {
1851 turn_id: context.turn_id,
1852 message_id: output_message_id,
1853 delta: pending_delta.clone(),
1854 accumulated: text.clone(),
1855 phase: streamed_phase,
1856 },
1857 ))
1858 .await
1859 {
1860 tracing::warn!(
1861 session_id = %session_id,
1862 error = %e,
1863 "ReasonAtom: failed to emit final output.message.delta event"
1864 );
1865 }
1866
1867 if !pending_thinking_delta.is_empty()
1869 && let Err(e) = self
1870 .event_emitter
1871 .emit(EventRequest::new(
1872 session_id,
1873 streaming_event_context.clone(),
1874 ReasonThinkingDeltaData {
1875 turn_id: context.turn_id,
1876 delta: pending_thinking_delta.clone(),
1877 accumulated: thinking.clone(),
1878 },
1879 ))
1880 .await
1881 {
1882 tracing::warn!(
1883 session_id = %session_id,
1884 error = %e,
1885 "ReasonAtom: failed to emit final reason.thinking.delta event"
1886 );
1887 }
1888
1889 if !thinking.is_empty()
1891 && let Err(e) = self
1892 .event_emitter
1893 .emit(EventRequest::new(
1894 session_id,
1895 streaming_event_context.clone(),
1896 ReasonThinkingCompletedData {
1897 turn_id: context.turn_id,
1898 thinking: thinking.clone(),
1899 },
1900 ))
1901 .await
1902 {
1903 tracing::warn!(
1904 session_id = %session_id,
1905 error = %e,
1906 "ReasonAtom: failed to emit reason.thinking.completed event"
1907 );
1908 }
1909 termination = StreamTermination::Completed(metadata);
1910 break;
1911 }
1912 LlmStreamEvent::Error(err) => {
1913 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
1918
1919 if has_partial_output {
1920 tracing::warn!(
1921 session_id = %session_id,
1922 error = %err,
1923 tool_call_count = tool_calls.len(),
1924 text_len = text.len(),
1925 "ReasonAtom: trailing stream error after valid output — treating as partial success"
1926 );
1927 termination = StreamTermination::PartialSuccess;
1931 break;
1932 }
1933
1934 if replay_state.should_retry(
1935 &err,
1936 stream_retry_metadata.attempts,
1937 retry_config.max_retries,
1938 ) {
1939 let proposed_wait =
1940 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1941 let Some(wait_duration) = reserve_retry_wait(
1942 &retry_config,
1943 &mut retry_started_at,
1944 proposed_wait,
1945 ) else {
1946 return Err(AgentLoopError::llm_kind(
1947 err.kind(),
1948 format!(
1949 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1950 stream_retry_metadata.attempts
1951 ),
1952 )
1953 .with_retry_metadata(&stream_retry_metadata));
1954 };
1955 tracing::warn!(
1956 session_id = %session_id,
1957 turn_id = %context.turn_id,
1958 attempt = stream_retry_metadata.attempts + 1,
1959 max_retries = retry_config.max_retries,
1960 wait_secs = wait_duration.as_secs_f64(),
1961 error_code = err.code.as_deref().unwrap_or("none"),
1962 error_status = err.status,
1963 error = %err,
1964 "ReasonAtom: transient stream error before output, retrying"
1965 );
1966 stream_retry_metadata.record_retry(wait_duration, None);
1967 tokio::time::sleep(wait_duration).await;
1968 continue 'stream_attempt;
1969 }
1970
1971 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
1973 let event_context = EventContext::from_execution_context(context)
1974 .with_span(
1975 trace_id.to_string(),
1976 Uuid::now_v7().to_string(),
1977 Some(reason_span_id.to_string()),
1978 );
1979 let tools_summary: Vec<ToolDefinitionSummary> =
1980 runtime_agent.tools.iter().map(|t| t.into()).collect();
1981 let generation_data = LlmGenerationData::failure(
1982 messages_for_event.clone(),
1983 tools_summary,
1984 runtime_agent.model.clone(),
1985 Some(model_with_provider.provider_type.to_string()),
1986 err.to_string(),
1987 Some(llm_duration_ms),
1988 time_to_first_token_ms,
1989 );
1990 let _ = self
1991 .event_emitter
1992 .emit(EventRequest::new(
1993 session_id,
1994 event_context,
1995 generation_data,
1996 ))
1997 .await;
1998 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
1999 }
2000 }
2001 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2004 && let Some(ref hb) = self.stream_heartbeater
2005 {
2006 hb.heartbeat(crate::durability::StreamProgress {
2007 accumulated_len: text.len() + thinking.len(),
2008 last_delta_at: last_token_at_unix,
2009 })
2010 .await;
2011 last_stream_heartbeat = Instant::now();
2012 }
2013 }
2014 let (mut completion_metadata, tripped) = termination.into_parts();
2015 if let Some(metadata) = completion_metadata.as_mut() {
2016 metadata.retry_metadata =
2017 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2018 }
2019
2020 break 'stream_attempt (
2021 text,
2022 thinking,
2023 reasoning,
2024 tool_calls,
2025 completion_metadata,
2026 time_to_first_token_ms,
2027 pending_delta,
2028 tripped,
2029 );
2030 };
2031 let (mut text, mut thinking, mut reasoning, mut tool_calls) =
2032 (text, thinking, reasoning, tool_calls);
2033
2034 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
2045 if tripped.is_none()
2046 && !annotation_providers.is_empty()
2047 && !text.is_empty()
2048 && tool_calls.is_empty()
2049 {
2050 text = filter_response_text(
2053 &self.capability_registry,
2054 &resolved_capability_configs,
2055 text,
2056 );
2057 let collected = collect_annotations(
2058 &annotation_providers,
2059 &runtime_agent.system_prompt,
2060 &text,
2061 &messages,
2062 self.utility_llm_service.as_ref(),
2063 )
2064 .await;
2065 text = collected.text;
2066 citation_annotations = collected.annotations;
2067
2068 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
2071 let guarded_output = client_visible_guardrail_text(
2072 &text,
2073 &thinking,
2074 &reasoning,
2075 &citation_annotations,
2076 );
2077 let ctx = PostGenerationOutputContext {
2078 system_prompt: &runtime_agent.system_prompt,
2079 message_text: &guarded_output,
2080 utility_llm_service: self.utility_llm_service.as_ref(),
2081 };
2082 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2083 }
2084
2085 if tripped.is_none()
2088 && !citation_annotations.is_empty()
2089 && !citation_verifiers.is_empty()
2090 {
2091 citation_annotations = verify_annotations(
2092 &citation_verifiers,
2093 &text,
2094 self.utility_llm_service.as_ref(),
2095 citation_annotations,
2096 )
2097 .await;
2098 }
2099 }
2100
2101 if tripped.is_none()
2104 && citation_annotations.is_empty()
2105 && !post_output_providers.is_empty()
2106 && (!text.is_empty() || !thinking.is_empty() || !reasoning.is_empty())
2107 {
2108 let guarded_output = client_visible_guardrail_text(&text, &thinking, &reasoning, &[]);
2109 let ctx = PostGenerationOutputContext {
2110 system_prompt: &runtime_agent.system_prompt,
2111 message_text: &guarded_output,
2112 utility_llm_service: self.utility_llm_service.as_ref(),
2113 };
2114 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2115 }
2116
2117 if tripped.is_some() {
2118 citation_annotations.clear();
2119 }
2120
2121 if tripped.is_none() {
2125 for item in &reasoning {
2126 if let Err(e) = self
2127 .event_emitter
2128 .emit(EventRequest::new(
2129 session_id,
2130 streaming_event_context.clone(),
2131 ReasonItemData {
2132 turn_id: context.turn_id,
2133 provider: item.provider.clone(),
2134 model: Some(llm_config.model.clone()),
2135 item_id: item.item_id.clone().unwrap_or_default(),
2136 summary: item
2137 .display_text()
2138 .filter(|_| !matches!(item.text, Some(ReasoningText::Plain { .. })))
2139 .into_iter()
2140 .collect(),
2141 token_count: item.tokens,
2142 },
2143 ))
2144 .await
2145 {
2146 tracing::warn!(
2147 session_id = %session_id,
2148 error = %e,
2149 "ReasonAtom: failed to emit reason.item event"
2150 );
2151 }
2152 }
2153 }
2154
2155 if buffer_output_deltas
2158 && tripped.is_none()
2159 && !pending_delta.is_empty()
2160 && let Err(e) = self
2161 .event_emitter
2162 .emit(EventRequest::new(
2163 session_id,
2164 streaming_event_context.clone(),
2165 OutputMessageDeltaData {
2166 turn_id: context.turn_id,
2167 message_id: output_message_id,
2168 delta: pending_delta.clone(),
2169 accumulated: text.clone(),
2170 phase: streamed_phase,
2171 },
2172 ))
2173 .await
2174 {
2175 tracing::warn!(
2176 session_id = %session_id,
2177 error = %e,
2178 "ReasonAtom: failed to emit guarded output.message.delta event"
2179 );
2180 }
2181
2182 if let Some(ref t) = tripped {
2188 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2189 trace_id.to_string(),
2190 Uuid::now_v7().to_string(),
2191 Some(reason_span_id.to_string()),
2192 );
2193 if let Err(e) = self
2194 .event_emitter
2195 .emit(EventRequest::new(
2196 session_id,
2197 replaced_event_context,
2198 OutputMessageReplacedData {
2199 turn_id: context.turn_id,
2200 message_id: output_message_id,
2201 guardrail_capability_id: t.capability_id.clone(),
2202 guardrail_id: t.guardrail_id.clone(),
2203 reason_code: t.block.reason_code.clone(),
2204 replacement: t.block.replacement.clone(),
2205 },
2206 ))
2207 .await
2208 {
2209 tracing::warn!(
2210 session_id = %session_id,
2211 error = %e,
2212 "ReasonAtom: failed to emit output.message.replaced event"
2213 );
2214 }
2215 text = t.block.replacement.clone();
2216 tool_calls.clear();
2217 thinking.clear();
2218 reasoning.clear();
2219 }
2220
2221 if !tool_calls.is_empty() {
2225 self.apply_finalized_tool_call_hooks(
2226 session_id,
2227 context,
2228 &resolved_capability_configs,
2229 &runtime_agent.tools,
2230 &mut tool_calls,
2231 iteration,
2232 )
2233 .await;
2234 }
2235
2236 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2237
2238 let response_id = completion_metadata
2240 .as_ref()
2241 .and_then(|meta| meta.response_id.clone());
2242 let finish_reason = completion_metadata
2243 .as_ref()
2244 .and_then(|meta| meta.finish_reason.clone());
2245
2246 let usage = completion_metadata.as_ref().and_then(|meta| {
2254 match (meta.prompt_tokens, meta.completion_tokens) {
2255 (Some(input), Some(output)) => {
2256 let actual_cost_usd = meta.provider_cost_usd;
2257 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2258 &model_with_provider.provider_type,
2259 &runtime_agent.model,
2260 input,
2261 output,
2262 meta.cache_read_tokens.unwrap_or(0),
2263 meta.cache_creation_tokens.unwrap_or(0),
2264 );
2265 Some(
2266 TokenUsage::with_cache(
2267 input,
2268 output,
2269 meta.cache_read_tokens,
2270 meta.cache_creation_tokens,
2271 )
2272 .with_cost(actual_cost_usd, estimated_cost_usd),
2273 )
2274 }
2275 _ => None,
2276 }
2277 });
2278
2279 let event_context = EventContext::from_execution_context(context).with_span(
2281 trace_id.to_string(),
2282 Uuid::now_v7().to_string(),
2283 Some(reason_span_id.to_string()),
2284 );
2285 let tools_summary: Vec<ToolDefinitionSummary> =
2286 runtime_agent.tools.iter().map(|t| t.into()).collect();
2287 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2288 if tool_calls.is_empty() {
2289 "stop".to_string()
2290 } else {
2291 "tool_calls".to_string()
2292 }
2293 })]);
2294 let retry_info = completion_metadata
2296 .as_ref()
2297 .and_then(|meta| meta.retry_metadata.as_ref())
2298 .filter(|rm| rm.had_retries())
2299 .map(|rm| LlmRetryInfo {
2300 attempts: rm.attempts,
2301 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2302 });
2303 let mut generation_data = LlmGenerationData::success_with_retry(
2305 messages_for_event.clone(),
2306 tools_summary,
2307 Some(text.clone()).filter(|s| !s.is_empty()),
2308 tool_calls.clone(),
2309 runtime_agent.model.clone(),
2310 Some(model_with_provider.provider_type.to_string()),
2311 usage.clone(),
2312 Some(llm_duration_ms),
2313 time_to_first_token_ms,
2314 finish_reasons,
2315 response_id.clone(),
2316 retry_info,
2317 );
2318
2319 if let Some(info) = compaction_info {
2325 if let Some(compaction_cost) = info.cost_usd {
2326 match generation_data.metadata.usage.as_mut() {
2327 Some(usage) => {
2328 add_compaction_cost(usage, compaction_cost);
2329 }
2330 None => {
2335 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2336 input_tokens: 0,
2337 output_tokens: 0,
2338 cache_read_tokens: None,
2339 cache_creation_tokens: None,
2340 actual_cost_usd: Some(compaction_cost),
2341 estimated_cost_usd: None,
2342 effective_cost_usd: None,
2343 });
2344 }
2345 }
2346 }
2347 generation_data = generation_data.with_compaction(info);
2348 }
2349
2350 if let Some(request_options) =
2351 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2352 {
2353 generation_data = generation_data.with_request_options(request_options);
2354 }
2355
2356 if let Err(e) = self
2357 .event_emitter
2358 .emit(EventRequest::new(
2359 session_id,
2360 event_context,
2361 generation_data,
2362 ))
2363 .await
2364 {
2365 tracing::warn!(
2366 session_id = %session_id,
2367 error = %e,
2368 "ReasonAtom: failed to emit llm.generation event"
2369 );
2370 }
2371
2372 let mut metadata = std::collections::HashMap::new();
2374 metadata.insert(
2375 "model".to_string(),
2376 serde_json::Value::String(runtime_agent.model.clone()),
2377 );
2378 if let Some(state) = &llm_config.reasoning_state {
2379 metadata.insert(
2380 reasoning_updates::STATE_KEY.to_string(),
2381 serde_json::json!(state),
2382 );
2383 }
2384 if let Some(effort) = llm_config
2385 .reasoning_state
2386 .as_ref()
2387 .and_then(|state| state.effective)
2388 .or(reasoning_effort)
2389 {
2390 metadata.insert(
2391 "reasoning_effort".to_string(),
2392 serde_json::Value::String(effort.as_str().to_string()),
2393 );
2394 }
2395 metadata.insert(
2402 "provider".to_string(),
2403 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2404 );
2405 if let Some(ref rid) = response_id {
2406 metadata.insert(
2407 "response_id".to_string(),
2408 serde_json::Value::String(rid.clone()),
2409 );
2410 }
2411
2412 let text = filter_response_text(
2416 &self.capability_registry,
2417 &resolved_capability_configs,
2418 text,
2419 );
2420 let has_tool_calls = !tool_calls.is_empty();
2421 let mut assistant_message = if has_tool_calls {
2422 Message::assistant_with_tools(&text, tool_calls.clone())
2423 } else {
2424 Message::assistant(&text)
2425 }
2426 .with_id(output_message_id);
2427 if !citation_annotations.is_empty() {
2430 for part in assistant_message.content.iter_mut() {
2431 if let crate::message::ContentPart::Text(t) = part {
2432 t.annotations = std::mem::take(&mut citation_annotations);
2433 break;
2434 }
2435 }
2436 }
2437 let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2441 let provider_phase = completion_metadata
2445 .as_ref()
2446 .and_then(|meta| meta.phase.as_deref())
2447 .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2448 let (phase, phase_source) = match provider_phase {
2449 Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2450 None => (
2451 everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2452 everruns_provider::PhaseSource::Derived,
2453 ),
2454 };
2455 assistant_message.phase = Some(phase);
2456 assistant_message.phase_source = Some(phase_source);
2457 assistant_message.metadata = Some(metadata);
2458 if reasoning.is_empty() && !thinking.is_empty() {
2467 reasoning.push(
2468 ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2469 ReasoningText::Plain {
2470 text: thinking.clone(),
2471 },
2472 ),
2473 );
2474 }
2475 if !reasoning.is_empty() {
2476 let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2477 content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2478 content.append(&mut assistant_message.content);
2479 assistant_message.content = content;
2480 }
2481 let message_event_context = EventContext::from_execution_context(context).with_span(
2484 trace_id.to_string(),
2485 Uuid::now_v7().to_string(),
2486 Some(reason_span_id.to_string()),
2487 );
2488 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2489 if let Some(ref u) = usage {
2490 output_message_data = output_message_data.with_usage(u.clone());
2491 }
2492 self.event_emitter
2493 .emit(EventRequest::new(
2494 session_id,
2495 message_event_context,
2496 output_message_data,
2497 ))
2498 .await?;
2499
2500 tracing::info!(
2501 session_id = %session_id,
2502 turn_id = %context.turn_id,
2503 has_tool_calls = %has_tool_calls,
2504 tool_count = %tool_calls.len(),
2505 "ReasonAtom: LLM call completed"
2506 );
2507
2508 Ok(ReasonResult {
2509 success: true,
2510 text,
2511 tool_calls,
2512 has_tool_calls,
2513 tool_definitions: runtime_agent.tools.clone(),
2514 max_iterations: runtime_agent.max_iterations,
2515 error: None,
2516 user_facing_error: None,
2517 error_disclosure: None,
2518 usage,
2519 output_message_id: Some(output_message_id),
2520 time_to_first_token_ms,
2521 response_id,
2522 finish_reason,
2523 locale: resolved_locale,
2524 network_access: runtime_agent.network_access.clone(),
2525 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2526 })
2527 }
2528
2529 async fn finalize_partial_stream(
2534 &self,
2535 session_id: SessionId,
2536 context: &ExecutionContext,
2537 partial: PartialStreamState,
2538 iteration: u32,
2539 runtime_agent: &crate::RuntimeAgent,
2540 resolved_capability_configs: &[crate::CapabilityRef],
2541 ) -> Result<ReasonResult> {
2542 let event_context = EventContext::from_execution_context(context);
2543 let turn_id = context.turn_id;
2544 let message_id = partial.message_id;
2545
2546 let _ = self
2548 .event_emitter
2549 .emit(EventRequest::new(
2550 session_id,
2551 event_context.clone(),
2552 OutputMessageStartedData {
2553 reasoning_state: partial.reasoning_state.clone(),
2554 turn_id,
2555 message_id,
2556 model: None,
2557 iteration: Some(iteration),
2558 phase: None,
2561 },
2562 ))
2563 .await;
2564
2565 let accumulated = filter_response_text(
2568 &self.capability_registry,
2569 resolved_capability_configs,
2570 partial.accumulated,
2571 );
2572 let mut assistant_message = Message::assistant(&accumulated).with_id(message_id);
2573 if let Some(state) = partial.reasoning_state {
2574 assistant_message.metadata = Some(HashMap::from([
2575 ("model".into(), serde_json::json!("gpt-6-astra")),
2576 ("provider".into(), serde_json::json!("openai")),
2577 (
2578 reasoning_updates::STATE_KEY.into(),
2579 serde_json::json!(state),
2580 ),
2581 (
2582 "reasoning_effort".into(),
2583 serde_json::json!(state.effective),
2584 ),
2585 ]));
2586 }
2587 let output_message_id = message_id;
2588 self.event_emitter
2589 .emit(EventRequest::new(
2590 session_id,
2591 event_context.clone(),
2592 OutputMessageCompletedData::new(assistant_message),
2593 ))
2594 .await?;
2595
2596 let accumulated_len = accumulated.len();
2598 let _ = self
2599 .event_emitter
2600 .emit(EventRequest::new(
2601 session_id,
2602 event_context.clone(),
2603 ReasonRecoveredData {
2604 turn_id,
2605 mode: RecoveryMode::Finalize,
2606 accumulated_len,
2607 },
2608 ))
2609 .await;
2610
2611 tracing::info!(
2612 session_id = %session_id,
2613 turn_id = %turn_id,
2614 accumulated_len,
2615 "ReasonAtom: finalized partial stream from persisted accumulated text"
2616 );
2617
2618 Ok(ReasonResult {
2619 success: true,
2620 text: accumulated,
2621 tool_calls: vec![],
2622 has_tool_calls: false,
2623 tool_definitions: runtime_agent.tools.clone(),
2624 max_iterations: runtime_agent.max_iterations,
2625 error: None,
2626 user_facing_error: None,
2627 error_disclosure: None,
2628 usage: None,
2629 output_message_id: Some(output_message_id),
2630 time_to_first_token_ms: None,
2631 response_id: None,
2632 finish_reason: Some("stop".to_string()),
2633 locale: None,
2634 network_access: None,
2635 parallel_tool_calls: None,
2637 })
2638 }
2639
2640 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2651 let mut resolved = HashMap::new();
2652
2653 let resolver = match &self.image_resolver {
2655 Some(r) => r,
2656 None => return resolved,
2657 };
2658
2659 let image_ids: Vec<Uuid> = messages
2661 .iter()
2662 .flat_map(crate::llm_conversions::extract_image_file_ids)
2663 .collect::<std::collections::HashSet<_>>()
2664 .into_iter()
2665 .collect();
2666
2667 if image_ids.is_empty() {
2668 return resolved;
2669 }
2670
2671 tracing::debug!(
2672 image_count = image_ids.len(),
2673 "ReasonAtom: resolving image_file references"
2674 );
2675
2676 for image_id in image_ids {
2678 match resolver.resolve_image(image_id).await {
2679 Ok(Some(image)) => {
2680 resolved.insert(image_id, image);
2681 }
2682 Ok(None) => {
2683 tracing::warn!(
2684 image_id = %image_id,
2685 "ReasonAtom: image not found during resolution"
2686 );
2687 }
2688 Err(e) => {
2689 tracing::warn!(
2690 image_id = %image_id,
2691 error = %e,
2692 "ReasonAtom: failed to resolve image"
2693 );
2694 }
2695 }
2696 }
2697
2698 tracing::debug!(
2699 resolved_count = resolved.len(),
2700 "ReasonAtom: image resolution complete"
2701 );
2702
2703 resolved
2704 }
2705}
2706
2707#[cfg(test)]
2712mod tests;