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 judgment_service: Option<Arc<dyn crate::JudgmentService>>,
317 schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
323 compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
325}
326
327impl ReasonAtom {
328 pub fn with_native_async(
329 mut self,
330 coordinator: Arc<tokio::sync::Mutex<crate::native_async::NativeAsyncCoordinator>>,
331 ) -> Self {
332 self.native_async = Some(coordinator);
333 self
334 }
335
336 pub fn new(
338 context_resolver: impl TurnContextResolver + 'static,
339 message_retriever: impl MessageRetriever + 'static,
340 capability_registry: CapabilityRegistry,
341 event_emitter: impl PhaseEffectSink + 'static,
342 ) -> Self {
343 Self {
344 native_async: None,
345 context_resolver: Arc::new(context_resolver),
346 message_retriever: Arc::new(message_retriever),
347 capability_registry,
348 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
349 image_resolver: None,
350 file_resolver: None,
351 stream_heartbeater: None,
352 provider_stall_timeout: None,
353 provider_retry_config: LlmRetryConfig::default(),
354 durable_tool_result_store: None,
355 partial_stream_store: None,
356 reasoning_effort_handle: None,
357 utility_llm_service: None,
358 judgment_service: None,
359 schedule_store: None,
360 compaction_checkpoint_store: None,
361 }
362 }
363
364 pub fn with_schedule_store(
367 mut self,
368 store: Arc<dyn crate::session_services::SessionScheduleStore>,
369 ) -> Self {
370 self.schedule_store = Some(store);
371 self
372 }
373
374 pub fn with_compaction_checkpoint_store(
375 mut self,
376 store: Arc<dyn crate::CompactionCheckpointStore>,
377 ) -> Self {
378 self.compaction_checkpoint_store = Some(store);
379 self
380 }
381
382 fn collect_llm_error_hooks(
388 &self,
389 resolved_capability_configs: &[crate::CapabilityRef],
390 ) -> Vec<(
391 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
392 serde_json::Value,
393 )> {
394 resolved_capability_configs
395 .iter()
396 .filter_map(|cfg| {
397 let cap = self.capability_registry.get(cfg.capability_id())?;
398 let hook = cap.llm_error_hook()?;
399 Some((hook, cfg.config_value().clone()))
400 })
401 .collect()
402 }
403
404 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
417 self.image_resolver = Some(resolver);
418 self
419 }
420
421 pub fn with_file_resolver(mut self, resolver: Arc<dyn FileResolver>) -> Self {
422 self.file_resolver = Some(resolver);
423 self
424 }
425
426 pub fn with_stream_heartbeater(
428 mut self,
429 heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
430 ) -> Self {
431 self.stream_heartbeater = Some(heartbeater);
432 self
433 }
434
435 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
438 self.provider_stall_timeout = Some(timeout);
439 self
440 }
441
442 pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
444 self.provider_retry_config = config;
445 self
446 }
447
448 pub fn with_durable_tool_result_store(
454 mut self,
455 store: Arc<dyn DurableToolResultStore>,
456 ) -> Self {
457 self.durable_tool_result_store = Some(store);
458 self
459 }
460
461 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
463 self.partial_stream_store = Some(store);
464 self
465 }
466
467 pub fn with_reasoning_effort_handle(
474 mut self,
475 handle: crate::tool_context::ReasoningEffortHandle,
476 ) -> Self {
477 self.reasoning_effort_handle = Some(handle);
478 self
479 }
480
481 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
484 self.utility_llm_service = Some(service);
485 self
486 }
487
488 pub fn with_judgment_service(mut self, service: Arc<dyn crate::JudgmentService>) -> Self {
491 self.judgment_service = Some(service);
492 self
493 }
494}
495
496impl ReasonAtom {
497 pub fn name(&self) -> &'static str {
499 "reason"
500 }
501
502 pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
504 self.execute_inner(input, None).await
505 }
506}
507
508impl ReasonAtom {
509 pub async fn execute_with_assembled_context(
514 &self,
515 input: ReasonInput,
516 assembled: AssembledTurnContext,
517 ) -> Result<ReasonResult> {
518 self.execute_inner(input, Some(assembled)).await
519 }
520
521 async fn emit_capability_usage_snapshot(
522 &self,
523 session_id: SessionId,
524 context: &ExecutionContext,
525 resolved_capability_configs: &[crate::CapabilityRef],
526 tool_definitions: &[ToolDefinition],
527 ) {
528 let records = capability_usage_snapshot_records(
529 &self.capability_registry,
530 resolved_capability_configs,
531 tool_definitions,
532 );
533 if records.is_empty() {
534 return;
535 }
536
537 if let Err(error) = self
538 .event_emitter
539 .emit(EventRequest::new(
540 session_id,
541 EventContext::from_execution_context(context),
542 CapabilityUsageData { records },
543 ))
544 .await
545 {
546 tracing::warn!(
547 session_id = %session_id,
548 error = %error,
549 "ReasonAtom: failed to emit capability.usage event"
550 );
551 }
552 }
553
554 async fn apply_finalized_tool_call_hooks(
557 &self,
558 session_id: SessionId,
559 context: &ExecutionContext,
560 resolved_capability_configs: &[crate::CapabilityRef],
561 tool_definitions: &[ToolDefinition],
562 tool_calls: &mut [ToolCall],
563 iteration: u32,
564 ) {
565 apply_finalized_tool_calls_hooks(
566 &self.capability_registry,
567 self.event_emitter.as_ref(),
568 session_id,
569 context,
570 resolved_capability_configs,
571 tool_definitions,
572 tool_calls,
573 iteration,
574 )
575 .await;
576 }
577
578 async fn execute_inner(
579 &self,
580 input: ReasonInput,
581 assembled: Option<AssembledTurnContext>,
582 ) -> Result<ReasonResult> {
583 let ReasonInput {
584 context,
585 harness_id,
586 agent_id,
587 org_id,
588 mcp_tool_definitions,
589 previous_response_id,
590 iteration,
591 } = input;
592
593 tracing::info!(
594 session_id = %context.session_id,
595 turn_id = %context.turn_id,
596 exec_id = %context.exec_id,
597 harness_id = %harness_id,
598 agent_id = ?agent_id,
599 mcp_tools_count = %mcp_tool_definitions.len(),
600 "ReasonAtom: starting LLM call"
601 );
602
603 let trace_id = context.turn_id.to_string();
611 let reason_span_id = Uuid::now_v7().to_string();
612 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
616 trace_id.clone(),
617 reason_span_id.clone(),
618 Some(parent_span_id.clone()),
619 );
620
621 let reason_start = Instant::now();
623
624 if let Err(e) = self
626 .event_emitter
627 .emit(EventRequest::new(
628 context.session_id,
629 event_context.clone(),
630 ReasonStartedData {
631 harness_id,
632 agent_id,
633 metadata: None, },
635 ))
636 .await
637 {
638 tracing::warn!(
639 session_id = %context.session_id,
640 error = %e,
641 "ReasonAtom: failed to emit reason.started event"
642 );
643 }
644
645 let assembled = match assembled {
649 Some(assembled) => Ok(assembled),
650 None => {
651 self.context_resolver
652 .resolve_turn_context(TurnContextRequest {
653 session_id: context.session_id,
654 harness_id,
655 agent_id,
656 mcp_tool_definitions: mcp_tool_definitions.clone(),
657 })
658 .await
659 }
660 };
661
662 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
663 Ok(assembled) => {
664 let error_disclosure = resolve_error_disclosure(
665 &self.capability_registry,
666 &assembled.resolved_capability_configs,
667 error_disclosure_override(&assembled.messages).as_deref(),
668 );
669 let error_hooks =
673 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
674 let error_context = UserFacingErrorContext::default()
675 .with_provider(assembled.model.provider_type.to_string())
676 .with_model_id(assembled.model.model.clone());
677 let call_result = self
678 .execute_llm_call(
679 context.session_id,
680 harness_id,
681 agent_id,
682 org_id,
683 &context,
684 &trace_id,
685 &reason_span_id,
686 previous_response_id,
687 iteration,
688 assembled,
689 )
690 .await;
691 (error_disclosure, error_context, error_hooks, call_result)
692 }
693 Err(error) => (
694 ErrorDisclosure::default(),
695 UserFacingErrorContext::default(),
696 Vec::new(),
697 Err(error),
698 ),
699 };
700
701 let result = match call_result {
703 Ok(result) => {
704 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
706
707 let completed_context = EventContext::from_execution_context(&context).with_span(
709 trace_id.clone(),
710 reason_span_id.clone(), Some(parent_span_id.clone()),
712 );
713 if let Err(e) = self
714 .event_emitter
715 .emit(EventRequest::new(
716 context.session_id,
717 completed_context,
718 ReasonCompletedData::success(
719 &result.text,
720 result.has_tool_calls,
721 result.tool_calls.len() as u32,
722 Some(reason_duration_ms),
723 result.usage.clone(),
724 ),
725 ))
726 .await
727 {
728 tracing::warn!(
729 session_id = %context.session_id,
730 error = %e,
731 "ReasonAtom: failed to emit reason.completed event"
732 );
733 }
734 result
735 }
736 Err(e) => {
737 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
739
740 tracing::warn!(
743 session_id = %context.session_id,
744 turn_id = %context.turn_id,
745 error = %e,
746 "ReasonAtom: LLM call failed"
747 );
748
749 let error_msg = e.to_string();
750 let mut source_error = e.user_facing_error(error_context);
751
752 let is_transient = e.is_transient_llm_error()
759 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
760
761 if !is_transient && !error_hooks.is_empty() {
767 let services = crate::llm_error_hook::LlmErrorHookServices {
768 schedule_store: self.schedule_store.clone(),
769 };
770 for (hook, config) in &error_hooks {
771 let outcome = {
772 let ctx = crate::llm_error_hook::LlmErrorContext {
773 session_id: context.session_id,
774 error_code: &source_error.code,
775 error_fields: &source_error.fields,
776 config,
777 services: &services,
778 };
779 hook.on_llm_error(&ctx).await
780 };
781 for (key, value) in outcome.extra_error_fields {
782 source_error = source_error.with_field(key, value);
783 }
784 }
785 }
786
787 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
788 let user_error_text = user_error.fallback_message();
789
790 let mut output_message_id = None;
791
792 if !is_transient {
793 let mut error_message = Message::assistant(&user_error_text);
795 let mut metadata = std::collections::HashMap::new();
796 user_error.apply_to_message_metadata(&mut metadata);
797 UserFacingError::apply_disclosure_to_message_metadata(
798 &mut metadata,
799 error_disclosure,
800 &source_error.code,
801 );
802 error_message.metadata = Some(metadata);
803
804 output_message_id = Some(error_message.id);
805
806 let error_msg_context = EventContext::from_execution_context(&context)
809 .with_span(
810 trace_id.clone(),
811 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
814 if let Err(emit_err) = self
815 .event_emitter
816 .emit(EventRequest::new(
817 context.session_id,
818 error_msg_context,
819 OutputMessageCompletedData::new(error_message)
820 .with_user_facing_error(&user_error)
821 .with_error_disclosure(error_disclosure),
822 ))
823 .await
824 {
825 tracing::warn!(
826 session_id = %context.session_id,
827 error = %emit_err,
828 "ReasonAtom: failed to emit output.message.completed event for error"
829 );
830 }
831 } else {
832 tracing::info!(
833 session_id = %context.session_id,
834 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
835 );
836 }
837
838 let completed_context = EventContext::from_execution_context(&context).with_span(
840 trace_id.clone(),
841 reason_span_id.clone(), Some(parent_span_id.clone()),
843 );
844 if let Err(emit_err) = self
845 .event_emitter
846 .emit(EventRequest::new(
847 context.session_id,
848 completed_context,
849 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
850 ))
851 .await
852 {
853 tracing::warn!(
854 session_id = %context.session_id,
855 error = %emit_err,
856 "ReasonAtom: failed to emit reason.completed event"
857 );
858 }
859
860 ReasonResult {
861 native_counts: None,
862 success: false,
863 text: user_error_text,
864 tool_calls: vec![],
865 has_tool_calls: false,
866 tool_definitions: vec![],
867 max_iterations: default_max_iterations(),
868 error: Some(error_msg.clone()),
869 user_facing_error: Some(user_error),
870 error_disclosure: Some(error_disclosure),
871 usage: None,
872 output_message_id,
873 time_to_first_token_ms: None,
874 response_id: None,
875 finish_reason: error_msg
876 .to_ascii_lowercase()
877 .contains("model refused")
878 .then(|| "refusal".to_string()),
879 locale: None,
880 network_access: None,
881 parallel_tool_calls: None,
882 }
883 }
884 };
885
886 Ok(result)
887 }
888
889 #[allow(clippy::too_many_arguments)]
891 async fn execute_llm_call(
892 &self,
893 session_id: SessionId,
894 harness_id: HarnessId,
895 agent_id: Option<AgentId>,
896 org_id: i64,
897 context: &ExecutionContext,
898 trace_id: &str,
899 reason_span_id: &str,
900 previous_response_id: Option<String>,
901 iteration: u32,
902 assembled: AssembledTurnContext,
903 ) -> Result<ReasonResult> {
904 let prior_usage = assembled.cumulative_usage();
905 let mut messages = transcript::order_native_results(assembled.messages);
906 let mut message_source_sequence = assembled.message_source_sequence;
907 let model_with_provider = assembled.model;
908 let resolved_model_id = assembled.resolved_model_id;
909 let resolved_locale = assembled.resolved_locale;
910 let compaction_policy = assembled.compaction_policy;
911 let resolved_capability_configs = assembled.resolved_capability_configs;
912 let runtime_agent = assembled.runtime_agent;
913 let embedder_metadata = assembled.embedder_metadata;
914
915 self.emit_capability_usage_snapshot(
916 session_id,
917 context,
918 &resolved_capability_configs,
919 &runtime_agent.tools,
920 )
921 .await;
922
923 let output_hooks =
924 collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
925 let guardrail_providers = output_hooks.streaming;
926 let post_output_providers = output_hooks.post_generation;
927 let annotation_providers = output_hooks.annotations;
928 let citation_verifiers = output_hooks.citation_verifiers;
929
930 let chat_driver = Arc::clone(&model_with_provider.driver);
932 let stateful_response_continuation =
933 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
934 let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
935 let mut checkpoint_suffix_message_count = 0usize;
936 let native_reasoning_compaction = compaction_policy.as_ref().is_none_or(|policy| {
937 matches!(
938 policy.settings().strategy,
939 crate::compaction_policy::CompactionStrategy::Native
940 | crate::compaction_policy::CompactionStrategy::Auto
941 ) && chat_driver.supports_compact()
942 });
943
944 if compaction_policy.is_some()
945 && let Some(store) = self.compaction_checkpoint_store.as_ref()
946 && let Some(checkpoint) = store
947 .get_latest(
948 session_id,
949 model_with_provider.provider_type.as_str(),
950 &model_with_provider.model,
951 )
952 .await?
953 && checkpoint.is_compatible(
954 model_with_provider.provider_type.as_str(),
955 &model_with_provider.model,
956 )
957 && (native_reasoning_compaction || !matches!(
960 &checkpoint.payload,
961 crate::CompactionCheckpointPayload::ProviderOpaque {
962 context: crate::ProviderOpaqueContext::OpenResponsesCompact {
963 reasoning_state: Some(_), ..
964 }
965 }
966 ))
967 {
968 let filters = crate::capabilities::collect_message_filters_only(
969 &resolved_capability_configs,
970 &self.capability_registry,
971 );
972 let mut query =
973 crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
974 filters.apply_message_filters(&mut query);
975 let history = self.message_retriever.load_filtered_history(query).await?;
976 messages = history.messages;
977 checkpoint_suffix_message_count = messages.len();
978 filters.apply_post_load_filters(&mut messages);
979 if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
980 messages.insert(
981 0,
982 Message::system(format!(
983 "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
984 )),
985 );
986 }
987 message_source_sequence = history.source_sequence.or(message_source_sequence);
988 restored_checkpoint = Some(checkpoint);
989 }
990
991 let controls = resolve_request_controls(
992 &messages,
993 self.reasoning_effort_handle.as_ref(),
994 &model_with_provider.provider_type,
995 &model_with_provider.model,
996 );
997 let reasoning_effort = controls.reasoning_effort;
998 let speed = controls.speed;
999 let verbosity = controls.verbosity;
1000 let checkpoint_reasoning =
1001 restored_checkpoint
1002 .as_ref()
1003 .and_then(|checkpoint| match &checkpoint.payload {
1004 crate::CompactionCheckpointPayload::ProviderOpaque {
1005 context:
1006 crate::ProviderOpaqueContext::OpenResponsesCompact {
1007 reasoning_state, ..
1008 },
1009 } => reasoning_state.as_ref(),
1010 _ => None,
1011 });
1012 let mut reasoning_replay = reasoning_updates::prepare(
1013 &messages,
1014 model_with_provider.provider_type.as_str(),
1015 &model_with_provider.model,
1016 reasoning_effort,
1017 self.reasoning_effort_handle
1018 .as_ref()
1019 .and_then(crate::tool_context::ReasoningEffortHandle::get),
1020 checkpoint_reasoning,
1021 )
1022 .filter(|_| native_reasoning_compaction);
1023
1024 if let Some(ref store) = self.partial_stream_store {
1028 let turn_id_str = context.turn_id.to_string();
1029 match store.get_partial_stream(session_id, &turn_id_str).await {
1030 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
1031 return self
1033 .finalize_partial_stream(
1034 session_id,
1035 context,
1036 partial,
1037 iteration,
1038 &runtime_agent,
1039 &resolved_capability_configs,
1040 )
1041 .await;
1042 }
1043 Ok(Some(partial)) => {
1044 if let (Some(replay), Some(mut saved)) =
1045 (reasoning_replay.as_mut(), partial.reasoning_state)
1046 {
1047 saved.pending = saved.effective;
1050 replay.state = saved;
1051 }
1052 let recovery_ctx = EventContext::from_execution_context(context);
1055 let _ = self
1056 .event_emitter
1057 .emit(EventRequest::new(
1058 session_id,
1059 recovery_ctx,
1060 ReasonRecoveredData {
1061 turn_id: context.turn_id,
1062 mode: RecoveryMode::Restart,
1063 accumulated_len: 0,
1064 },
1065 ))
1066 .await;
1067 tracing::info!(
1068 session_id = %session_id,
1069 turn_id = %context.turn_id,
1070 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1071 );
1072 }
1073 Ok(None) => {} Err(e) => {
1075 if reasoning_replay.is_some() {
1076 return Err(e);
1077 }
1078 tracing::warn!(
1080 session_id = %session_id,
1081 turn_id = %context.turn_id,
1082 error = %e,
1083 "ReasonAtom: partial-stream store error; proceeding with normal execution"
1084 );
1085 }
1086 }
1087 }
1088
1089 let repair_event_context = EventContext::from_execution_context(context);
1093 let patched_messages = if self.native_async.is_some() {
1094 messages.clone()
1095 } else {
1096 repair_dangling_tool_calls(
1097 &messages,
1098 self.durable_tool_result_store.as_deref(),
1099 self.event_emitter.as_ref(),
1100 session_id,
1101 &repair_event_context,
1102 &context.turn_id.to_string(),
1103 )
1104 .await
1105 };
1106 let raw_tool_result_bytes = compaction_policy
1107 .as_ref()
1108 .map(|policy| policy.total_tool_result_bytes(&patched_messages))
1109 .unwrap_or(0);
1110
1111 let model_view_providers = crate::capabilities::collect_model_view_providers(
1114 &resolved_capability_configs,
1115 &self.capability_registry,
1116 Some(model_with_provider.model.as_str()),
1117 );
1118 let model_view_context = crate::capabilities::ModelViewContext {
1119 session_id,
1120 prior_usage: prior_usage.as_ref(),
1121 };
1122 let mut context_messages =
1123 model_view_providers.apply_model_view(patched_messages, &model_view_context);
1124 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1125 &context_messages,
1126 stateful_response_continuation || restored_checkpoint.is_some(),
1127 );
1128
1129 let mut volatile_suffix_len = 0usize;
1136 {
1137 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1138 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1139 &resolved_capability_configs,
1140 &self.capability_registry,
1141 Some(model_with_provider.model.as_str()),
1142 &facts_ctx,
1143 );
1144 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1145 context_messages.push(Message::user(block));
1146 volatile_suffix_len = 1;
1147 }
1148 }
1149
1150 if let Some(context) = runtime_agent.conversation_context.as_ref()
1159 && !context.is_empty()
1160 {
1161 context_messages.insert(0, Message::user(context.clone()));
1162 }
1163
1164 let resolved_images = self.resolve_images(&context_messages).await;
1169 let resolved_files = self.resolve_files(&context_messages).await;
1170
1171 let mut llm_messages = Vec::new();
1173
1174 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1176 if has_system_prompt {
1177 llm_messages.push(LlmMessage {
1178 native_tool_calls: Vec::new(),
1179 role: LlmMessageRole::System,
1180 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1181 tool_calls: None,
1182 tool_call_id: None,
1183 phase: None,
1184 reasoning: Vec::new(),
1185 configuration_update: None,
1186 });
1187 }
1188
1189 let messages_for_event: Vec<Message> = if has_system_prompt {
1191 std::iter::once(Message::system(&runtime_agent.system_prompt))
1192 .chain(context_messages.iter().cloned())
1193 .collect()
1194 } else {
1195 context_messages.clone()
1196 };
1197
1198 let mut stripped_error_count = 0u32;
1204 for msg in &context_messages {
1205 if is_error_placeholder_message(msg) {
1206 stripped_error_count += 1;
1207 continue;
1208 }
1209 let mut llm_msg = crate::llm_conversions::llm_message_from_message_with_attachments(
1210 msg,
1211 &resolved_images,
1212 &resolved_files,
1213 );
1214 llm_msg.configuration_update = reasoning_replay
1215 .as_ref()
1216 .and_then(|replay| replay.transitions.get(&msg.id).copied());
1217 if msg.role == MessageRole::User
1218 && let Some(ref actor) = msg.external_actor
1219 {
1220 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1221 }
1222 llm_messages.push(llm_msg);
1223 }
1224 if stripped_error_count > 0 {
1225 tracing::info!(
1226 session_id = %session_id,
1227 stripped_error_count,
1228 "ReasonAtom: stripped error placeholder messages from LLM input"
1229 );
1230 }
1231
1232 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1237 llm_messages,
1238 stateful_response_continuation || restored_checkpoint.is_some(),
1239 );
1240
1241 let mut llm_config_builder =
1243 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1244 if let Some(effort) = reasoning_effort {
1245 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1246 }
1247 if let Some(speed) = speed {
1248 llm_config_builder = llm_config_builder.speed(speed);
1249 }
1250 if let Some(verbosity) = verbosity {
1251 llm_config_builder = llm_config_builder.verbosity(verbosity);
1252 }
1253
1254 for (k, v) in &embedder_metadata {
1256 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1257 }
1258
1259 llm_config_builder = llm_config_builder
1263 .with_metadata("session_id", session_id.to_string())
1264 .with_metadata("harness_id", harness_id.to_string())
1265 .with_metadata("turn_id", context.turn_id.to_string())
1266 .with_metadata("exec_id", context.exec_id.to_string())
1267 .with_metadata("org_id", format!("org_{:032x}", org_id));
1268 if let Some(agent_id) = agent_id {
1269 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1270 }
1271
1272 if let Some(model_id) = &resolved_model_id {
1274 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1275 }
1276
1277 let mut llm_config = llm_config_builder
1278 .previous_response_id(previous_response_id.clone())
1279 .volatile_suffix_len(volatile_suffix_len)
1280 .build();
1281 if let Some(replay) = &reasoning_replay {
1282 llm_config.reasoning_effort = replay.state.baseline;
1283 llm_config.reasoning_state = Some(replay.state.clone());
1284 if replay.reset_continuation {
1285 llm_config.previous_response_id = None;
1286 }
1287 } else if messages
1288 .iter()
1289 .rev()
1290 .find(|message| {
1291 message.role == MessageRole::Agent && !is_error_placeholder_message(message)
1292 })
1293 .and_then(|message| message.metadata.as_ref())
1294 .is_some_and(|metadata| metadata.contains_key(reasoning_updates::STATE_KEY))
1295 {
1296 llm_config.previous_response_id = None;
1299 }
1300 if let Some(checkpoint) = restored_checkpoint.as_ref()
1301 && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1302 &checkpoint.payload
1303 {
1304 llm_config.previous_response_id = None;
1305 llm_config.provider_opaque_context = Some(context.clone());
1306 }
1307
1308 tracing::debug!(
1309 session_id = %session_id,
1310 turn_id = %context.turn_id,
1311 model = %runtime_agent.model,
1312 message_count = %llm_messages.len(),
1313 "ReasonAtom: calling LLM"
1314 );
1315
1316 let streaming_event_context = EventContext::from_execution_context(context);
1319
1320 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1327 for (cap_id, cfg, provider) in &guardrail_providers {
1328 let ctx = OutputGuardrailContext {
1329 system_prompt: &runtime_agent.system_prompt,
1330 config: cfg,
1331 };
1332 let guardrail_id = provider.id().to_string();
1333 if let Some(run) = provider.arm(&ctx) {
1334 armed_guardrails.push(ArmedGuardrail {
1335 capability_id: cap_id.clone(),
1336 guardrail_id,
1337 run,
1338 });
1339 }
1340 }
1341 let buffer_output_deltas = !post_output_providers.is_empty();
1346 let output_message_id = MessageId::new();
1350 tracing::info!(
1351 session_id = %session_id,
1352 turn_id = %context.turn_id,
1353 "ReasonAtom: emitting output.message.started event"
1354 );
1355 if let Err(e) = self
1356 .event_emitter
1357 .emit(EventRequest::new(
1358 session_id,
1359 streaming_event_context.clone(),
1360 OutputMessageStartedData {
1361 reasoning_state: llm_config.reasoning_state.clone(),
1362 turn_id: context.turn_id,
1363 message_id: output_message_id,
1364 model: Some(runtime_agent.model.clone()),
1365 iteration: Some(iteration),
1366 phase: None,
1369 },
1370 ))
1371 .await
1372 {
1373 if llm_config.reasoning_state.is_some() {
1374 return Err(e);
1375 }
1376 tracing::warn!(
1377 session_id = %session_id,
1378 error = %e,
1379 "ReasonAtom: failed to emit output.message.started event"
1380 );
1381 } else {
1382 tracing::info!(
1383 session_id = %session_id,
1384 "ReasonAtom: output.message.started event emitted successfully"
1385 );
1386 }
1387
1388 let thinking_enabled = reasoning_effort.is_some();
1390 if thinking_enabled {
1391 tracing::info!(
1392 session_id = %session_id,
1393 turn_id = %context.turn_id,
1394 "ReasonAtom: emitting reason.thinking.started event"
1395 );
1396 if let Err(e) = self
1397 .event_emitter
1398 .emit(EventRequest::new(
1399 session_id,
1400 streaming_event_context.clone(),
1401 ReasonThinkingStartedData {
1402 turn_id: context.turn_id,
1403 model: Some(runtime_agent.model.clone()),
1404 },
1405 ))
1406 .await
1407 {
1408 tracing::warn!(
1409 session_id = %session_id,
1410 error = %e,
1411 "ReasonAtom: failed to emit reason.thinking.started event"
1412 );
1413 } else {
1414 tracing::info!(
1415 session_id = %session_id,
1416 "ReasonAtom: reason.thinking.started event emitted successfully"
1417 );
1418 }
1419 }
1420
1421 let llm_start = Instant::now();
1423
1424 let mut compaction_info: Option<LlmCompactionInfo> = None;
1428 let mut llm_messages_for_call = llm_messages.clone();
1429
1430 if let Some(policy) = compaction_policy.as_deref() {
1431 compaction_info = apply_proactive_compaction(
1432 ProactiveCompactionContext {
1433 chat_driver: chat_driver.as_ref(),
1434 policy,
1435 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1436 event_emitter: self.event_emitter.as_ref(),
1437 event_context: &streaming_event_context,
1438 session_id,
1439 message_source_sequence,
1440 provider_type: model_with_provider.provider_type.as_str(),
1441 model: &model_with_provider.model,
1442 system_prompt: has_system_prompt
1443 .then_some(runtime_agent.system_prompt.as_str()),
1444 stateful_response_continuation,
1445 checkpoint_restored: restored_checkpoint.is_some(),
1446 checkpoint_suffix_message_count,
1447 raw_tool_result_bytes,
1448 prior_usage: prior_usage.as_ref(),
1449 },
1450 &mut llm_messages_for_call,
1451 &mut llm_config,
1452 )
1453 .await?;
1454 }
1455
1456 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1459 let retry_config = self.provider_retry_config.clone();
1460 let has_provider_executed_tools = llm_config
1468 .driver_options
1469 .get("openrouter/routing")
1470 .and_then(|raw| raw.get("server_tools"))
1471 .and_then(|tools| tools.as_array())
1472 .is_some_and(|tools| !tools.is_empty());
1473 let mut stream_retry_metadata = RetryMetadata::default();
1474 let mut retry_started_at = None;
1475 let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1480 let mut native_calls = std::collections::BTreeMap::new();
1481 let (
1482 text,
1483 thinking,
1484 reasoning,
1485 tool_calls,
1486 completion_metadata,
1487 time_to_first_token_ms,
1488 pending_delta,
1489 mut tripped,
1490 ) = 'stream_attempt: loop {
1491 let stream_result = if let Some(remaining) =
1492 remaining_retry_time(&retry_config, retry_started_at)
1493 {
1494 match tokio::time::timeout(
1495 remaining,
1496 chat_driver.chat_completion_stream(
1497 &crate::ProviderEndpoint::default(),
1498 llm_messages_for_call.clone(),
1499 &llm_config,
1500 ),
1501 )
1502 .await
1503 {
1504 Ok(result) => result,
1505 Err(_) => {
1506 return Err(AgentLoopError::llm_kind(
1507 crate::error::LlmErrorKind::Unavailable,
1508 format!(
1509 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1510 stream_retry_metadata.attempts,
1511 retry_config.max_retry_elapsed.as_secs_f64()
1512 ),
1513 )
1514 .with_retry_metadata(&stream_retry_metadata));
1515 }
1516 }
1517 } else {
1518 chat_driver
1519 .chat_completion_stream(
1520 &crate::ProviderEndpoint::default(),
1521 llm_messages_for_call.clone(),
1522 &llm_config,
1523 )
1524 .await
1525 };
1526 let mut stream = match stream_result {
1527 Ok(stream) => stream,
1528 Err(e) if e.is_request_too_large() => {
1529 let Some(policy) = compaction_policy.as_deref() else {
1530 tracing::warn!(
1531 session_id = %session_id,
1532 turn_id = %context.turn_id,
1533 "ReasonAtom: context too large and compaction capability is not enabled"
1534 );
1535 return Err(e);
1536 };
1537 let outcome = apply_reactive_compaction(
1538 ReactiveCompactionContext {
1539 chat_driver: chat_driver.as_ref(),
1540 policy,
1541 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1542 event_emitter: self.event_emitter.as_ref(),
1543 event_context: &streaming_event_context,
1544 session_id,
1545 message_source_sequence,
1546 provider_type: model_with_provider.provider_type.as_str(),
1547 model: &model_with_provider.model,
1548 summarization_model_fallback: &runtime_agent.model,
1549 system_prompt: has_system_prompt
1550 .then_some(runtime_agent.system_prompt.as_str()),
1551 stateful_response_continuation,
1552 },
1553 &mut llm_messages_for_call,
1554 &mut llm_config,
1555 )
1556 .await?;
1557 let Some(outcome) = outcome else {
1558 return Err(e);
1559 };
1560 if outcome.generation_info.is_some() {
1561 compaction_info = outcome.generation_info;
1562 }
1563
1564 chat_driver
1565 .chat_completion_stream(
1566 &crate::ProviderEndpoint::default(),
1567 llm_messages_for_call.clone(),
1568 &llm_config,
1569 )
1570 .await?
1571 }
1572 Err(e)
1573 if e.is_transient_llm_error()
1574 && !e.llm_retry_handled()
1575 && !has_provider_executed_tools
1576 && stream_retry_metadata.attempts < retry_config.max_retries =>
1577 {
1578 let proposed_wait =
1579 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1580 let Some(wait_duration) =
1581 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1582 else {
1583 return Err(AgentLoopError::llm_kind(
1584 e.llm_error_kind()
1585 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1586 format!(
1587 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1588 stream_retry_metadata.attempts
1589 ),
1590 )
1591 .with_retry_metadata(&stream_retry_metadata));
1592 };
1593 tracing::warn!(
1594 session_id = %session_id,
1595 turn_id = %context.turn_id,
1596 attempt = stream_retry_metadata.attempts + 1,
1597 max_retries = retry_config.max_retries,
1598 wait_secs = wait_duration.as_secs_f64(),
1599 error = %e,
1600 "ReasonAtom: transient provider failure before stream, retrying"
1601 );
1602 stream_retry_metadata.record_retry(wait_duration, None);
1603 tokio::time::sleep(wait_duration).await;
1604 continue 'stream_attempt;
1605 }
1606 Err(e) => return Err(e),
1607 };
1608
1609 if let Some(coordinator) = &self.native_async {
1610 coordinator
1611 .lock()
1612 .await
1613 .begin_transcript_response(output_message_id.to_string())
1614 .await?;
1615 let coordinator = coordinator.clone();
1616 stream = Box::pin(futures::stream::unfold(
1617 Some((coordinator, stream)),
1618 |state| async move {
1619 let (coordinator, mut source) = state?;
1620 let event = coordinator
1621 .lock()
1622 .await
1623 .next_response_event(&mut source)
1624 .await;
1625 let finished = matches!(&event, Ok(LlmStreamEvent::Done(_)) | Err(_));
1626 Some((event, (!finished).then_some((coordinator, source))))
1627 },
1628 ));
1629 }
1630 let mut text = String::new();
1631 let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1635 let mut thinking = String::new();
1637 let mut tool_calls = Vec::new();
1638 let mut termination = StreamTermination::Exhausted;
1639 let mut replay_state = StreamReplayState::for_request(has_provider_executed_tools);
1640 let mut pending_delta = String::new();
1641 let mut pending_thinking_delta = String::new();
1642 let mut last_delta_emit = Instant::now();
1643 let mut last_thinking_delta_emit = Instant::now();
1644 let mut time_to_first_token_ms: Option<u64> = None;
1645
1646 let stall_timeout = self
1648 .provider_stall_timeout
1649 .unwrap_or(std::time::Duration::from_secs(120));
1650 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1651 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1652 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1653 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1654 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1655 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1657 let mut last_token_at_unix: u64 = unix_now_secs();
1662
1663 loop {
1664 let event = tokio::select! {
1665 biased;
1666 next = stream.next() => match next {
1667 Some(e) => e,
1668 None => break,
1669 },
1670 _ = &mut stall_sleep => {
1671 let stall_error =
1681 crate::driver_registry::LlmStreamError::new(format!(
1682 "provider stream stall: no tokens for {}s",
1683 stall_timeout.as_secs()
1684 ));
1685 tracing::warn!(
1686 session_id = %session_id,
1687 turn_id = %context.turn_id,
1688 stall_secs = stall_timeout.as_secs(),
1689 "ReasonAtom: provider stream stall timeout"
1690 );
1691 if replay_state.should_retry(
1692 &stall_error,
1693 stream_retry_metadata.attempts,
1694 retry_config.max_retries,
1695 ) {
1696 let proposed_wait = retry_config
1697 .calculate_backoff(stream_retry_metadata.attempts);
1698 let Some(wait_duration) = reserve_retry_wait(
1699 &retry_config,
1700 &mut retry_started_at,
1701 proposed_wait,
1702 ) else {
1703 return Err(AgentLoopError::llm_kind(
1704 crate::error::LlmErrorKind::Unavailable,
1705 format!(
1706 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1707 stall_error.message,
1708 stream_retry_metadata.attempts
1709 ),
1710 )
1711 .with_retry_metadata(&stream_retry_metadata));
1712 };
1713 tracing::warn!(
1714 session_id = %session_id,
1715 turn_id = %context.turn_id,
1716 attempt = stream_retry_metadata.attempts + 1,
1717 max_retries = retry_config.max_retries,
1718 wait_secs = wait_duration.as_secs_f64(),
1719 "ReasonAtom: provider stream stall, retrying"
1720 );
1721 stream_retry_metadata.record_retry(wait_duration, None);
1722 tokio::time::sleep(wait_duration).await;
1723 continue 'stream_attempt;
1724 }
1725 return Err(AgentLoopError::llm(stall_error.message));
1726 },
1727 _ = keepalive_ticker.tick() => {
1728 if let Some(ref hb) = self.stream_heartbeater {
1729 hb.heartbeat(crate::durability::StreamProgress {
1730 accumulated_len: text.len() + thinking.len(),
1731 last_delta_at: last_token_at_unix,
1732 })
1733 .await;
1734 last_stream_heartbeat = Instant::now();
1735 }
1736 continue;
1737 },
1738 };
1739 let event = event?;
1740 replay_state.observe(&event);
1741 let advanced_stall_deadline = advances_stall_deadline(&event);
1742 if advanced_stall_deadline {
1743 stall_sleep
1744 .as_mut()
1745 .reset(tokio::time::Instant::now() + stall_timeout);
1746 last_token_at_unix = unix_now_secs();
1747 }
1748 match event {
1749 LlmStreamEvent::TextDelta(delta) => {
1750 if delta.is_empty() {
1751 continue;
1752 }
1753 if time_to_first_token_ms.is_none() {
1755 let ttft = llm_start.elapsed().as_millis() as u64;
1756 time_to_first_token_ms = Some(ttft);
1757 tracing::info!(
1758 session_id = %session_id,
1759 time_to_first_token_ms = ttft,
1760 "ReasonAtom: received first token from LLM"
1761 );
1762 }
1763 text.push_str(&delta);
1764 pending_delta.push_str(&delta);
1765
1766 if !armed_guardrails.is_empty()
1773 && let Some(t) =
1774 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1775 {
1776 tracing::warn!(
1777 session_id = %session_id,
1778 turn_id = %context.turn_id,
1779 guardrail_capability_id = %t.capability_id,
1780 guardrail_id = %t.guardrail_id,
1781 reason_code = %t.block.reason_code,
1782 "ReasonAtom: output guardrail tripped, replacing assistant message"
1783 );
1784 pending_delta.clear();
1785 termination = StreamTermination::GuardrailBlocked(t);
1786 break;
1787 }
1788
1789 if !buffer_output_deltas
1791 && last_delta_emit.elapsed().as_millis() as u64
1792 >= DELTA_BATCH_INTERVAL_MS
1793 && !pending_delta.is_empty()
1794 {
1795 if let Err(e) = self
1796 .event_emitter
1797 .emit(EventRequest::new(
1798 session_id,
1799 streaming_event_context.clone(),
1800 OutputMessageDeltaData {
1801 turn_id: context.turn_id,
1802 message_id: output_message_id,
1803 delta: pending_delta.clone(),
1804 accumulated: text.clone(),
1805 phase: streamed_phase,
1806 },
1807 ))
1808 .await
1809 {
1810 tracing::warn!(
1811 session_id = %session_id,
1812 error = %e,
1813 "ReasonAtom: failed to emit output.message.delta event"
1814 );
1815 }
1816 pending_delta.clear();
1817 last_delta_emit = Instant::now();
1818 }
1819 }
1820 LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1821 if delta.is_empty() {
1822 continue;
1823 }
1824 if let Some(t) = append_guarded_thinking_delta(
1825 &mut armed_guardrails,
1826 &mut thinking,
1827 &mut pending_thinking_delta,
1828 &delta,
1829 ) {
1830 tracing::warn!(
1831 session_id = %session_id,
1832 guardrail_capability_id = %t.capability_id,
1833 guardrail_id = %t.guardrail_id,
1834 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1835 );
1836 termination = StreamTermination::GuardrailBlocked(t);
1837 break;
1838 }
1839 tracing::debug!(
1840 session_id = %session_id,
1841 delta_len = delta.len(),
1842 total_thinking_len = thinking.len(),
1843 "ReasonAtom: received ThinkingDelta from LLM"
1844 );
1845
1846 if last_thinking_delta_emit.elapsed().as_millis() as u64
1848 >= DELTA_BATCH_INTERVAL_MS
1849 && !pending_thinking_delta.is_empty()
1850 {
1851 if let Err(e) = self
1852 .event_emitter
1853 .emit(EventRequest::new(
1854 session_id,
1855 streaming_event_context.clone(),
1856 ReasonThinkingDeltaData {
1857 turn_id: context.turn_id,
1858 delta: pending_thinking_delta.clone(),
1859 accumulated: thinking.clone(),
1860 },
1861 ))
1862 .await
1863 {
1864 tracing::warn!(
1865 session_id = %session_id,
1866 error = %e,
1867 "ReasonAtom: failed to emit reason.thinking.delta event"
1868 );
1869 }
1870 pending_thinking_delta.clear();
1871 last_thinking_delta_emit = Instant::now();
1872 }
1873 }
1874 LlmStreamEvent::ReasoningItem(item) => {
1875 if let Some(t) = inspect_guarded_reasoning_item(
1876 &mut armed_guardrails,
1877 &mut thinking,
1878 &item,
1879 ) {
1880 tracing::warn!(
1881 session_id = %session_id,
1882 guardrail_capability_id = %t.capability_id,
1883 guardrail_id = %t.guardrail_id,
1884 "ReasonAtom: output guardrail tripped on completed reasoning item, replacing assistant message"
1885 );
1886 termination = StreamTermination::GuardrailBlocked(t);
1887 break;
1888 }
1889 tracing::debug!(
1893 session_id = %session_id,
1894 provider = %item.provider,
1895 item_id = ?item.item_id,
1896 has_signature = item.signature.is_some(),
1897 has_encrypted = item.encrypted.is_some(),
1898 "ReasonAtom: captured reasoning artifact"
1899 );
1900 reasoning.push(item);
1901 }
1902 LlmStreamEvent::NativeToolCall(call) => {
1903 if self.native_async.is_none() {
1904 return Err(AgentLoopError::config(
1905 "native async/custom tools require a configured native-call coordinator",
1906 ));
1907 }
1908 let part = crate::message::ToolCallContentPart::from_native(call.clone())?;
1909 if native_calls.insert(call.id().to_owned(), call).is_none() {
1910 tool_calls.push(ToolCall {
1911 id: part.id,
1912 name: part.name,
1913 arguments: part.arguments,
1914 });
1915 }
1916 }
1917 LlmStreamEvent::ToolCalls(calls) => {
1918 if self.native_async.is_some() {
1919 for call in &calls {
1920 native_calls.entry(call.id.clone()).or_insert_with(|| {
1921 everruns_provider::native_async::NativeToolCall::Function {
1922 call_id: call.id.clone(),
1923 name: call.name.clone(),
1924 arguments: call.arguments.to_string(),
1925 asynchronous: false,
1926 }
1927 });
1928 }
1929 for call in calls {
1930 if !tool_calls.iter().any(|existing| existing.id == call.id) {
1931 tool_calls.push(call);
1932 }
1933 }
1934 } else {
1935 tool_calls = calls;
1936 }
1937 }
1938 LlmStreamEvent::MessagePhase(phase) => {
1939 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1948 streamed_phase,
1949 phase,
1950 );
1951 }
1952 LlmStreamEvent::Done(metadata) => {
1953 if !buffer_output_deltas
1957 && !pending_delta.is_empty()
1958 && let Err(e) = self
1959 .event_emitter
1960 .emit(EventRequest::new(
1961 session_id,
1962 streaming_event_context.clone(),
1963 OutputMessageDeltaData {
1964 turn_id: context.turn_id,
1965 message_id: output_message_id,
1966 delta: pending_delta.clone(),
1967 accumulated: text.clone(),
1968 phase: streamed_phase,
1969 },
1970 ))
1971 .await
1972 {
1973 tracing::warn!(
1974 session_id = %session_id,
1975 error = %e,
1976 "ReasonAtom: failed to emit final output.message.delta event"
1977 );
1978 }
1979
1980 if !pending_thinking_delta.is_empty()
1982 && let Err(e) = self
1983 .event_emitter
1984 .emit(EventRequest::new(
1985 session_id,
1986 streaming_event_context.clone(),
1987 ReasonThinkingDeltaData {
1988 turn_id: context.turn_id,
1989 delta: pending_thinking_delta.clone(),
1990 accumulated: thinking.clone(),
1991 },
1992 ))
1993 .await
1994 {
1995 tracing::warn!(
1996 session_id = %session_id,
1997 error = %e,
1998 "ReasonAtom: failed to emit final reason.thinking.delta event"
1999 );
2000 }
2001
2002 if !thinking.is_empty()
2004 && let Err(e) = self
2005 .event_emitter
2006 .emit(EventRequest::new(
2007 session_id,
2008 streaming_event_context.clone(),
2009 ReasonThinkingCompletedData {
2010 turn_id: context.turn_id,
2011 thinking: thinking.clone(),
2012 },
2013 ))
2014 .await
2015 {
2016 tracing::warn!(
2017 session_id = %session_id,
2018 error = %e,
2019 "ReasonAtom: failed to emit reason.thinking.completed event"
2020 );
2021 }
2022 termination = StreamTermination::Completed(metadata);
2023 break;
2024 }
2025 LlmStreamEvent::Error(err) => {
2026 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
2031
2032 if has_partial_output {
2033 tracing::warn!(
2034 session_id = %session_id,
2035 error = %err,
2036 tool_call_count = tool_calls.len(),
2037 text_len = text.len(),
2038 "ReasonAtom: trailing stream error after valid output — treating as partial success"
2039 );
2040 termination = StreamTermination::PartialSuccess;
2044 break;
2045 }
2046
2047 if replay_state.should_retry(
2048 &err,
2049 stream_retry_metadata.attempts,
2050 retry_config.max_retries,
2051 ) {
2052 let proposed_wait =
2053 retry_config.calculate_backoff(stream_retry_metadata.attempts);
2054 let Some(wait_duration) = reserve_retry_wait(
2055 &retry_config,
2056 &mut retry_started_at,
2057 proposed_wait,
2058 ) else {
2059 return Err(AgentLoopError::llm_kind(
2060 err.kind(),
2061 format!(
2062 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
2063 stream_retry_metadata.attempts
2064 ),
2065 )
2066 .with_retry_metadata(&stream_retry_metadata));
2067 };
2068 tracing::warn!(
2069 session_id = %session_id,
2070 turn_id = %context.turn_id,
2071 attempt = stream_retry_metadata.attempts + 1,
2072 max_retries = retry_config.max_retries,
2073 wait_secs = wait_duration.as_secs_f64(),
2074 error_code = err.code.as_deref().unwrap_or("none"),
2075 error_status = err.status,
2076 error = %err,
2077 "ReasonAtom: transient stream error before output, retrying"
2078 );
2079 stream_retry_metadata.record_retry(wait_duration, None);
2080 tokio::time::sleep(wait_duration).await;
2081 continue 'stream_attempt;
2082 }
2083
2084 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2086 let event_context = EventContext::from_execution_context(context)
2087 .with_span(
2088 trace_id.to_string(),
2089 Uuid::now_v7().to_string(),
2090 Some(reason_span_id.to_string()),
2091 );
2092 let tools_summary: Vec<ToolDefinitionSummary> =
2093 runtime_agent.tools.iter().map(|t| t.into()).collect();
2094 let generation_data = LlmGenerationData::failure(
2095 messages_for_event.clone(),
2096 tools_summary,
2097 runtime_agent.model.clone(),
2098 Some(model_with_provider.provider_type.to_string()),
2099 err.to_string(),
2100 Some(llm_duration_ms),
2101 time_to_first_token_ms,
2102 );
2103 let _ = self
2104 .event_emitter
2105 .emit(EventRequest::new(
2106 session_id,
2107 event_context,
2108 generation_data,
2109 ))
2110 .await;
2111 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
2112 }
2113 _ => {}
2119 }
2120 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2123 && let Some(ref hb) = self.stream_heartbeater
2124 {
2125 hb.heartbeat(crate::durability::StreamProgress {
2126 accumulated_len: text.len() + thinking.len(),
2127 last_delta_at: last_token_at_unix,
2128 })
2129 .await;
2130 last_stream_heartbeat = Instant::now();
2131 }
2132 }
2133 let (mut completion_metadata, tripped) = termination.into_parts();
2134 if let Some(metadata) = completion_metadata.as_mut() {
2135 metadata.retry_metadata =
2136 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2137 }
2138
2139 break 'stream_attempt (
2140 text,
2141 thinking,
2142 reasoning,
2143 tool_calls,
2144 completion_metadata,
2145 time_to_first_token_ms,
2146 pending_delta,
2147 tripped,
2148 );
2149 };
2150 let (mut text, mut thinking, mut reasoning, mut tool_calls) =
2151 (text, thinking, reasoning, tool_calls);
2152
2153 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
2164 if tripped.is_none()
2165 && !annotation_providers.is_empty()
2166 && !text.is_empty()
2167 && tool_calls.is_empty()
2168 {
2169 text = filter_response_text(
2172 &self.capability_registry,
2173 &resolved_capability_configs,
2174 text,
2175 );
2176 let collected = collect_annotations(
2177 &annotation_providers,
2178 &runtime_agent.system_prompt,
2179 &text,
2180 &messages,
2181 self.utility_llm_service.as_ref(),
2182 )
2183 .await;
2184 text = collected.text;
2185 citation_annotations = collected.annotations;
2186
2187 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
2190 let guarded_output = client_visible_guardrail_text(
2191 &text,
2192 &thinking,
2193 &reasoning,
2194 &citation_annotations,
2195 );
2196 let ctx = PostGenerationOutputContext {
2197 system_prompt: &runtime_agent.system_prompt,
2198 message_text: &guarded_output,
2199 utility_llm_service: self.utility_llm_service.as_ref(),
2200 judgment_service: self.judgment_service.as_ref(),
2201 };
2202 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2203 }
2204
2205 if tripped.is_none()
2208 && !citation_annotations.is_empty()
2209 && !citation_verifiers.is_empty()
2210 {
2211 citation_annotations = verify_annotations(
2212 &citation_verifiers,
2213 &text,
2214 self.utility_llm_service.as_ref(),
2215 citation_annotations,
2216 )
2217 .await;
2218 }
2219 }
2220
2221 if tripped.is_none()
2224 && citation_annotations.is_empty()
2225 && !post_output_providers.is_empty()
2226 && (!text.is_empty() || !thinking.is_empty() || !reasoning.is_empty())
2227 {
2228 let guarded_output = client_visible_guardrail_text(&text, &thinking, &reasoning, &[]);
2229 let ctx = PostGenerationOutputContext {
2230 system_prompt: &runtime_agent.system_prompt,
2231 message_text: &guarded_output,
2232 utility_llm_service: self.utility_llm_service.as_ref(),
2233 judgment_service: self.judgment_service.as_ref(),
2234 };
2235 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2236 }
2237
2238 if tripped.is_some() {
2239 citation_annotations.clear();
2240 }
2241
2242 if tripped.is_none() {
2246 for item in &reasoning {
2247 if let Err(e) = self
2248 .event_emitter
2249 .emit(EventRequest::new(
2250 session_id,
2251 streaming_event_context.clone(),
2252 ReasonItemData {
2253 turn_id: context.turn_id,
2254 provider: item.provider.clone(),
2255 model: Some(llm_config.model.clone()),
2256 item_id: item.item_id.clone().unwrap_or_default(),
2257 summary: item
2258 .display_text()
2259 .filter(|_| !matches!(item.text, Some(ReasoningText::Plain { .. })))
2260 .into_iter()
2261 .collect(),
2262 token_count: item.tokens,
2263 },
2264 ))
2265 .await
2266 {
2267 tracing::warn!(
2268 session_id = %session_id,
2269 error = %e,
2270 "ReasonAtom: failed to emit reason.item event"
2271 );
2272 }
2273 }
2274 }
2275
2276 if buffer_output_deltas
2279 && tripped.is_none()
2280 && !pending_delta.is_empty()
2281 && let Err(e) = self
2282 .event_emitter
2283 .emit(EventRequest::new(
2284 session_id,
2285 streaming_event_context.clone(),
2286 OutputMessageDeltaData {
2287 turn_id: context.turn_id,
2288 message_id: output_message_id,
2289 delta: pending_delta.clone(),
2290 accumulated: text.clone(),
2291 phase: streamed_phase,
2292 },
2293 ))
2294 .await
2295 {
2296 tracing::warn!(
2297 session_id = %session_id,
2298 error = %e,
2299 "ReasonAtom: failed to emit guarded output.message.delta event"
2300 );
2301 }
2302
2303 if let Some(ref t) = tripped {
2309 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2310 trace_id.to_string(),
2311 Uuid::now_v7().to_string(),
2312 Some(reason_span_id.to_string()),
2313 );
2314 if let Err(e) = self
2315 .event_emitter
2316 .emit(EventRequest::new(
2317 session_id,
2318 replaced_event_context,
2319 OutputMessageReplacedData {
2320 turn_id: context.turn_id,
2321 message_id: output_message_id,
2322 guardrail_capability_id: t.capability_id.clone(),
2323 guardrail_id: t.guardrail_id.clone(),
2324 reason_code: t.block.reason_code.clone(),
2325 replacement: t.block.replacement.clone(),
2326 },
2327 ))
2328 .await
2329 {
2330 tracing::warn!(
2331 session_id = %session_id,
2332 error = %e,
2333 "ReasonAtom: failed to emit output.message.replaced event"
2334 );
2335 }
2336 text = t.block.replacement.clone();
2337 tool_calls.clear();
2338 thinking.clear();
2339 reasoning.clear();
2340 }
2341
2342 if !tool_calls.is_empty() {
2346 self.apply_finalized_tool_call_hooks(
2347 session_id,
2348 context,
2349 &resolved_capability_configs,
2350 &runtime_agent.tools,
2351 &mut tool_calls,
2352 iteration,
2353 )
2354 .await;
2355 }
2356
2357 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2358
2359 let response_id = completion_metadata
2361 .as_ref()
2362 .and_then(|meta| meta.response_id.clone());
2363 let finish_reason = completion_metadata
2364 .as_ref()
2365 .and_then(|meta| meta.finish_reason.clone());
2366
2367 let usage = completion_metadata.as_ref().and_then(|meta| {
2375 match (meta.prompt_tokens, meta.completion_tokens) {
2376 (Some(input), Some(output)) => {
2377 let actual_cost_usd = meta.provider_cost_usd;
2378 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2379 &model_with_provider.provider_type,
2380 &runtime_agent.model,
2381 input,
2382 output,
2383 meta.cache_read_tokens.unwrap_or(0),
2384 meta.cache_creation_tokens.unwrap_or(0),
2385 );
2386 Some(
2387 TokenUsage::with_cache(
2388 input,
2389 output,
2390 meta.cache_read_tokens,
2391 meta.cache_creation_tokens,
2392 )
2393 .with_cost(actual_cost_usd, estimated_cost_usd),
2394 )
2395 }
2396 _ => None,
2397 }
2398 });
2399
2400 let event_context = EventContext::from_execution_context(context).with_span(
2402 trace_id.to_string(),
2403 Uuid::now_v7().to_string(),
2404 Some(reason_span_id.to_string()),
2405 );
2406 let tools_summary: Vec<ToolDefinitionSummary> =
2407 runtime_agent.tools.iter().map(|t| t.into()).collect();
2408 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2409 if tool_calls.is_empty() {
2410 "stop".to_string()
2411 } else {
2412 "tool_calls".to_string()
2413 }
2414 })]);
2415 let retry_info = completion_metadata
2417 .as_ref()
2418 .and_then(|meta| meta.retry_metadata.as_ref())
2419 .filter(|rm| rm.had_retries())
2420 .map(|rm| LlmRetryInfo {
2421 attempts: rm.attempts,
2422 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2423 });
2424 let mut generation_data = LlmGenerationData::success_with_retry(
2426 messages_for_event.clone(),
2427 tools_summary,
2428 Some(text.clone()).filter(|s| !s.is_empty()),
2429 tool_calls.clone(),
2430 runtime_agent.model.clone(),
2431 Some(model_with_provider.provider_type.to_string()),
2432 usage.clone(),
2433 Some(llm_duration_ms),
2434 time_to_first_token_ms,
2435 finish_reasons,
2436 response_id.clone(),
2437 retry_info,
2438 );
2439
2440 if let Some(info) = compaction_info {
2446 if let Some(compaction_cost) = info.cost_usd {
2447 match generation_data.metadata.usage.as_mut() {
2448 Some(usage) => {
2449 add_compaction_cost(usage, compaction_cost);
2450 }
2451 None => {
2456 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2457 input_tokens: 0,
2458 output_tokens: 0,
2459 cache_read_tokens: None,
2460 cache_creation_tokens: None,
2461 actual_cost_usd: Some(compaction_cost),
2462 estimated_cost_usd: None,
2463 effective_cost_usd: None,
2464 });
2465 }
2466 }
2467 }
2468 generation_data = generation_data.with_compaction(info);
2469 }
2470
2471 if let Some(request_options) =
2472 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2473 {
2474 generation_data = generation_data.with_request_options(request_options);
2475 }
2476
2477 if let Err(e) = self
2478 .event_emitter
2479 .emit(EventRequest::new(
2480 session_id,
2481 event_context,
2482 generation_data,
2483 ))
2484 .await
2485 {
2486 tracing::warn!(
2487 session_id = %session_id,
2488 error = %e,
2489 "ReasonAtom: failed to emit llm.generation event"
2490 );
2491 }
2492
2493 let mut metadata = std::collections::HashMap::new();
2495 metadata.insert(
2496 "model".to_string(),
2497 serde_json::Value::String(runtime_agent.model.clone()),
2498 );
2499 if let Some(state) = &llm_config.reasoning_state {
2500 metadata.insert(
2501 reasoning_updates::STATE_KEY.to_string(),
2502 serde_json::json!(state),
2503 );
2504 }
2505 if let Some(effort) = llm_config
2506 .reasoning_state
2507 .as_ref()
2508 .and_then(|state| state.effective)
2509 .or(reasoning_effort)
2510 {
2511 metadata.insert(
2512 "reasoning_effort".to_string(),
2513 serde_json::Value::String(effort.as_str().to_string()),
2514 );
2515 }
2516 metadata.insert(
2523 "provider".to_string(),
2524 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2525 );
2526 if let Some(ref rid) = response_id {
2527 metadata.insert(
2528 "response_id".to_string(),
2529 serde_json::Value::String(rid.clone()),
2530 );
2531 }
2532
2533 let text = filter_response_text(
2537 &self.capability_registry,
2538 &resolved_capability_configs,
2539 text,
2540 );
2541 let has_tool_calls = !tool_calls.is_empty();
2542 let mut assistant_message = if has_tool_calls {
2543 Message::assistant_with_tools(&text, tool_calls.clone())
2544 } else {
2545 Message::assistant(&text)
2546 }
2547 .with_id(output_message_id);
2548 for part in &mut assistant_message.content {
2549 if let crate::message::ContentPart::ToolCall(call) = part {
2550 call.native = native_calls.get(&call.id).cloned();
2551 }
2552 }
2553 if !citation_annotations.is_empty() {
2556 for part in assistant_message.content.iter_mut() {
2557 if let crate::message::ContentPart::Text(t) = part {
2558 t.annotations = std::mem::take(&mut citation_annotations);
2559 break;
2560 }
2561 }
2562 }
2563 let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2567 let provider_phase = completion_metadata
2571 .as_ref()
2572 .and_then(|meta| meta.phase.as_deref())
2573 .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2574 let (phase, phase_source) = match provider_phase {
2575 Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2576 None => (
2577 everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2578 everruns_provider::PhaseSource::Derived,
2579 ),
2580 };
2581 assistant_message.phase = Some(phase);
2582 assistant_message.phase_source = Some(phase_source);
2583 assistant_message.metadata = Some(metadata);
2584 if reasoning.is_empty() && !thinking.is_empty() {
2593 reasoning.push(
2594 ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2595 ReasoningText::Plain {
2596 text: thinking.clone(),
2597 },
2598 ),
2599 );
2600 }
2601 if !reasoning.is_empty() {
2602 let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2603 content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2604 content.append(&mut assistant_message.content);
2605 assistant_message.content = content;
2606 }
2607 let message_event_context = EventContext::from_execution_context(context).with_span(
2610 trace_id.to_string(),
2611 Uuid::now_v7().to_string(),
2612 Some(reason_span_id.to_string()),
2613 );
2614 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2615 if let Some(ref u) = usage {
2616 output_message_data = output_message_data.with_usage(u.clone());
2617 }
2618 let result = ReasonResult {
2619 native_counts: None,
2620 success: true,
2621 text,
2622 tool_calls,
2623 has_tool_calls,
2624 tool_definitions: runtime_agent.tools.clone(),
2625 max_iterations: runtime_agent.max_iterations,
2626 error: None,
2627 user_facing_error: None,
2628 error_disclosure: None,
2629 usage,
2630 output_message_id: Some(output_message_id),
2631 time_to_first_token_ms,
2632 response_id,
2633 finish_reason,
2634 locale: resolved_locale,
2635 network_access: runtime_agent.network_access.clone(),
2636 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2637 };
2638 if let Some(coordinator) = &self.native_async {
2639 coordinator
2640 .lock()
2641 .await
2642 .stage_transcript_result(
2643 serde_json::to_value(&result)
2644 .map_err(|error| AgentLoopError::store(error.to_string()))?,
2645 )
2646 .await?;
2647 }
2648 self.event_emitter
2649 .emit(EventRequest::new(
2650 session_id,
2651 message_event_context,
2652 output_message_data,
2653 ))
2654 .await?;
2655
2656 if let Some(coordinator) = &self.native_async {
2657 coordinator
2658 .lock()
2659 .await
2660 .transcript_committed(&output_message_id.to_string())
2661 .await?;
2662 }
2663 tracing::info!(
2664 session_id = %session_id,
2665 turn_id = %context.turn_id,
2666 has_tool_calls = %result.has_tool_calls,
2667 tool_count = %result.tool_calls.len(),
2668 "ReasonAtom: LLM call completed"
2669 );
2670
2671 Ok(result)
2672 }
2673
2674 async fn finalize_partial_stream(
2679 &self,
2680 session_id: SessionId,
2681 context: &ExecutionContext,
2682 partial: PartialStreamState,
2683 iteration: u32,
2684 runtime_agent: &crate::RuntimeAgent,
2685 resolved_capability_configs: &[crate::CapabilityRef],
2686 ) -> Result<ReasonResult> {
2687 let event_context = EventContext::from_execution_context(context);
2688 let turn_id = context.turn_id;
2689 let message_id = partial.message_id;
2690
2691 let _ = self
2693 .event_emitter
2694 .emit(EventRequest::new(
2695 session_id,
2696 event_context.clone(),
2697 OutputMessageStartedData {
2698 reasoning_state: partial.reasoning_state.clone(),
2699 turn_id,
2700 message_id,
2701 model: None,
2702 iteration: Some(iteration),
2703 phase: None,
2706 },
2707 ))
2708 .await;
2709
2710 let accumulated = filter_response_text(
2713 &self.capability_registry,
2714 resolved_capability_configs,
2715 partial.accumulated,
2716 );
2717 let mut assistant_message = Message::assistant(&accumulated).with_id(message_id);
2718 if let Some(state) = partial.reasoning_state {
2719 assistant_message.metadata = Some(HashMap::from([
2720 ("model".into(), serde_json::json!("gpt-6-astra")),
2721 ("provider".into(), serde_json::json!("openai")),
2722 (
2723 reasoning_updates::STATE_KEY.into(),
2724 serde_json::json!(state),
2725 ),
2726 (
2727 "reasoning_effort".into(),
2728 serde_json::json!(state.effective),
2729 ),
2730 ]));
2731 }
2732 let output_message_id = message_id;
2733 self.event_emitter
2734 .emit(EventRequest::new(
2735 session_id,
2736 event_context.clone(),
2737 OutputMessageCompletedData::new(assistant_message),
2738 ))
2739 .await?;
2740
2741 let accumulated_len = accumulated.len();
2743 let _ = self
2744 .event_emitter
2745 .emit(EventRequest::new(
2746 session_id,
2747 event_context.clone(),
2748 ReasonRecoveredData {
2749 turn_id,
2750 mode: RecoveryMode::Finalize,
2751 accumulated_len,
2752 },
2753 ))
2754 .await;
2755
2756 tracing::info!(
2757 session_id = %session_id,
2758 turn_id = %turn_id,
2759 accumulated_len,
2760 "ReasonAtom: finalized partial stream from persisted accumulated text"
2761 );
2762
2763 Ok(ReasonResult {
2764 native_counts: None,
2765 success: true,
2766 text: accumulated,
2767 tool_calls: vec![],
2768 has_tool_calls: false,
2769 tool_definitions: runtime_agent.tools.clone(),
2770 max_iterations: runtime_agent.max_iterations,
2771 error: None,
2772 user_facing_error: None,
2773 error_disclosure: None,
2774 usage: None,
2775 output_message_id: Some(output_message_id),
2776 time_to_first_token_ms: None,
2777 response_id: None,
2778 finish_reason: Some("stop".to_string()),
2779 locale: None,
2780 network_access: None,
2781 parallel_tool_calls: None,
2783 })
2784 }
2785
2786 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2797 let mut resolved = HashMap::new();
2798
2799 let resolver = match &self.image_resolver {
2801 Some(r) => r,
2802 None => return resolved,
2803 };
2804
2805 let image_ids: Vec<Uuid> = messages
2807 .iter()
2808 .flat_map(crate::llm_conversions::extract_image_file_ids)
2809 .collect::<std::collections::HashSet<_>>()
2810 .into_iter()
2811 .collect();
2812
2813 if image_ids.is_empty() {
2814 return resolved;
2815 }
2816
2817 tracing::debug!(
2818 image_count = image_ids.len(),
2819 "ReasonAtom: resolving image_file references"
2820 );
2821
2822 for image_id in image_ids {
2824 match resolver.resolve_image(image_id).await {
2825 Ok(Some(image)) => {
2826 resolved.insert(image_id, image);
2827 }
2828 Ok(None) => {
2829 tracing::warn!(
2830 image_id = %image_id,
2831 "ReasonAtom: image not found during resolution"
2832 );
2833 }
2834 Err(e) => {
2835 tracing::warn!(
2836 image_id = %image_id,
2837 error = %e,
2838 "ReasonAtom: failed to resolve image"
2839 );
2840 }
2841 }
2842 }
2843
2844 tracing::debug!(
2845 resolved_count = resolved.len(),
2846 "ReasonAtom: image resolution complete"
2847 );
2848
2849 resolved
2850 }
2851
2852 async fn resolve_files(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedFile> {
2853 let Some(resolver) = &self.file_resolver else {
2854 return HashMap::new();
2855 };
2856
2857 let file_ids: Vec<Uuid> = messages
2858 .iter()
2859 .flat_map(crate::llm_conversions::extract_file_ids)
2860 .collect::<std::collections::HashSet<_>>()
2861 .into_iter()
2862 .collect();
2863
2864 if file_ids.is_empty() {
2865 return HashMap::new();
2866 }
2867
2868 match resolver.resolve_files(&file_ids).await {
2869 Ok(map) => map,
2870 Err(e) => {
2871 tracing::warn!(
2872 target: "reason",
2873 "ReasonAtom: file resolution failed: {e}"
2874 );
2875 HashMap::new()
2876 }
2877 }
2878 }
2879}
2880
2881#[cfg(test)]
2886mod tests;