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,
63 durability::PartialStreamState,
64 durability::PartialStreamStore,
65 event_emitter::EventEmitter,
66 file_services::{FileResolver, ResolvedFile},
67 image_services::ImageResolver,
68 image_services::ResolvedImage,
69};
70use everruns_provider::reasoning::{ReasoningContentPart, ReasoningText};
71
72mod compaction;
73mod error_policy;
74mod observability;
75mod output_hooks;
76mod reasoning_updates;
77mod request_controls;
78mod stream_state;
79mod transcript;
80
81use compaction::{
82 ProactiveCompactionContext, ReactiveCompactionContext, apply_proactive_compaction,
83 apply_reactive_compaction,
84};
85use error_policy::{
86 error_disclosure_override, filter_response_text, is_error_placeholder_message,
87 resolve_error_disclosure,
88};
89use observability::{build_request_options, capability_usage_snapshot_records};
90use output_hooks::collect_output_hooks;
91use request_controls::resolve_request_controls;
92use stream_state::{
93 StreamReplayState, StreamTermination, advances_stall_deadline, append_guarded_thinking_delta,
94 inspect_guarded_reasoning_item, merge_retry_metadata,
95};
96use transcript::repair_dangling_tool_calls;
97
98fn client_visible_guardrail_text(
103 text: &str,
104 streamed_reasoning: &str,
105 reasoning: &[ReasoningContentPart],
106 citation_annotations: &[crate::message::TextAnnotation],
107) -> String {
108 let mut guarded = streamed_reasoning.to_string();
109 if guarded.is_empty() {
110 for item_text in reasoning
111 .iter()
112 .filter_map(ReasoningContentPart::display_text)
113 {
114 if !guarded.is_empty() {
115 guarded.push_str("\n\n");
116 }
117 guarded.push_str(&item_text);
118 }
119 }
120
121 let prose = post_generation_guardrail_text(text, citation_annotations);
122 if !guarded.is_empty() && !prose.is_empty() {
123 guarded.push_str("\n\n");
124 }
125 guarded.push_str(&prose);
126 guarded
127}
128
129#[allow(clippy::too_many_arguments)]
132async fn apply_finalized_tool_calls_hooks(
133 capability_registry: &CapabilityRegistry,
134 event_emitter: &dyn EventEmitter,
135 session_id: SessionId,
136 context: &ExecutionContext,
137 resolved_capability_configs: &[crate::CapabilityRef],
138 tool_definitions: &[ToolDefinition],
139 tool_calls: &mut [ToolCall],
140 iteration: u32,
141) {
142 let hook_context = crate::finalized_tool_calls::FinalizedToolCallsContext {
143 event_emitter,
144 session_id,
145 execution_context: context,
146 tool_definitions,
147 iteration,
148 };
149 for config in resolved_capability_configs {
150 let Some(capability) = capability_registry.get(config.capability_id()) else {
151 continue;
152 };
153 if let Some(hook) = capability.finalized_tool_calls_hook(config.config_value()) {
154 hook.apply(&hook_context, tool_calls).await;
155 }
156 }
157}
158
159fn unix_now_secs() -> u64 {
160 std::time::SystemTime::now()
161 .duration_since(std::time::UNIX_EPOCH)
162 .unwrap_or_default()
163 .as_secs()
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct ReasonInput {
169 pub context: ExecutionContext,
171 pub harness_id: HarnessId,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub agent_id: Option<AgentId>,
176 #[serde(default)]
178 pub org_id: i64,
179 #[serde(default)]
183 pub mcp_tool_definitions: Vec<ToolDefinition>,
184 #[serde(skip_serializing_if = "Option::is_none")]
187 pub previous_response_id: Option<String>,
188 #[serde(default = "default_iteration")]
191 pub iteration: u32,
192}
193
194fn default_iteration() -> u32 {
195 1
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct NativeExecutionCounts {
201 pub llm_calls: u32,
202 pub tool_calls: u32,
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct ReasonResult {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub native_counts: Option<NativeExecutionCounts>,
210 pub success: bool,
212 pub text: String,
214 #[serde(default)]
216 pub tool_calls: Vec<ToolCall>,
217 pub has_tool_calls: bool,
219 #[serde(default)]
221 pub tool_definitions: Vec<ToolDefinition>,
222 #[serde(default = "default_max_iterations")]
224 pub max_iterations: usize,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 pub error: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub user_facing_error: Option<UserFacingError>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub error_disclosure: Option<ErrorDisclosure>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub usage: Option<TokenUsage>,
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub output_message_id: Option<MessageId>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub time_to_first_token_ms: Option<u64>,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub response_id: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub finish_reason: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub locale: Option<String>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub network_access: Option<crate::network_access::NetworkAccessList>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub parallel_tool_calls: Option<bool>,
262}
263
264fn default_max_iterations() -> usize {
265 500
266}
267
268pub struct ReasonAtom {
287 native_async: Option<Arc<tokio::sync::Mutex<crate::native_async::NativeAsyncCoordinator>>>,
288 context_resolver: Arc<dyn TurnContextResolver>,
289 message_retriever: Arc<dyn MessageRetriever>,
290 capability_registry: CapabilityRegistry,
291 event_emitter: PhaseEffectEmitter<dyn PhaseEffectSink>,
292 image_resolver: Option<Arc<dyn ImageResolver>>,
294 file_resolver: Option<Arc<dyn FileResolver>>,
295 stream_heartbeater: Option<Arc<dyn crate::durability::StreamHeartbeater>>,
297 provider_stall_timeout: Option<std::time::Duration>,
299 provider_retry_config: LlmRetryConfig,
301 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
303 partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
305 reasoning_effort_handle: Option<crate::tool_context::ReasoningEffortHandle>,
309 utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
313 schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
319 compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
321}
322
323impl ReasonAtom {
324 pub fn with_native_async(
325 mut self,
326 coordinator: Arc<tokio::sync::Mutex<crate::native_async::NativeAsyncCoordinator>>,
327 ) -> Self {
328 self.native_async = Some(coordinator);
329 self
330 }
331
332 pub fn new(
334 context_resolver: impl TurnContextResolver + 'static,
335 message_retriever: impl MessageRetriever + 'static,
336 capability_registry: CapabilityRegistry,
337 event_emitter: impl PhaseEffectSink + 'static,
338 ) -> Self {
339 Self {
340 native_async: None,
341 context_resolver: Arc::new(context_resolver),
342 message_retriever: Arc::new(message_retriever),
343 capability_registry,
344 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
345 image_resolver: None,
346 file_resolver: None,
347 stream_heartbeater: None,
348 provider_stall_timeout: None,
349 provider_retry_config: LlmRetryConfig::default(),
350 durable_tool_result_store: None,
351 partial_stream_store: None,
352 reasoning_effort_handle: None,
353 utility_llm_service: None,
354 schedule_store: None,
355 compaction_checkpoint_store: None,
356 }
357 }
358
359 pub fn with_schedule_store(
362 mut self,
363 store: Arc<dyn crate::session_services::SessionScheduleStore>,
364 ) -> Self {
365 self.schedule_store = Some(store);
366 self
367 }
368
369 pub fn with_compaction_checkpoint_store(
370 mut self,
371 store: Arc<dyn crate::CompactionCheckpointStore>,
372 ) -> Self {
373 self.compaction_checkpoint_store = Some(store);
374 self
375 }
376
377 fn collect_llm_error_hooks(
383 &self,
384 resolved_capability_configs: &[crate::CapabilityRef],
385 ) -> Vec<(
386 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
387 serde_json::Value,
388 )> {
389 resolved_capability_configs
390 .iter()
391 .filter_map(|cfg| {
392 let cap = self.capability_registry.get(cfg.capability_id())?;
393 let hook = cap.llm_error_hook()?;
394 Some((hook, cfg.config_value().clone()))
395 })
396 .collect()
397 }
398
399 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
412 self.image_resolver = Some(resolver);
413 self
414 }
415
416 pub fn with_file_resolver(mut self, resolver: Arc<dyn FileResolver>) -> Self {
417 self.file_resolver = Some(resolver);
418 self
419 }
420
421 pub fn with_stream_heartbeater(
423 mut self,
424 heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
425 ) -> Self {
426 self.stream_heartbeater = Some(heartbeater);
427 self
428 }
429
430 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
433 self.provider_stall_timeout = Some(timeout);
434 self
435 }
436
437 pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
439 self.provider_retry_config = config;
440 self
441 }
442
443 pub fn with_durable_tool_result_store(
449 mut self,
450 store: Arc<dyn DurableToolResultStore>,
451 ) -> Self {
452 self.durable_tool_result_store = Some(store);
453 self
454 }
455
456 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
458 self.partial_stream_store = Some(store);
459 self
460 }
461
462 pub fn with_reasoning_effort_handle(
469 mut self,
470 handle: crate::tool_context::ReasoningEffortHandle,
471 ) -> Self {
472 self.reasoning_effort_handle = Some(handle);
473 self
474 }
475
476 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
479 self.utility_llm_service = Some(service);
480 self
481 }
482}
483
484impl ReasonAtom {
485 pub fn name(&self) -> &'static str {
487 "reason"
488 }
489
490 pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
492 self.execute_inner(input, None).await
493 }
494}
495
496impl ReasonAtom {
497 pub async fn execute_with_assembled_context(
502 &self,
503 input: ReasonInput,
504 assembled: AssembledTurnContext,
505 ) -> Result<ReasonResult> {
506 self.execute_inner(input, Some(assembled)).await
507 }
508
509 async fn emit_capability_usage_snapshot(
510 &self,
511 session_id: SessionId,
512 context: &ExecutionContext,
513 resolved_capability_configs: &[crate::CapabilityRef],
514 tool_definitions: &[ToolDefinition],
515 ) {
516 let records = capability_usage_snapshot_records(
517 &self.capability_registry,
518 resolved_capability_configs,
519 tool_definitions,
520 );
521 if records.is_empty() {
522 return;
523 }
524
525 if let Err(error) = self
526 .event_emitter
527 .emit(EventRequest::new(
528 session_id,
529 EventContext::from_execution_context(context),
530 CapabilityUsageData { records },
531 ))
532 .await
533 {
534 tracing::warn!(
535 session_id = %session_id,
536 error = %error,
537 "ReasonAtom: failed to emit capability.usage event"
538 );
539 }
540 }
541
542 async fn apply_finalized_tool_call_hooks(
545 &self,
546 session_id: SessionId,
547 context: &ExecutionContext,
548 resolved_capability_configs: &[crate::CapabilityRef],
549 tool_definitions: &[ToolDefinition],
550 tool_calls: &mut [ToolCall],
551 iteration: u32,
552 ) {
553 apply_finalized_tool_calls_hooks(
554 &self.capability_registry,
555 self.event_emitter.as_ref(),
556 session_id,
557 context,
558 resolved_capability_configs,
559 tool_definitions,
560 tool_calls,
561 iteration,
562 )
563 .await;
564 }
565
566 async fn execute_inner(
567 &self,
568 input: ReasonInput,
569 assembled: Option<AssembledTurnContext>,
570 ) -> Result<ReasonResult> {
571 let ReasonInput {
572 context,
573 harness_id,
574 agent_id,
575 org_id,
576 mcp_tool_definitions,
577 previous_response_id,
578 iteration,
579 } = input;
580
581 tracing::info!(
582 session_id = %context.session_id,
583 turn_id = %context.turn_id,
584 exec_id = %context.exec_id,
585 harness_id = %harness_id,
586 agent_id = ?agent_id,
587 mcp_tools_count = %mcp_tool_definitions.len(),
588 "ReasonAtom: starting LLM call"
589 );
590
591 let trace_id = context.turn_id.to_string();
599 let reason_span_id = Uuid::now_v7().to_string();
600 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
604 trace_id.clone(),
605 reason_span_id.clone(),
606 Some(parent_span_id.clone()),
607 );
608
609 let reason_start = Instant::now();
611
612 if let Err(e) = self
614 .event_emitter
615 .emit(EventRequest::new(
616 context.session_id,
617 event_context.clone(),
618 ReasonStartedData {
619 harness_id,
620 agent_id,
621 metadata: None, },
623 ))
624 .await
625 {
626 tracing::warn!(
627 session_id = %context.session_id,
628 error = %e,
629 "ReasonAtom: failed to emit reason.started event"
630 );
631 }
632
633 let assembled = match assembled {
637 Some(assembled) => Ok(assembled),
638 None => {
639 self.context_resolver
640 .resolve_turn_context(TurnContextRequest {
641 session_id: context.session_id,
642 harness_id,
643 agent_id,
644 mcp_tool_definitions: mcp_tool_definitions.clone(),
645 })
646 .await
647 }
648 };
649
650 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
651 Ok(assembled) => {
652 let error_disclosure = resolve_error_disclosure(
653 &self.capability_registry,
654 &assembled.resolved_capability_configs,
655 error_disclosure_override(&assembled.messages).as_deref(),
656 );
657 let error_hooks =
661 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
662 let error_context = UserFacingErrorContext::default()
663 .with_provider(assembled.model.provider_type.to_string())
664 .with_model_id(assembled.model.model.clone());
665 let call_result = self
666 .execute_llm_call(
667 context.session_id,
668 harness_id,
669 agent_id,
670 org_id,
671 &context,
672 &trace_id,
673 &reason_span_id,
674 previous_response_id,
675 iteration,
676 assembled,
677 )
678 .await;
679 (error_disclosure, error_context, error_hooks, call_result)
680 }
681 Err(error) => (
682 ErrorDisclosure::default(),
683 UserFacingErrorContext::default(),
684 Vec::new(),
685 Err(error),
686 ),
687 };
688
689 let result = match call_result {
691 Ok(result) => {
692 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
694
695 let completed_context = EventContext::from_execution_context(&context).with_span(
697 trace_id.clone(),
698 reason_span_id.clone(), Some(parent_span_id.clone()),
700 );
701 if let Err(e) = self
702 .event_emitter
703 .emit(EventRequest::new(
704 context.session_id,
705 completed_context,
706 ReasonCompletedData::success(
707 &result.text,
708 result.has_tool_calls,
709 result.tool_calls.len() as u32,
710 Some(reason_duration_ms),
711 result.usage.clone(),
712 ),
713 ))
714 .await
715 {
716 tracing::warn!(
717 session_id = %context.session_id,
718 error = %e,
719 "ReasonAtom: failed to emit reason.completed event"
720 );
721 }
722 result
723 }
724 Err(e) => {
725 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
727
728 tracing::warn!(
731 session_id = %context.session_id,
732 turn_id = %context.turn_id,
733 error = %e,
734 "ReasonAtom: LLM call failed"
735 );
736
737 let error_msg = e.to_string();
738 let mut source_error = e.user_facing_error(error_context);
739
740 let is_transient = e.is_transient_llm_error()
747 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
748
749 if !is_transient && !error_hooks.is_empty() {
755 let services = crate::llm_error_hook::LlmErrorHookServices {
756 schedule_store: self.schedule_store.clone(),
757 };
758 for (hook, config) in &error_hooks {
759 let outcome = {
760 let ctx = crate::llm_error_hook::LlmErrorContext {
761 session_id: context.session_id,
762 error_code: &source_error.code,
763 error_fields: &source_error.fields,
764 config,
765 services: &services,
766 };
767 hook.on_llm_error(&ctx).await
768 };
769 for (key, value) in outcome.extra_error_fields {
770 source_error = source_error.with_field(key, value);
771 }
772 }
773 }
774
775 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
776 let user_error_text = user_error.fallback_message();
777
778 let mut output_message_id = None;
779
780 if !is_transient {
781 let mut error_message = Message::assistant(&user_error_text);
783 let mut metadata = std::collections::HashMap::new();
784 user_error.apply_to_message_metadata(&mut metadata);
785 UserFacingError::apply_disclosure_to_message_metadata(
786 &mut metadata,
787 error_disclosure,
788 &source_error.code,
789 );
790 error_message.metadata = Some(metadata);
791
792 output_message_id = Some(error_message.id);
793
794 let error_msg_context = EventContext::from_execution_context(&context)
797 .with_span(
798 trace_id.clone(),
799 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
802 if let Err(emit_err) = self
803 .event_emitter
804 .emit(EventRequest::new(
805 context.session_id,
806 error_msg_context,
807 OutputMessageCompletedData::new(error_message)
808 .with_user_facing_error(&user_error)
809 .with_error_disclosure(error_disclosure),
810 ))
811 .await
812 {
813 tracing::warn!(
814 session_id = %context.session_id,
815 error = %emit_err,
816 "ReasonAtom: failed to emit output.message.completed event for error"
817 );
818 }
819 } else {
820 tracing::info!(
821 session_id = %context.session_id,
822 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
823 );
824 }
825
826 let completed_context = EventContext::from_execution_context(&context).with_span(
828 trace_id.clone(),
829 reason_span_id.clone(), Some(parent_span_id.clone()),
831 );
832 if let Err(emit_err) = self
833 .event_emitter
834 .emit(EventRequest::new(
835 context.session_id,
836 completed_context,
837 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
838 ))
839 .await
840 {
841 tracing::warn!(
842 session_id = %context.session_id,
843 error = %emit_err,
844 "ReasonAtom: failed to emit reason.completed event"
845 );
846 }
847
848 ReasonResult {
849 native_counts: None,
850 success: false,
851 text: user_error_text,
852 tool_calls: vec![],
853 has_tool_calls: false,
854 tool_definitions: vec![],
855 max_iterations: default_max_iterations(),
856 error: Some(error_msg.clone()),
857 user_facing_error: Some(user_error),
858 error_disclosure: Some(error_disclosure),
859 usage: None,
860 output_message_id,
861 time_to_first_token_ms: None,
862 response_id: None,
863 finish_reason: error_msg
864 .to_ascii_lowercase()
865 .contains("model refused")
866 .then(|| "refusal".to_string()),
867 locale: None,
868 network_access: None,
869 parallel_tool_calls: None,
870 }
871 }
872 };
873
874 Ok(result)
875 }
876
877 #[allow(clippy::too_many_arguments)]
879 async fn execute_llm_call(
880 &self,
881 session_id: SessionId,
882 harness_id: HarnessId,
883 agent_id: Option<AgentId>,
884 org_id: i64,
885 context: &ExecutionContext,
886 trace_id: &str,
887 reason_span_id: &str,
888 previous_response_id: Option<String>,
889 iteration: u32,
890 assembled: AssembledTurnContext,
891 ) -> Result<ReasonResult> {
892 let prior_usage = assembled.cumulative_usage();
893 let mut messages = transcript::order_native_results(assembled.messages);
894 let mut message_source_sequence = assembled.message_source_sequence;
895 let model_with_provider = assembled.model;
896 let resolved_model_id = assembled.resolved_model_id;
897 let resolved_locale = assembled.resolved_locale;
898 let compaction_policy = assembled.compaction_policy;
899 let resolved_capability_configs = assembled.resolved_capability_configs;
900 let runtime_agent = assembled.runtime_agent;
901 let embedder_metadata = assembled.embedder_metadata;
902
903 self.emit_capability_usage_snapshot(
904 session_id,
905 context,
906 &resolved_capability_configs,
907 &runtime_agent.tools,
908 )
909 .await;
910
911 let output_hooks =
912 collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
913 let guardrail_providers = output_hooks.streaming;
914 let post_output_providers = output_hooks.post_generation;
915 let annotation_providers = output_hooks.annotations;
916 let citation_verifiers = output_hooks.citation_verifiers;
917
918 let chat_driver = Arc::clone(&model_with_provider.driver);
920 let stateful_response_continuation =
921 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
922 let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
923 let mut checkpoint_suffix_message_count = 0usize;
924 let native_reasoning_compaction = compaction_policy.as_ref().is_none_or(|policy| {
925 matches!(
926 policy.settings().strategy,
927 crate::compaction_policy::CompactionStrategy::Native
928 | crate::compaction_policy::CompactionStrategy::Auto
929 ) && chat_driver.supports_compact()
930 });
931
932 if compaction_policy.is_some()
933 && let Some(store) = self.compaction_checkpoint_store.as_ref()
934 && let Some(checkpoint) = store
935 .get_latest(
936 session_id,
937 model_with_provider.provider_type.as_str(),
938 &model_with_provider.model,
939 )
940 .await?
941 && checkpoint.is_compatible(
942 model_with_provider.provider_type.as_str(),
943 &model_with_provider.model,
944 )
945 && (native_reasoning_compaction || !matches!(
948 &checkpoint.payload,
949 crate::CompactionCheckpointPayload::ProviderOpaque {
950 context: crate::ProviderOpaqueContext::OpenResponsesCompact {
951 reasoning_state: Some(_), ..
952 }
953 }
954 ))
955 {
956 let filters = crate::capabilities::collect_message_filters_only(
957 &resolved_capability_configs,
958 &self.capability_registry,
959 );
960 let mut query =
961 crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
962 filters.apply_message_filters(&mut query);
963 let history = self.message_retriever.load_filtered_history(query).await?;
964 messages = history.messages;
965 checkpoint_suffix_message_count = messages.len();
966 filters.apply_post_load_filters(&mut messages);
967 if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
968 messages.insert(
969 0,
970 Message::system(format!(
971 "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
972 )),
973 );
974 }
975 message_source_sequence = history.source_sequence.or(message_source_sequence);
976 restored_checkpoint = Some(checkpoint);
977 }
978
979 let controls = resolve_request_controls(
980 &messages,
981 self.reasoning_effort_handle.as_ref(),
982 &model_with_provider.provider_type,
983 &model_with_provider.model,
984 );
985 let reasoning_effort = controls.reasoning_effort;
986 let speed = controls.speed;
987 let verbosity = controls.verbosity;
988 let checkpoint_reasoning =
989 restored_checkpoint
990 .as_ref()
991 .and_then(|checkpoint| match &checkpoint.payload {
992 crate::CompactionCheckpointPayload::ProviderOpaque {
993 context:
994 crate::ProviderOpaqueContext::OpenResponsesCompact {
995 reasoning_state, ..
996 },
997 } => reasoning_state.as_ref(),
998 _ => None,
999 });
1000 let mut reasoning_replay = reasoning_updates::prepare(
1001 &messages,
1002 model_with_provider.provider_type.as_str(),
1003 &model_with_provider.model,
1004 reasoning_effort,
1005 self.reasoning_effort_handle
1006 .as_ref()
1007 .and_then(crate::tool_context::ReasoningEffortHandle::get),
1008 checkpoint_reasoning,
1009 )
1010 .filter(|_| native_reasoning_compaction);
1011
1012 if let Some(ref store) = self.partial_stream_store {
1016 let turn_id_str = context.turn_id.to_string();
1017 match store.get_partial_stream(session_id, &turn_id_str).await {
1018 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
1019 return self
1021 .finalize_partial_stream(
1022 session_id,
1023 context,
1024 partial,
1025 iteration,
1026 &runtime_agent,
1027 &resolved_capability_configs,
1028 )
1029 .await;
1030 }
1031 Ok(Some(partial)) => {
1032 if let (Some(replay), Some(mut saved)) =
1033 (reasoning_replay.as_mut(), partial.reasoning_state)
1034 {
1035 saved.pending = saved.effective;
1038 replay.state = saved;
1039 }
1040 let recovery_ctx = EventContext::from_execution_context(context);
1043 let _ = self
1044 .event_emitter
1045 .emit(EventRequest::new(
1046 session_id,
1047 recovery_ctx,
1048 ReasonRecoveredData {
1049 turn_id: context.turn_id,
1050 mode: RecoveryMode::Restart,
1051 accumulated_len: 0,
1052 },
1053 ))
1054 .await;
1055 tracing::info!(
1056 session_id = %session_id,
1057 turn_id = %context.turn_id,
1058 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1059 );
1060 }
1061 Ok(None) => {} Err(e) => {
1063 if reasoning_replay.is_some() {
1064 return Err(e);
1065 }
1066 tracing::warn!(
1068 session_id = %session_id,
1069 turn_id = %context.turn_id,
1070 error = %e,
1071 "ReasonAtom: partial-stream store error; proceeding with normal execution"
1072 );
1073 }
1074 }
1075 }
1076
1077 let repair_event_context = EventContext::from_execution_context(context);
1081 let patched_messages = if self.native_async.is_some() {
1082 messages.clone()
1083 } else {
1084 repair_dangling_tool_calls(
1085 &messages,
1086 self.durable_tool_result_store.as_deref(),
1087 self.event_emitter.as_ref(),
1088 session_id,
1089 &repair_event_context,
1090 &context.turn_id.to_string(),
1091 )
1092 .await
1093 };
1094 let raw_tool_result_bytes = compaction_policy
1095 .as_ref()
1096 .map(|policy| policy.total_tool_result_bytes(&patched_messages))
1097 .unwrap_or(0);
1098
1099 let model_view_providers = crate::capabilities::collect_model_view_providers(
1102 &resolved_capability_configs,
1103 &self.capability_registry,
1104 Some(model_with_provider.model.as_str()),
1105 );
1106 let model_view_context = crate::capabilities::ModelViewContext {
1107 session_id,
1108 prior_usage: prior_usage.as_ref(),
1109 };
1110 let mut context_messages =
1111 model_view_providers.apply_model_view(patched_messages, &model_view_context);
1112 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1113 &context_messages,
1114 stateful_response_continuation || restored_checkpoint.is_some(),
1115 );
1116
1117 let mut volatile_suffix_len = 0usize;
1124 {
1125 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1126 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1127 &resolved_capability_configs,
1128 &self.capability_registry,
1129 Some(model_with_provider.model.as_str()),
1130 &facts_ctx,
1131 );
1132 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1133 context_messages.push(Message::user(block));
1134 volatile_suffix_len = 1;
1135 }
1136 }
1137
1138 if let Some(context) = runtime_agent.conversation_context.as_ref()
1147 && !context.is_empty()
1148 {
1149 context_messages.insert(0, Message::user(context.clone()));
1150 }
1151
1152 let resolved_images = self.resolve_images(&context_messages).await;
1157 let resolved_files = self.resolve_files(&context_messages).await;
1158
1159 let mut llm_messages = Vec::new();
1161
1162 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1164 if has_system_prompt {
1165 llm_messages.push(LlmMessage {
1166 native_tool_calls: Vec::new(),
1167 role: LlmMessageRole::System,
1168 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1169 tool_calls: None,
1170 tool_call_id: None,
1171 phase: None,
1172 reasoning: Vec::new(),
1173 configuration_update: None,
1174 });
1175 }
1176
1177 let messages_for_event: Vec<Message> = if has_system_prompt {
1179 std::iter::once(Message::system(&runtime_agent.system_prompt))
1180 .chain(context_messages.iter().cloned())
1181 .collect()
1182 } else {
1183 context_messages.clone()
1184 };
1185
1186 let mut stripped_error_count = 0u32;
1192 for msg in &context_messages {
1193 if is_error_placeholder_message(msg) {
1194 stripped_error_count += 1;
1195 continue;
1196 }
1197 let mut llm_msg = crate::llm_conversions::llm_message_from_message_with_attachments(
1198 msg,
1199 &resolved_images,
1200 &resolved_files,
1201 );
1202 llm_msg.configuration_update = reasoning_replay
1203 .as_ref()
1204 .and_then(|replay| replay.transitions.get(&msg.id).copied());
1205 if msg.role == MessageRole::User
1206 && let Some(ref actor) = msg.external_actor
1207 {
1208 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1209 }
1210 llm_messages.push(llm_msg);
1211 }
1212 if stripped_error_count > 0 {
1213 tracing::info!(
1214 session_id = %session_id,
1215 stripped_error_count,
1216 "ReasonAtom: stripped error placeholder messages from LLM input"
1217 );
1218 }
1219
1220 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1225 llm_messages,
1226 stateful_response_continuation || restored_checkpoint.is_some(),
1227 );
1228
1229 let mut llm_config_builder =
1231 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1232 if let Some(effort) = reasoning_effort {
1233 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1234 }
1235 if let Some(speed) = speed {
1236 llm_config_builder = llm_config_builder.speed(speed);
1237 }
1238 if let Some(verbosity) = verbosity {
1239 llm_config_builder = llm_config_builder.verbosity(verbosity);
1240 }
1241
1242 for (k, v) in &embedder_metadata {
1244 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1245 }
1246
1247 llm_config_builder = llm_config_builder
1251 .with_metadata("session_id", session_id.to_string())
1252 .with_metadata("harness_id", harness_id.to_string())
1253 .with_metadata("turn_id", context.turn_id.to_string())
1254 .with_metadata("exec_id", context.exec_id.to_string())
1255 .with_metadata("org_id", format!("org_{:032x}", org_id));
1256 if let Some(agent_id) = agent_id {
1257 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1258 }
1259
1260 if let Some(model_id) = &resolved_model_id {
1262 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1263 }
1264
1265 let mut llm_config = llm_config_builder
1266 .previous_response_id(previous_response_id.clone())
1267 .volatile_suffix_len(volatile_suffix_len)
1268 .build();
1269 if let Some(replay) = &reasoning_replay {
1270 llm_config.reasoning_effort = replay.state.baseline;
1271 llm_config.reasoning_state = Some(replay.state.clone());
1272 if replay.reset_continuation {
1273 llm_config.previous_response_id = None;
1274 }
1275 } else if messages
1276 .iter()
1277 .rev()
1278 .find(|message| {
1279 message.role == MessageRole::Agent && !is_error_placeholder_message(message)
1280 })
1281 .and_then(|message| message.metadata.as_ref())
1282 .is_some_and(|metadata| metadata.contains_key(reasoning_updates::STATE_KEY))
1283 {
1284 llm_config.previous_response_id = None;
1287 }
1288 if let Some(checkpoint) = restored_checkpoint.as_ref()
1289 && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1290 &checkpoint.payload
1291 {
1292 llm_config.previous_response_id = None;
1293 llm_config.provider_opaque_context = Some(context.clone());
1294 }
1295
1296 tracing::debug!(
1297 session_id = %session_id,
1298 turn_id = %context.turn_id,
1299 model = %runtime_agent.model,
1300 message_count = %llm_messages.len(),
1301 "ReasonAtom: calling LLM"
1302 );
1303
1304 let streaming_event_context = EventContext::from_execution_context(context);
1307
1308 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1315 for (cap_id, cfg, provider) in &guardrail_providers {
1316 let ctx = OutputGuardrailContext {
1317 system_prompt: &runtime_agent.system_prompt,
1318 config: cfg,
1319 };
1320 let guardrail_id = provider.id().to_string();
1321 if let Some(run) = provider.arm(&ctx) {
1322 armed_guardrails.push(ArmedGuardrail {
1323 capability_id: cap_id.clone(),
1324 guardrail_id,
1325 run,
1326 });
1327 }
1328 }
1329 let buffer_output_deltas = !post_output_providers.is_empty();
1334 let output_message_id = MessageId::new();
1338 tracing::info!(
1339 session_id = %session_id,
1340 turn_id = %context.turn_id,
1341 "ReasonAtom: emitting output.message.started event"
1342 );
1343 if let Err(e) = self
1344 .event_emitter
1345 .emit(EventRequest::new(
1346 session_id,
1347 streaming_event_context.clone(),
1348 OutputMessageStartedData {
1349 reasoning_state: llm_config.reasoning_state.clone(),
1350 turn_id: context.turn_id,
1351 message_id: output_message_id,
1352 model: Some(runtime_agent.model.clone()),
1353 iteration: Some(iteration),
1354 phase: None,
1357 },
1358 ))
1359 .await
1360 {
1361 if llm_config.reasoning_state.is_some() {
1362 return Err(e);
1363 }
1364 tracing::warn!(
1365 session_id = %session_id,
1366 error = %e,
1367 "ReasonAtom: failed to emit output.message.started event"
1368 );
1369 } else {
1370 tracing::info!(
1371 session_id = %session_id,
1372 "ReasonAtom: output.message.started event emitted successfully"
1373 );
1374 }
1375
1376 let thinking_enabled = reasoning_effort.is_some();
1378 if thinking_enabled {
1379 tracing::info!(
1380 session_id = %session_id,
1381 turn_id = %context.turn_id,
1382 "ReasonAtom: emitting reason.thinking.started event"
1383 );
1384 if let Err(e) = self
1385 .event_emitter
1386 .emit(EventRequest::new(
1387 session_id,
1388 streaming_event_context.clone(),
1389 ReasonThinkingStartedData {
1390 turn_id: context.turn_id,
1391 model: Some(runtime_agent.model.clone()),
1392 },
1393 ))
1394 .await
1395 {
1396 tracing::warn!(
1397 session_id = %session_id,
1398 error = %e,
1399 "ReasonAtom: failed to emit reason.thinking.started event"
1400 );
1401 } else {
1402 tracing::info!(
1403 session_id = %session_id,
1404 "ReasonAtom: reason.thinking.started event emitted successfully"
1405 );
1406 }
1407 }
1408
1409 let llm_start = Instant::now();
1411
1412 let mut compaction_info: Option<LlmCompactionInfo> = None;
1416 let mut llm_messages_for_call = llm_messages.clone();
1417
1418 if let Some(policy) = compaction_policy.as_deref() {
1419 compaction_info = apply_proactive_compaction(
1420 ProactiveCompactionContext {
1421 chat_driver: chat_driver.as_ref(),
1422 policy,
1423 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1424 event_emitter: self.event_emitter.as_ref(),
1425 event_context: &streaming_event_context,
1426 session_id,
1427 message_source_sequence,
1428 provider_type: model_with_provider.provider_type.as_str(),
1429 model: &model_with_provider.model,
1430 system_prompt: has_system_prompt
1431 .then_some(runtime_agent.system_prompt.as_str()),
1432 stateful_response_continuation,
1433 checkpoint_restored: restored_checkpoint.is_some(),
1434 checkpoint_suffix_message_count,
1435 raw_tool_result_bytes,
1436 prior_usage: prior_usage.as_ref(),
1437 },
1438 &mut llm_messages_for_call,
1439 &mut llm_config,
1440 )
1441 .await?;
1442 }
1443
1444 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1447 let retry_config = self.provider_retry_config.clone();
1448 let has_provider_executed_tools = llm_config
1456 .driver_options
1457 .get("openrouter/routing")
1458 .and_then(|raw| raw.get("server_tools"))
1459 .and_then(|tools| tools.as_array())
1460 .is_some_and(|tools| !tools.is_empty());
1461 let mut stream_retry_metadata = RetryMetadata::default();
1462 let mut retry_started_at = None;
1463 let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1468 let mut native_calls = std::collections::BTreeMap::new();
1469 let (
1470 text,
1471 thinking,
1472 reasoning,
1473 tool_calls,
1474 completion_metadata,
1475 time_to_first_token_ms,
1476 pending_delta,
1477 mut tripped,
1478 ) = 'stream_attempt: loop {
1479 let stream_result = if let Some(remaining) =
1480 remaining_retry_time(&retry_config, retry_started_at)
1481 {
1482 match tokio::time::timeout(
1483 remaining,
1484 chat_driver.chat_completion_stream(
1485 &crate::ProviderEndpoint::default(),
1486 llm_messages_for_call.clone(),
1487 &llm_config,
1488 ),
1489 )
1490 .await
1491 {
1492 Ok(result) => result,
1493 Err(_) => {
1494 return Err(AgentLoopError::llm_kind(
1495 crate::error::LlmErrorKind::Unavailable,
1496 format!(
1497 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1498 stream_retry_metadata.attempts,
1499 retry_config.max_retry_elapsed.as_secs_f64()
1500 ),
1501 )
1502 .with_retry_metadata(&stream_retry_metadata));
1503 }
1504 }
1505 } else {
1506 chat_driver
1507 .chat_completion_stream(
1508 &crate::ProviderEndpoint::default(),
1509 llm_messages_for_call.clone(),
1510 &llm_config,
1511 )
1512 .await
1513 };
1514 let mut stream = match stream_result {
1515 Ok(stream) => stream,
1516 Err(e) if e.is_request_too_large() => {
1517 let Some(policy) = compaction_policy.as_deref() else {
1518 tracing::warn!(
1519 session_id = %session_id,
1520 turn_id = %context.turn_id,
1521 "ReasonAtom: context too large and compaction capability is not enabled"
1522 );
1523 return Err(e);
1524 };
1525 let outcome = apply_reactive_compaction(
1526 ReactiveCompactionContext {
1527 chat_driver: chat_driver.as_ref(),
1528 policy,
1529 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1530 event_emitter: self.event_emitter.as_ref(),
1531 event_context: &streaming_event_context,
1532 session_id,
1533 message_source_sequence,
1534 provider_type: model_with_provider.provider_type.as_str(),
1535 model: &model_with_provider.model,
1536 summarization_model_fallback: &runtime_agent.model,
1537 system_prompt: has_system_prompt
1538 .then_some(runtime_agent.system_prompt.as_str()),
1539 stateful_response_continuation,
1540 },
1541 &mut llm_messages_for_call,
1542 &mut llm_config,
1543 )
1544 .await?;
1545 let Some(outcome) = outcome else {
1546 return Err(e);
1547 };
1548 if outcome.generation_info.is_some() {
1549 compaction_info = outcome.generation_info;
1550 }
1551
1552 chat_driver
1553 .chat_completion_stream(
1554 &crate::ProviderEndpoint::default(),
1555 llm_messages_for_call.clone(),
1556 &llm_config,
1557 )
1558 .await?
1559 }
1560 Err(e)
1561 if e.is_transient_llm_error()
1562 && !e.llm_retry_handled()
1563 && !has_provider_executed_tools
1564 && stream_retry_metadata.attempts < retry_config.max_retries =>
1565 {
1566 let proposed_wait =
1567 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1568 let Some(wait_duration) =
1569 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1570 else {
1571 return Err(AgentLoopError::llm_kind(
1572 e.llm_error_kind()
1573 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1574 format!(
1575 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1576 stream_retry_metadata.attempts
1577 ),
1578 )
1579 .with_retry_metadata(&stream_retry_metadata));
1580 };
1581 tracing::warn!(
1582 session_id = %session_id,
1583 turn_id = %context.turn_id,
1584 attempt = stream_retry_metadata.attempts + 1,
1585 max_retries = retry_config.max_retries,
1586 wait_secs = wait_duration.as_secs_f64(),
1587 error = %e,
1588 "ReasonAtom: transient provider failure before stream, retrying"
1589 );
1590 stream_retry_metadata.record_retry(wait_duration, None);
1591 tokio::time::sleep(wait_duration).await;
1592 continue 'stream_attempt;
1593 }
1594 Err(e) => return Err(e),
1595 };
1596
1597 if let Some(coordinator) = &self.native_async {
1598 coordinator
1599 .lock()
1600 .await
1601 .begin_transcript_response(output_message_id.to_string())
1602 .await?;
1603 let coordinator = coordinator.clone();
1604 stream = Box::pin(futures::stream::unfold(
1605 Some((coordinator, stream)),
1606 |state| async move {
1607 let (coordinator, mut source) = state?;
1608 let event = coordinator
1609 .lock()
1610 .await
1611 .next_response_event(&mut source)
1612 .await;
1613 let finished = matches!(&event, Ok(LlmStreamEvent::Done(_)) | Err(_));
1614 Some((event, (!finished).then_some((coordinator, source))))
1615 },
1616 ));
1617 }
1618 let mut text = String::new();
1619 let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1623 let mut thinking = String::new();
1625 let mut tool_calls = Vec::new();
1626 let mut termination = StreamTermination::Exhausted;
1627 let mut replay_state = StreamReplayState::for_request(has_provider_executed_tools);
1628 let mut pending_delta = String::new();
1629 let mut pending_thinking_delta = String::new();
1630 let mut last_delta_emit = Instant::now();
1631 let mut last_thinking_delta_emit = Instant::now();
1632 let mut time_to_first_token_ms: Option<u64> = None;
1633
1634 let stall_timeout = self
1636 .provider_stall_timeout
1637 .unwrap_or(std::time::Duration::from_secs(120));
1638 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1639 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1640 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1641 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1642 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1643 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1645 let mut last_token_at_unix: u64 = unix_now_secs();
1650
1651 loop {
1652 let event = tokio::select! {
1653 biased;
1654 next = stream.next() => match next {
1655 Some(e) => e,
1656 None => break,
1657 },
1658 _ = &mut stall_sleep => {
1659 let stall_error =
1669 crate::driver_registry::LlmStreamError::new(format!(
1670 "provider stream stall: no tokens for {}s",
1671 stall_timeout.as_secs()
1672 ));
1673 tracing::warn!(
1674 session_id = %session_id,
1675 turn_id = %context.turn_id,
1676 stall_secs = stall_timeout.as_secs(),
1677 "ReasonAtom: provider stream stall timeout"
1678 );
1679 if replay_state.should_retry(
1680 &stall_error,
1681 stream_retry_metadata.attempts,
1682 retry_config.max_retries,
1683 ) {
1684 let proposed_wait = retry_config
1685 .calculate_backoff(stream_retry_metadata.attempts);
1686 let Some(wait_duration) = reserve_retry_wait(
1687 &retry_config,
1688 &mut retry_started_at,
1689 proposed_wait,
1690 ) else {
1691 return Err(AgentLoopError::llm_kind(
1692 crate::error::LlmErrorKind::Unavailable,
1693 format!(
1694 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1695 stall_error.message,
1696 stream_retry_metadata.attempts
1697 ),
1698 )
1699 .with_retry_metadata(&stream_retry_metadata));
1700 };
1701 tracing::warn!(
1702 session_id = %session_id,
1703 turn_id = %context.turn_id,
1704 attempt = stream_retry_metadata.attempts + 1,
1705 max_retries = retry_config.max_retries,
1706 wait_secs = wait_duration.as_secs_f64(),
1707 "ReasonAtom: provider stream stall, retrying"
1708 );
1709 stream_retry_metadata.record_retry(wait_duration, None);
1710 tokio::time::sleep(wait_duration).await;
1711 continue 'stream_attempt;
1712 }
1713 return Err(AgentLoopError::llm(stall_error.message));
1714 },
1715 _ = keepalive_ticker.tick() => {
1716 if let Some(ref hb) = self.stream_heartbeater {
1717 hb.heartbeat(crate::durability::StreamProgress {
1718 accumulated_len: text.len() + thinking.len(),
1719 last_delta_at: last_token_at_unix,
1720 })
1721 .await;
1722 last_stream_heartbeat = Instant::now();
1723 }
1724 continue;
1725 },
1726 };
1727 let event = event?;
1728 replay_state.observe(&event);
1729 let advanced_stall_deadline = advances_stall_deadline(&event);
1730 if advanced_stall_deadline {
1731 stall_sleep
1732 .as_mut()
1733 .reset(tokio::time::Instant::now() + stall_timeout);
1734 last_token_at_unix = unix_now_secs();
1735 }
1736 match event {
1737 LlmStreamEvent::TextDelta(delta) => {
1738 if delta.is_empty() {
1739 continue;
1740 }
1741 if time_to_first_token_ms.is_none() {
1743 let ttft = llm_start.elapsed().as_millis() as u64;
1744 time_to_first_token_ms = Some(ttft);
1745 tracing::info!(
1746 session_id = %session_id,
1747 time_to_first_token_ms = ttft,
1748 "ReasonAtom: received first token from LLM"
1749 );
1750 }
1751 text.push_str(&delta);
1752 pending_delta.push_str(&delta);
1753
1754 if !armed_guardrails.is_empty()
1761 && let Some(t) =
1762 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1763 {
1764 tracing::warn!(
1765 session_id = %session_id,
1766 turn_id = %context.turn_id,
1767 guardrail_capability_id = %t.capability_id,
1768 guardrail_id = %t.guardrail_id,
1769 reason_code = %t.block.reason_code,
1770 "ReasonAtom: output guardrail tripped, replacing assistant message"
1771 );
1772 pending_delta.clear();
1773 termination = StreamTermination::GuardrailBlocked(t);
1774 break;
1775 }
1776
1777 if !buffer_output_deltas
1779 && last_delta_emit.elapsed().as_millis() as u64
1780 >= DELTA_BATCH_INTERVAL_MS
1781 && !pending_delta.is_empty()
1782 {
1783 if let Err(e) = self
1784 .event_emitter
1785 .emit(EventRequest::new(
1786 session_id,
1787 streaming_event_context.clone(),
1788 OutputMessageDeltaData {
1789 turn_id: context.turn_id,
1790 message_id: output_message_id,
1791 delta: pending_delta.clone(),
1792 accumulated: text.clone(),
1793 phase: streamed_phase,
1794 },
1795 ))
1796 .await
1797 {
1798 tracing::warn!(
1799 session_id = %session_id,
1800 error = %e,
1801 "ReasonAtom: failed to emit output.message.delta event"
1802 );
1803 }
1804 pending_delta.clear();
1805 last_delta_emit = Instant::now();
1806 }
1807 }
1808 LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1809 if delta.is_empty() {
1810 continue;
1811 }
1812 if let Some(t) = append_guarded_thinking_delta(
1813 &mut armed_guardrails,
1814 &mut thinking,
1815 &mut pending_thinking_delta,
1816 &delta,
1817 ) {
1818 tracing::warn!(
1819 session_id = %session_id,
1820 guardrail_capability_id = %t.capability_id,
1821 guardrail_id = %t.guardrail_id,
1822 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1823 );
1824 termination = StreamTermination::GuardrailBlocked(t);
1825 break;
1826 }
1827 tracing::debug!(
1828 session_id = %session_id,
1829 delta_len = delta.len(),
1830 total_thinking_len = thinking.len(),
1831 "ReasonAtom: received ThinkingDelta from LLM"
1832 );
1833
1834 if last_thinking_delta_emit.elapsed().as_millis() as u64
1836 >= DELTA_BATCH_INTERVAL_MS
1837 && !pending_thinking_delta.is_empty()
1838 {
1839 if let Err(e) = self
1840 .event_emitter
1841 .emit(EventRequest::new(
1842 session_id,
1843 streaming_event_context.clone(),
1844 ReasonThinkingDeltaData {
1845 turn_id: context.turn_id,
1846 delta: pending_thinking_delta.clone(),
1847 accumulated: thinking.clone(),
1848 },
1849 ))
1850 .await
1851 {
1852 tracing::warn!(
1853 session_id = %session_id,
1854 error = %e,
1855 "ReasonAtom: failed to emit reason.thinking.delta event"
1856 );
1857 }
1858 pending_thinking_delta.clear();
1859 last_thinking_delta_emit = Instant::now();
1860 }
1861 }
1862 LlmStreamEvent::ReasoningItem(item) => {
1863 if let Some(t) = inspect_guarded_reasoning_item(
1864 &mut armed_guardrails,
1865 &mut thinking,
1866 &item,
1867 ) {
1868 tracing::warn!(
1869 session_id = %session_id,
1870 guardrail_capability_id = %t.capability_id,
1871 guardrail_id = %t.guardrail_id,
1872 "ReasonAtom: output guardrail tripped on completed reasoning item, replacing assistant message"
1873 );
1874 termination = StreamTermination::GuardrailBlocked(t);
1875 break;
1876 }
1877 tracing::debug!(
1881 session_id = %session_id,
1882 provider = %item.provider,
1883 item_id = ?item.item_id,
1884 has_signature = item.signature.is_some(),
1885 has_encrypted = item.encrypted.is_some(),
1886 "ReasonAtom: captured reasoning artifact"
1887 );
1888 reasoning.push(item);
1889 }
1890 LlmStreamEvent::NativeToolCall(call) => {
1891 if self.native_async.is_none() {
1892 return Err(AgentLoopError::config(
1893 "native async/custom tools require a configured native-call coordinator",
1894 ));
1895 }
1896 let part = crate::message::ToolCallContentPart::from_native(call.clone())?;
1897 if native_calls.insert(call.id().to_owned(), call).is_none() {
1898 tool_calls.push(ToolCall {
1899 id: part.id,
1900 name: part.name,
1901 arguments: part.arguments,
1902 });
1903 }
1904 }
1905 LlmStreamEvent::ToolCalls(calls) => {
1906 if self.native_async.is_some() {
1907 for call in &calls {
1908 native_calls.entry(call.id.clone()).or_insert_with(|| {
1909 everruns_provider::native_async::NativeToolCall::Function {
1910 call_id: call.id.clone(),
1911 name: call.name.clone(),
1912 arguments: call.arguments.to_string(),
1913 asynchronous: false,
1914 }
1915 });
1916 }
1917 for call in calls {
1918 if !tool_calls.iter().any(|existing| existing.id == call.id) {
1919 tool_calls.push(call);
1920 }
1921 }
1922 } else {
1923 tool_calls = calls;
1924 }
1925 }
1926 LlmStreamEvent::MessagePhase(phase) => {
1927 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1936 streamed_phase,
1937 phase,
1938 );
1939 }
1940 LlmStreamEvent::Done(metadata) => {
1941 if !buffer_output_deltas
1945 && !pending_delta.is_empty()
1946 && let Err(e) = self
1947 .event_emitter
1948 .emit(EventRequest::new(
1949 session_id,
1950 streaming_event_context.clone(),
1951 OutputMessageDeltaData {
1952 turn_id: context.turn_id,
1953 message_id: output_message_id,
1954 delta: pending_delta.clone(),
1955 accumulated: text.clone(),
1956 phase: streamed_phase,
1957 },
1958 ))
1959 .await
1960 {
1961 tracing::warn!(
1962 session_id = %session_id,
1963 error = %e,
1964 "ReasonAtom: failed to emit final output.message.delta event"
1965 );
1966 }
1967
1968 if !pending_thinking_delta.is_empty()
1970 && let Err(e) = self
1971 .event_emitter
1972 .emit(EventRequest::new(
1973 session_id,
1974 streaming_event_context.clone(),
1975 ReasonThinkingDeltaData {
1976 turn_id: context.turn_id,
1977 delta: pending_thinking_delta.clone(),
1978 accumulated: thinking.clone(),
1979 },
1980 ))
1981 .await
1982 {
1983 tracing::warn!(
1984 session_id = %session_id,
1985 error = %e,
1986 "ReasonAtom: failed to emit final reason.thinking.delta event"
1987 );
1988 }
1989
1990 if !thinking.is_empty()
1992 && let Err(e) = self
1993 .event_emitter
1994 .emit(EventRequest::new(
1995 session_id,
1996 streaming_event_context.clone(),
1997 ReasonThinkingCompletedData {
1998 turn_id: context.turn_id,
1999 thinking: thinking.clone(),
2000 },
2001 ))
2002 .await
2003 {
2004 tracing::warn!(
2005 session_id = %session_id,
2006 error = %e,
2007 "ReasonAtom: failed to emit reason.thinking.completed event"
2008 );
2009 }
2010 termination = StreamTermination::Completed(metadata);
2011 break;
2012 }
2013 LlmStreamEvent::Error(err) => {
2014 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
2019
2020 if has_partial_output {
2021 tracing::warn!(
2022 session_id = %session_id,
2023 error = %err,
2024 tool_call_count = tool_calls.len(),
2025 text_len = text.len(),
2026 "ReasonAtom: trailing stream error after valid output — treating as partial success"
2027 );
2028 termination = StreamTermination::PartialSuccess;
2032 break;
2033 }
2034
2035 if replay_state.should_retry(
2036 &err,
2037 stream_retry_metadata.attempts,
2038 retry_config.max_retries,
2039 ) {
2040 let proposed_wait =
2041 retry_config.calculate_backoff(stream_retry_metadata.attempts);
2042 let Some(wait_duration) = reserve_retry_wait(
2043 &retry_config,
2044 &mut retry_started_at,
2045 proposed_wait,
2046 ) else {
2047 return Err(AgentLoopError::llm_kind(
2048 err.kind(),
2049 format!(
2050 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
2051 stream_retry_metadata.attempts
2052 ),
2053 )
2054 .with_retry_metadata(&stream_retry_metadata));
2055 };
2056 tracing::warn!(
2057 session_id = %session_id,
2058 turn_id = %context.turn_id,
2059 attempt = stream_retry_metadata.attempts + 1,
2060 max_retries = retry_config.max_retries,
2061 wait_secs = wait_duration.as_secs_f64(),
2062 error_code = err.code.as_deref().unwrap_or("none"),
2063 error_status = err.status,
2064 error = %err,
2065 "ReasonAtom: transient stream error before output, retrying"
2066 );
2067 stream_retry_metadata.record_retry(wait_duration, None);
2068 tokio::time::sleep(wait_duration).await;
2069 continue 'stream_attempt;
2070 }
2071
2072 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2074 let event_context = EventContext::from_execution_context(context)
2075 .with_span(
2076 trace_id.to_string(),
2077 Uuid::now_v7().to_string(),
2078 Some(reason_span_id.to_string()),
2079 );
2080 let tools_summary: Vec<ToolDefinitionSummary> =
2081 runtime_agent.tools.iter().map(|t| t.into()).collect();
2082 let generation_data = LlmGenerationData::failure(
2083 messages_for_event.clone(),
2084 tools_summary,
2085 runtime_agent.model.clone(),
2086 Some(model_with_provider.provider_type.to_string()),
2087 err.to_string(),
2088 Some(llm_duration_ms),
2089 time_to_first_token_ms,
2090 );
2091 let _ = self
2092 .event_emitter
2093 .emit(EventRequest::new(
2094 session_id,
2095 event_context,
2096 generation_data,
2097 ))
2098 .await;
2099 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
2100 }
2101 }
2102 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2105 && let Some(ref hb) = self.stream_heartbeater
2106 {
2107 hb.heartbeat(crate::durability::StreamProgress {
2108 accumulated_len: text.len() + thinking.len(),
2109 last_delta_at: last_token_at_unix,
2110 })
2111 .await;
2112 last_stream_heartbeat = Instant::now();
2113 }
2114 }
2115 let (mut completion_metadata, tripped) = termination.into_parts();
2116 if let Some(metadata) = completion_metadata.as_mut() {
2117 metadata.retry_metadata =
2118 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2119 }
2120
2121 break 'stream_attempt (
2122 text,
2123 thinking,
2124 reasoning,
2125 tool_calls,
2126 completion_metadata,
2127 time_to_first_token_ms,
2128 pending_delta,
2129 tripped,
2130 );
2131 };
2132 let (mut text, mut thinking, mut reasoning, mut tool_calls) =
2133 (text, thinking, reasoning, tool_calls);
2134
2135 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
2146 if tripped.is_none()
2147 && !annotation_providers.is_empty()
2148 && !text.is_empty()
2149 && tool_calls.is_empty()
2150 {
2151 text = filter_response_text(
2154 &self.capability_registry,
2155 &resolved_capability_configs,
2156 text,
2157 );
2158 let collected = collect_annotations(
2159 &annotation_providers,
2160 &runtime_agent.system_prompt,
2161 &text,
2162 &messages,
2163 self.utility_llm_service.as_ref(),
2164 )
2165 .await;
2166 text = collected.text;
2167 citation_annotations = collected.annotations;
2168
2169 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
2172 let guarded_output = client_visible_guardrail_text(
2173 &text,
2174 &thinking,
2175 &reasoning,
2176 &citation_annotations,
2177 );
2178 let ctx = PostGenerationOutputContext {
2179 system_prompt: &runtime_agent.system_prompt,
2180 message_text: &guarded_output,
2181 utility_llm_service: self.utility_llm_service.as_ref(),
2182 };
2183 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2184 }
2185
2186 if tripped.is_none()
2189 && !citation_annotations.is_empty()
2190 && !citation_verifiers.is_empty()
2191 {
2192 citation_annotations = verify_annotations(
2193 &citation_verifiers,
2194 &text,
2195 self.utility_llm_service.as_ref(),
2196 citation_annotations,
2197 )
2198 .await;
2199 }
2200 }
2201
2202 if tripped.is_none()
2205 && citation_annotations.is_empty()
2206 && !post_output_providers.is_empty()
2207 && (!text.is_empty() || !thinking.is_empty() || !reasoning.is_empty())
2208 {
2209 let guarded_output = client_visible_guardrail_text(&text, &thinking, &reasoning, &[]);
2210 let ctx = PostGenerationOutputContext {
2211 system_prompt: &runtime_agent.system_prompt,
2212 message_text: &guarded_output,
2213 utility_llm_service: self.utility_llm_service.as_ref(),
2214 };
2215 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2216 }
2217
2218 if tripped.is_some() {
2219 citation_annotations.clear();
2220 }
2221
2222 if tripped.is_none() {
2226 for item in &reasoning {
2227 if let Err(e) = self
2228 .event_emitter
2229 .emit(EventRequest::new(
2230 session_id,
2231 streaming_event_context.clone(),
2232 ReasonItemData {
2233 turn_id: context.turn_id,
2234 provider: item.provider.clone(),
2235 model: Some(llm_config.model.clone()),
2236 item_id: item.item_id.clone().unwrap_or_default(),
2237 summary: item
2238 .display_text()
2239 .filter(|_| !matches!(item.text, Some(ReasoningText::Plain { .. })))
2240 .into_iter()
2241 .collect(),
2242 token_count: item.tokens,
2243 },
2244 ))
2245 .await
2246 {
2247 tracing::warn!(
2248 session_id = %session_id,
2249 error = %e,
2250 "ReasonAtom: failed to emit reason.item event"
2251 );
2252 }
2253 }
2254 }
2255
2256 if buffer_output_deltas
2259 && tripped.is_none()
2260 && !pending_delta.is_empty()
2261 && let Err(e) = self
2262 .event_emitter
2263 .emit(EventRequest::new(
2264 session_id,
2265 streaming_event_context.clone(),
2266 OutputMessageDeltaData {
2267 turn_id: context.turn_id,
2268 message_id: output_message_id,
2269 delta: pending_delta.clone(),
2270 accumulated: text.clone(),
2271 phase: streamed_phase,
2272 },
2273 ))
2274 .await
2275 {
2276 tracing::warn!(
2277 session_id = %session_id,
2278 error = %e,
2279 "ReasonAtom: failed to emit guarded output.message.delta event"
2280 );
2281 }
2282
2283 if let Some(ref t) = tripped {
2289 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2290 trace_id.to_string(),
2291 Uuid::now_v7().to_string(),
2292 Some(reason_span_id.to_string()),
2293 );
2294 if let Err(e) = self
2295 .event_emitter
2296 .emit(EventRequest::new(
2297 session_id,
2298 replaced_event_context,
2299 OutputMessageReplacedData {
2300 turn_id: context.turn_id,
2301 message_id: output_message_id,
2302 guardrail_capability_id: t.capability_id.clone(),
2303 guardrail_id: t.guardrail_id.clone(),
2304 reason_code: t.block.reason_code.clone(),
2305 replacement: t.block.replacement.clone(),
2306 },
2307 ))
2308 .await
2309 {
2310 tracing::warn!(
2311 session_id = %session_id,
2312 error = %e,
2313 "ReasonAtom: failed to emit output.message.replaced event"
2314 );
2315 }
2316 text = t.block.replacement.clone();
2317 tool_calls.clear();
2318 thinking.clear();
2319 reasoning.clear();
2320 }
2321
2322 if !tool_calls.is_empty() {
2326 self.apply_finalized_tool_call_hooks(
2327 session_id,
2328 context,
2329 &resolved_capability_configs,
2330 &runtime_agent.tools,
2331 &mut tool_calls,
2332 iteration,
2333 )
2334 .await;
2335 }
2336
2337 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2338
2339 let response_id = completion_metadata
2341 .as_ref()
2342 .and_then(|meta| meta.response_id.clone());
2343 let finish_reason = completion_metadata
2344 .as_ref()
2345 .and_then(|meta| meta.finish_reason.clone());
2346
2347 let usage = completion_metadata.as_ref().and_then(|meta| {
2355 match (meta.prompt_tokens, meta.completion_tokens) {
2356 (Some(input), Some(output)) => {
2357 let actual_cost_usd = meta.provider_cost_usd;
2358 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2359 &model_with_provider.provider_type,
2360 &runtime_agent.model,
2361 input,
2362 output,
2363 meta.cache_read_tokens.unwrap_or(0),
2364 meta.cache_creation_tokens.unwrap_or(0),
2365 );
2366 Some(
2367 TokenUsage::with_cache(
2368 input,
2369 output,
2370 meta.cache_read_tokens,
2371 meta.cache_creation_tokens,
2372 )
2373 .with_cost(actual_cost_usd, estimated_cost_usd),
2374 )
2375 }
2376 _ => None,
2377 }
2378 });
2379
2380 let event_context = EventContext::from_execution_context(context).with_span(
2382 trace_id.to_string(),
2383 Uuid::now_v7().to_string(),
2384 Some(reason_span_id.to_string()),
2385 );
2386 let tools_summary: Vec<ToolDefinitionSummary> =
2387 runtime_agent.tools.iter().map(|t| t.into()).collect();
2388 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2389 if tool_calls.is_empty() {
2390 "stop".to_string()
2391 } else {
2392 "tool_calls".to_string()
2393 }
2394 })]);
2395 let retry_info = completion_metadata
2397 .as_ref()
2398 .and_then(|meta| meta.retry_metadata.as_ref())
2399 .filter(|rm| rm.had_retries())
2400 .map(|rm| LlmRetryInfo {
2401 attempts: rm.attempts,
2402 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2403 });
2404 let mut generation_data = LlmGenerationData::success_with_retry(
2406 messages_for_event.clone(),
2407 tools_summary,
2408 Some(text.clone()).filter(|s| !s.is_empty()),
2409 tool_calls.clone(),
2410 runtime_agent.model.clone(),
2411 Some(model_with_provider.provider_type.to_string()),
2412 usage.clone(),
2413 Some(llm_duration_ms),
2414 time_to_first_token_ms,
2415 finish_reasons,
2416 response_id.clone(),
2417 retry_info,
2418 );
2419
2420 if let Some(info) = compaction_info {
2426 if let Some(compaction_cost) = info.cost_usd {
2427 match generation_data.metadata.usage.as_mut() {
2428 Some(usage) => {
2429 add_compaction_cost(usage, compaction_cost);
2430 }
2431 None => {
2436 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2437 input_tokens: 0,
2438 output_tokens: 0,
2439 cache_read_tokens: None,
2440 cache_creation_tokens: None,
2441 actual_cost_usd: Some(compaction_cost),
2442 estimated_cost_usd: None,
2443 effective_cost_usd: None,
2444 });
2445 }
2446 }
2447 }
2448 generation_data = generation_data.with_compaction(info);
2449 }
2450
2451 if let Some(request_options) =
2452 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2453 {
2454 generation_data = generation_data.with_request_options(request_options);
2455 }
2456
2457 if let Err(e) = self
2458 .event_emitter
2459 .emit(EventRequest::new(
2460 session_id,
2461 event_context,
2462 generation_data,
2463 ))
2464 .await
2465 {
2466 tracing::warn!(
2467 session_id = %session_id,
2468 error = %e,
2469 "ReasonAtom: failed to emit llm.generation event"
2470 );
2471 }
2472
2473 let mut metadata = std::collections::HashMap::new();
2475 metadata.insert(
2476 "model".to_string(),
2477 serde_json::Value::String(runtime_agent.model.clone()),
2478 );
2479 if let Some(state) = &llm_config.reasoning_state {
2480 metadata.insert(
2481 reasoning_updates::STATE_KEY.to_string(),
2482 serde_json::json!(state),
2483 );
2484 }
2485 if let Some(effort) = llm_config
2486 .reasoning_state
2487 .as_ref()
2488 .and_then(|state| state.effective)
2489 .or(reasoning_effort)
2490 {
2491 metadata.insert(
2492 "reasoning_effort".to_string(),
2493 serde_json::Value::String(effort.as_str().to_string()),
2494 );
2495 }
2496 metadata.insert(
2503 "provider".to_string(),
2504 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2505 );
2506 if let Some(ref rid) = response_id {
2507 metadata.insert(
2508 "response_id".to_string(),
2509 serde_json::Value::String(rid.clone()),
2510 );
2511 }
2512
2513 let text = filter_response_text(
2517 &self.capability_registry,
2518 &resolved_capability_configs,
2519 text,
2520 );
2521 let has_tool_calls = !tool_calls.is_empty();
2522 let mut assistant_message = if has_tool_calls {
2523 Message::assistant_with_tools(&text, tool_calls.clone())
2524 } else {
2525 Message::assistant(&text)
2526 }
2527 .with_id(output_message_id);
2528 for part in &mut assistant_message.content {
2529 if let crate::message::ContentPart::ToolCall(call) = part {
2530 call.native = native_calls.get(&call.id).cloned();
2531 }
2532 }
2533 if !citation_annotations.is_empty() {
2536 for part in assistant_message.content.iter_mut() {
2537 if let crate::message::ContentPart::Text(t) = part {
2538 t.annotations = std::mem::take(&mut citation_annotations);
2539 break;
2540 }
2541 }
2542 }
2543 let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2547 let provider_phase = completion_metadata
2551 .as_ref()
2552 .and_then(|meta| meta.phase.as_deref())
2553 .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2554 let (phase, phase_source) = match provider_phase {
2555 Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2556 None => (
2557 everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2558 everruns_provider::PhaseSource::Derived,
2559 ),
2560 };
2561 assistant_message.phase = Some(phase);
2562 assistant_message.phase_source = Some(phase_source);
2563 assistant_message.metadata = Some(metadata);
2564 if reasoning.is_empty() && !thinking.is_empty() {
2573 reasoning.push(
2574 ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2575 ReasoningText::Plain {
2576 text: thinking.clone(),
2577 },
2578 ),
2579 );
2580 }
2581 if !reasoning.is_empty() {
2582 let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2583 content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2584 content.append(&mut assistant_message.content);
2585 assistant_message.content = content;
2586 }
2587 let message_event_context = EventContext::from_execution_context(context).with_span(
2590 trace_id.to_string(),
2591 Uuid::now_v7().to_string(),
2592 Some(reason_span_id.to_string()),
2593 );
2594 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2595 if let Some(ref u) = usage {
2596 output_message_data = output_message_data.with_usage(u.clone());
2597 }
2598 let result = ReasonResult {
2599 native_counts: None,
2600 success: true,
2601 text,
2602 tool_calls,
2603 has_tool_calls,
2604 tool_definitions: runtime_agent.tools.clone(),
2605 max_iterations: runtime_agent.max_iterations,
2606 error: None,
2607 user_facing_error: None,
2608 error_disclosure: None,
2609 usage,
2610 output_message_id: Some(output_message_id),
2611 time_to_first_token_ms,
2612 response_id,
2613 finish_reason,
2614 locale: resolved_locale,
2615 network_access: runtime_agent.network_access.clone(),
2616 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2617 };
2618 if let Some(coordinator) = &self.native_async {
2619 coordinator
2620 .lock()
2621 .await
2622 .stage_transcript_result(
2623 serde_json::to_value(&result)
2624 .map_err(|error| AgentLoopError::store(error.to_string()))?,
2625 )
2626 .await?;
2627 }
2628 self.event_emitter
2629 .emit(EventRequest::new(
2630 session_id,
2631 message_event_context,
2632 output_message_data,
2633 ))
2634 .await?;
2635
2636 if let Some(coordinator) = &self.native_async {
2637 coordinator
2638 .lock()
2639 .await
2640 .transcript_committed(&output_message_id.to_string())
2641 .await?;
2642 }
2643 tracing::info!(
2644 session_id = %session_id,
2645 turn_id = %context.turn_id,
2646 has_tool_calls = %result.has_tool_calls,
2647 tool_count = %result.tool_calls.len(),
2648 "ReasonAtom: LLM call completed"
2649 );
2650
2651 Ok(result)
2652 }
2653
2654 async fn finalize_partial_stream(
2659 &self,
2660 session_id: SessionId,
2661 context: &ExecutionContext,
2662 partial: PartialStreamState,
2663 iteration: u32,
2664 runtime_agent: &crate::RuntimeAgent,
2665 resolved_capability_configs: &[crate::CapabilityRef],
2666 ) -> Result<ReasonResult> {
2667 let event_context = EventContext::from_execution_context(context);
2668 let turn_id = context.turn_id;
2669 let message_id = partial.message_id;
2670
2671 let _ = self
2673 .event_emitter
2674 .emit(EventRequest::new(
2675 session_id,
2676 event_context.clone(),
2677 OutputMessageStartedData {
2678 reasoning_state: partial.reasoning_state.clone(),
2679 turn_id,
2680 message_id,
2681 model: None,
2682 iteration: Some(iteration),
2683 phase: None,
2686 },
2687 ))
2688 .await;
2689
2690 let accumulated = filter_response_text(
2693 &self.capability_registry,
2694 resolved_capability_configs,
2695 partial.accumulated,
2696 );
2697 let mut assistant_message = Message::assistant(&accumulated).with_id(message_id);
2698 if let Some(state) = partial.reasoning_state {
2699 assistant_message.metadata = Some(HashMap::from([
2700 ("model".into(), serde_json::json!("gpt-6-astra")),
2701 ("provider".into(), serde_json::json!("openai")),
2702 (
2703 reasoning_updates::STATE_KEY.into(),
2704 serde_json::json!(state),
2705 ),
2706 (
2707 "reasoning_effort".into(),
2708 serde_json::json!(state.effective),
2709 ),
2710 ]));
2711 }
2712 let output_message_id = message_id;
2713 self.event_emitter
2714 .emit(EventRequest::new(
2715 session_id,
2716 event_context.clone(),
2717 OutputMessageCompletedData::new(assistant_message),
2718 ))
2719 .await?;
2720
2721 let accumulated_len = accumulated.len();
2723 let _ = self
2724 .event_emitter
2725 .emit(EventRequest::new(
2726 session_id,
2727 event_context.clone(),
2728 ReasonRecoveredData {
2729 turn_id,
2730 mode: RecoveryMode::Finalize,
2731 accumulated_len,
2732 },
2733 ))
2734 .await;
2735
2736 tracing::info!(
2737 session_id = %session_id,
2738 turn_id = %turn_id,
2739 accumulated_len,
2740 "ReasonAtom: finalized partial stream from persisted accumulated text"
2741 );
2742
2743 Ok(ReasonResult {
2744 native_counts: None,
2745 success: true,
2746 text: accumulated,
2747 tool_calls: vec![],
2748 has_tool_calls: false,
2749 tool_definitions: runtime_agent.tools.clone(),
2750 max_iterations: runtime_agent.max_iterations,
2751 error: None,
2752 user_facing_error: None,
2753 error_disclosure: None,
2754 usage: None,
2755 output_message_id: Some(output_message_id),
2756 time_to_first_token_ms: None,
2757 response_id: None,
2758 finish_reason: Some("stop".to_string()),
2759 locale: None,
2760 network_access: None,
2761 parallel_tool_calls: None,
2763 })
2764 }
2765
2766 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2777 let mut resolved = HashMap::new();
2778
2779 let resolver = match &self.image_resolver {
2781 Some(r) => r,
2782 None => return resolved,
2783 };
2784
2785 let image_ids: Vec<Uuid> = messages
2787 .iter()
2788 .flat_map(crate::llm_conversions::extract_image_file_ids)
2789 .collect::<std::collections::HashSet<_>>()
2790 .into_iter()
2791 .collect();
2792
2793 if image_ids.is_empty() {
2794 return resolved;
2795 }
2796
2797 tracing::debug!(
2798 image_count = image_ids.len(),
2799 "ReasonAtom: resolving image_file references"
2800 );
2801
2802 for image_id in image_ids {
2804 match resolver.resolve_image(image_id).await {
2805 Ok(Some(image)) => {
2806 resolved.insert(image_id, image);
2807 }
2808 Ok(None) => {
2809 tracing::warn!(
2810 image_id = %image_id,
2811 "ReasonAtom: image not found during resolution"
2812 );
2813 }
2814 Err(e) => {
2815 tracing::warn!(
2816 image_id = %image_id,
2817 error = %e,
2818 "ReasonAtom: failed to resolve image"
2819 );
2820 }
2821 }
2822 }
2823
2824 tracing::debug!(
2825 resolved_count = resolved.len(),
2826 "ReasonAtom: image resolution complete"
2827 );
2828
2829 resolved
2830 }
2831
2832 async fn resolve_files(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedFile> {
2833 let Some(resolver) = &self.file_resolver else {
2834 return HashMap::new();
2835 };
2836
2837 let file_ids: Vec<Uuid> = messages
2838 .iter()
2839 .flat_map(crate::llm_conversions::extract_file_ids)
2840 .collect::<std::collections::HashSet<_>>()
2841 .into_iter()
2842 .collect();
2843
2844 if file_ids.is_empty() {
2845 return HashMap::new();
2846 }
2847
2848 match resolver.resolve_files(&file_ids).await {
2849 Ok(map) => map,
2850 Err(e) => {
2851 tracing::warn!(
2852 target: "reason",
2853 "ReasonAtom: file resolution failed: {e}"
2854 );
2855 HashMap::new()
2856 }
2857 }
2858 }
2859}
2860
2861#[cfg(test)]
2866mod tests;