1use futures::StreamExt;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::sync::Arc;
23use std::time::Instant;
24use uuid::Uuid;
25
26use super::ExecutionContext;
27use crate::annotation_hook::{collect_annotations, verify_annotations};
28use crate::capabilities::CapabilityRegistry;
29use crate::driver_registry::{LlmMessage, LlmMessageContent, LlmMessageRole, LlmStreamEvent};
30use crate::error::{AgentLoopError, Result};
31use crate::events::{
32 CapabilityUsageData, EventContext, EventRequest, LlmCompactionInfo, LlmGenerationData,
33 LlmRetryInfo, OutputMessageCompletedData, OutputMessageDeltaData, OutputMessageReplacedData,
34 OutputMessageStartedData, ReasonCompletedData, ReasonItemData, ReasonRecoveredData,
35 ReasonStartedData, ReasonThinkingCompletedData, ReasonThinkingDeltaData,
36 ReasonThinkingStartedData, RecoveryMode, TokenUsage, ToolDefinitionSummary,
37};
38use crate::llm_retry::{
39 LlmRetryConfig, RetryMetadata, is_transient_error_message, remaining_retry_time,
40 reserve_retry_wait,
41};
42use crate::message::{Message, MessageRole};
43use crate::message_retriever::MessageRetriever;
44use crate::output_guardrail::{
45 ArmedGuardrail, OutputGuardrailContext, PostGenerationOutputContext, evaluate_guardrails,
46 evaluate_post_generation_guardrails, post_generation_guardrail_text,
47};
48use crate::phase_effects::{PhaseEffectEmitter, PhaseEffectSink};
49use crate::runtime_context::{AssembledTurnContext, TurnContextRequest, TurnContextResolver};
50use crate::tool_types::{ToolCall, ToolDefinition};
51use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId};
52use crate::{ErrorDisclosure, UserFacingError, UserFacingErrorContext};
53use crate::{
54 durability::DurableToolResultStore, durability::PartialStreamState,
55 durability::PartialStreamStore, event_emitter::EventEmitter, image_services::ImageResolver,
56 image_services::ResolvedImage,
57};
58
59mod compaction;
60mod error_policy;
61mod observability;
62mod output_hooks;
63mod request_controls;
64mod stream_state;
65mod transcript;
66
67use compaction::{
68 ProactiveCompactionContext, ReactiveCompactionContext, apply_proactive_compaction,
69 apply_reactive_compaction,
70};
71use error_policy::{
72 error_disclosure_override, filter_response_text, is_error_placeholder_message,
73 resolve_error_disclosure,
74};
75use observability::{build_request_options, capability_usage_snapshot_records};
76use output_hooks::collect_output_hooks;
77use request_controls::resolve_request_controls;
78use stream_state::{
79 StreamReplayState, StreamTermination, advances_stall_deadline, append_guarded_thinking_delta,
80 merge_retry_metadata,
81};
82use transcript::repair_dangling_tool_calls;
83
84#[allow(clippy::too_many_arguments)]
91async fn apply_finalized_tool_calls_hooks(
92 capability_registry: &CapabilityRegistry,
93 event_emitter: &dyn EventEmitter,
94 session_id: SessionId,
95 context: &ExecutionContext,
96 resolved_capability_configs: &[crate::CapabilityRef],
97 tool_definitions: &[ToolDefinition],
98 tool_calls: &mut [ToolCall],
99 iteration: u32,
100) {
101 let hook_context = crate::finalized_tool_calls::FinalizedToolCallsContext {
102 event_emitter,
103 session_id,
104 execution_context: context,
105 tool_definitions,
106 iteration,
107 };
108 for config in resolved_capability_configs {
109 let Some(capability) = capability_registry.get(config.capability_id()) else {
110 continue;
111 };
112 if let Some(hook) = capability.finalized_tool_calls_hook(config.config_value()) {
113 hook.apply(&hook_context, tool_calls).await;
114 }
115 }
116}
117
118fn unix_now_secs() -> u64 {
119 std::time::SystemTime::now()
120 .duration_since(std::time::UNIX_EPOCH)
121 .unwrap_or_default()
122 .as_secs()
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ReasonInput {
128 pub context: ExecutionContext,
130 pub harness_id: HarnessId,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub agent_id: Option<AgentId>,
135 #[serde(default)]
137 pub org_id: i64,
138 #[serde(default)]
142 pub mcp_tool_definitions: Vec<ToolDefinition>,
143 #[serde(skip_serializing_if = "Option::is_none")]
146 pub previous_response_id: Option<String>,
147 #[serde(default = "default_iteration")]
150 pub iteration: u32,
151}
152
153fn default_iteration() -> u32 {
154 1
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct ReasonResult {
160 pub success: bool,
162 pub text: String,
164 #[serde(default)]
166 pub tool_calls: Vec<ToolCall>,
167 pub has_tool_calls: bool,
169 #[serde(default)]
171 pub tool_definitions: Vec<ToolDefinition>,
172 #[serde(default = "default_max_iterations")]
174 pub max_iterations: usize,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub error: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub user_facing_error: Option<UserFacingError>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub error_disclosure: Option<ErrorDisclosure>,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub usage: Option<TokenUsage>,
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub output_message_id: Option<MessageId>,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub time_to_first_token_ms: Option<u64>,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 pub response_id: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub finish_reason: Option<String>,
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub locale: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub network_access: Option<crate::network_access::NetworkAccessList>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub parallel_tool_calls: Option<bool>,
212}
213
214fn default_max_iterations() -> usize {
215 500
216}
217
218pub struct ReasonAtom {
237 context_resolver: Arc<dyn TurnContextResolver>,
238 message_retriever: Arc<dyn MessageRetriever>,
239 capability_registry: CapabilityRegistry,
240 event_emitter: PhaseEffectEmitter<dyn PhaseEffectSink>,
241 image_resolver: Option<Arc<dyn ImageResolver>>,
243 stream_heartbeater: Option<Arc<dyn crate::durability::StreamHeartbeater>>,
245 provider_stall_timeout: Option<std::time::Duration>,
247 provider_retry_config: LlmRetryConfig,
249 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
251 partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
253 reasoning_effort_handle: Option<crate::tool_context::ReasoningEffortHandle>,
257 utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
261 schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
267 compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
269}
270
271impl ReasonAtom {
272 pub fn new(
274 context_resolver: impl TurnContextResolver + 'static,
275 message_retriever: impl MessageRetriever + 'static,
276 capability_registry: CapabilityRegistry,
277 event_emitter: impl PhaseEffectSink + 'static,
278 ) -> Self {
279 Self {
280 context_resolver: Arc::new(context_resolver),
281 message_retriever: Arc::new(message_retriever),
282 capability_registry,
283 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
284 image_resolver: None,
285 stream_heartbeater: None,
286 provider_stall_timeout: None,
287 provider_retry_config: LlmRetryConfig::default(),
288 durable_tool_result_store: None,
289 partial_stream_store: None,
290 reasoning_effort_handle: None,
291 utility_llm_service: None,
292 schedule_store: None,
293 compaction_checkpoint_store: None,
294 }
295 }
296
297 pub fn with_schedule_store(
300 mut self,
301 store: Arc<dyn crate::session_services::SessionScheduleStore>,
302 ) -> Self {
303 self.schedule_store = Some(store);
304 self
305 }
306
307 pub fn with_compaction_checkpoint_store(
308 mut self,
309 store: Arc<dyn crate::CompactionCheckpointStore>,
310 ) -> Self {
311 self.compaction_checkpoint_store = Some(store);
312 self
313 }
314
315 fn collect_llm_error_hooks(
321 &self,
322 resolved_capability_configs: &[crate::CapabilityRef],
323 ) -> Vec<(
324 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
325 serde_json::Value,
326 )> {
327 resolved_capability_configs
328 .iter()
329 .filter_map(|cfg| {
330 let cap = self.capability_registry.get(cfg.capability_id())?;
331 let hook = cap.llm_error_hook()?;
332 Some((hook, cfg.config_value().clone()))
333 })
334 .collect()
335 }
336
337 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
350 self.image_resolver = Some(resolver);
351 self
352 }
353
354 pub fn with_stream_heartbeater(
356 mut self,
357 heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
358 ) -> Self {
359 self.stream_heartbeater = Some(heartbeater);
360 self
361 }
362
363 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
366 self.provider_stall_timeout = Some(timeout);
367 self
368 }
369
370 pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
372 self.provider_retry_config = config;
373 self
374 }
375
376 pub fn with_durable_tool_result_store(
382 mut self,
383 store: Arc<dyn DurableToolResultStore>,
384 ) -> Self {
385 self.durable_tool_result_store = Some(store);
386 self
387 }
388
389 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
391 self.partial_stream_store = Some(store);
392 self
393 }
394
395 pub fn with_reasoning_effort_handle(
402 mut self,
403 handle: crate::tool_context::ReasoningEffortHandle,
404 ) -> Self {
405 self.reasoning_effort_handle = Some(handle);
406 self
407 }
408
409 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
412 self.utility_llm_service = Some(service);
413 self
414 }
415}
416
417impl ReasonAtom {
418 pub fn name(&self) -> &'static str {
420 "reason"
421 }
422
423 pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
425 self.execute_inner(input, None).await
426 }
427}
428
429impl ReasonAtom {
430 pub async fn execute_with_assembled_context(
435 &self,
436 input: ReasonInput,
437 assembled: AssembledTurnContext,
438 ) -> Result<ReasonResult> {
439 self.execute_inner(input, Some(assembled)).await
440 }
441
442 async fn emit_capability_usage_snapshot(
443 &self,
444 session_id: SessionId,
445 context: &ExecutionContext,
446 resolved_capability_configs: &[crate::CapabilityRef],
447 tool_definitions: &[ToolDefinition],
448 ) {
449 let records = capability_usage_snapshot_records(
450 &self.capability_registry,
451 resolved_capability_configs,
452 tool_definitions,
453 );
454 if records.is_empty() {
455 return;
456 }
457
458 if let Err(error) = self
459 .event_emitter
460 .emit(EventRequest::new(
461 session_id,
462 EventContext::from_execution_context(context),
463 CapabilityUsageData { records },
464 ))
465 .await
466 {
467 tracing::warn!(
468 session_id = %session_id,
469 error = %error,
470 "ReasonAtom: failed to emit capability.usage event"
471 );
472 }
473 }
474
475 async fn apply_finalized_tool_call_hooks(
478 &self,
479 session_id: SessionId,
480 context: &ExecutionContext,
481 resolved_capability_configs: &[crate::CapabilityRef],
482 tool_definitions: &[ToolDefinition],
483 tool_calls: &mut [ToolCall],
484 iteration: u32,
485 ) {
486 apply_finalized_tool_calls_hooks(
487 &self.capability_registry,
488 self.event_emitter.as_ref(),
489 session_id,
490 context,
491 resolved_capability_configs,
492 tool_definitions,
493 tool_calls,
494 iteration,
495 )
496 .await;
497 }
498
499 async fn execute_inner(
500 &self,
501 input: ReasonInput,
502 assembled: Option<AssembledTurnContext>,
503 ) -> Result<ReasonResult> {
504 let ReasonInput {
505 context,
506 harness_id,
507 agent_id,
508 org_id,
509 mcp_tool_definitions,
510 previous_response_id,
511 iteration,
512 } = input;
513
514 tracing::info!(
515 session_id = %context.session_id,
516 turn_id = %context.turn_id,
517 exec_id = %context.exec_id,
518 harness_id = %harness_id,
519 agent_id = ?agent_id,
520 mcp_tools_count = %mcp_tool_definitions.len(),
521 "ReasonAtom: starting LLM call"
522 );
523
524 let trace_id = context.turn_id.to_string();
532 let reason_span_id = Uuid::now_v7().to_string();
533 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
537 trace_id.clone(),
538 reason_span_id.clone(),
539 Some(parent_span_id.clone()),
540 );
541
542 let reason_start = Instant::now();
544
545 if let Err(e) = self
547 .event_emitter
548 .emit(EventRequest::new(
549 context.session_id,
550 event_context.clone(),
551 ReasonStartedData {
552 harness_id,
553 agent_id,
554 metadata: None, },
556 ))
557 .await
558 {
559 tracing::warn!(
560 session_id = %context.session_id,
561 error = %e,
562 "ReasonAtom: failed to emit reason.started event"
563 );
564 }
565
566 let assembled = match assembled {
570 Some(assembled) => Ok(assembled),
571 None => {
572 self.context_resolver
573 .resolve_turn_context(TurnContextRequest {
574 session_id: context.session_id,
575 harness_id,
576 agent_id,
577 mcp_tool_definitions: mcp_tool_definitions.clone(),
578 })
579 .await
580 }
581 };
582
583 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
584 Ok(assembled) => {
585 let error_disclosure = resolve_error_disclosure(
586 &self.capability_registry,
587 &assembled.resolved_capability_configs,
588 error_disclosure_override(&assembled.messages).as_deref(),
589 );
590 let error_hooks =
594 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
595 let error_context = UserFacingErrorContext::default()
596 .with_provider(assembled.model.provider_type.to_string())
597 .with_model_id(assembled.model.model.clone());
598 let call_result = self
599 .execute_llm_call(
600 context.session_id,
601 harness_id,
602 agent_id,
603 org_id,
604 &context,
605 &trace_id,
606 &reason_span_id,
607 previous_response_id,
608 iteration,
609 assembled,
610 )
611 .await;
612 (error_disclosure, error_context, error_hooks, call_result)
613 }
614 Err(error) => (
615 ErrorDisclosure::default(),
616 UserFacingErrorContext::default(),
617 Vec::new(),
618 Err(error),
619 ),
620 };
621
622 let result = match call_result {
624 Ok(result) => {
625 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
627
628 let completed_context = EventContext::from_execution_context(&context).with_span(
630 trace_id.clone(),
631 reason_span_id.clone(), Some(parent_span_id.clone()),
633 );
634 if let Err(e) = self
635 .event_emitter
636 .emit(EventRequest::new(
637 context.session_id,
638 completed_context,
639 ReasonCompletedData::success(
640 &result.text,
641 result.has_tool_calls,
642 result.tool_calls.len() as u32,
643 Some(reason_duration_ms),
644 result.usage.clone(),
645 ),
646 ))
647 .await
648 {
649 tracing::warn!(
650 session_id = %context.session_id,
651 error = %e,
652 "ReasonAtom: failed to emit reason.completed event"
653 );
654 }
655 result
656 }
657 Err(e) => {
658 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
660
661 tracing::warn!(
664 session_id = %context.session_id,
665 turn_id = %context.turn_id,
666 error = %e,
667 "ReasonAtom: LLM call failed"
668 );
669
670 let error_msg = e.to_string();
671 let mut source_error = e.user_facing_error(error_context);
672
673 let is_transient = e.is_transient_llm_error()
680 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
681
682 if !is_transient && !error_hooks.is_empty() {
688 let services = crate::llm_error_hook::LlmErrorHookServices {
689 schedule_store: self.schedule_store.clone(),
690 };
691 for (hook, config) in &error_hooks {
692 let outcome = {
693 let ctx = crate::llm_error_hook::LlmErrorContext {
694 session_id: context.session_id,
695 error_code: &source_error.code,
696 error_fields: &source_error.fields,
697 config,
698 services: &services,
699 };
700 hook.on_llm_error(&ctx).await
701 };
702 for (key, value) in outcome.extra_error_fields {
703 source_error = source_error.with_field(key, value);
704 }
705 }
706 }
707
708 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
709 let user_error_text = user_error.fallback_message();
710
711 let mut output_message_id = None;
712
713 if !is_transient {
714 let mut error_message = Message::assistant(&user_error_text);
716 let mut metadata = std::collections::HashMap::new();
717 user_error.apply_to_message_metadata(&mut metadata);
718 UserFacingError::apply_disclosure_to_message_metadata(
719 &mut metadata,
720 error_disclosure,
721 &source_error.code,
722 );
723 error_message.metadata = Some(metadata);
724
725 output_message_id = Some(error_message.id);
726
727 let error_msg_context = EventContext::from_execution_context(&context)
730 .with_span(
731 trace_id.clone(),
732 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
735 if let Err(emit_err) = self
736 .event_emitter
737 .emit(EventRequest::new(
738 context.session_id,
739 error_msg_context,
740 OutputMessageCompletedData::new(error_message)
741 .with_user_facing_error(&user_error)
742 .with_error_disclosure(error_disclosure),
743 ))
744 .await
745 {
746 tracing::warn!(
747 session_id = %context.session_id,
748 error = %emit_err,
749 "ReasonAtom: failed to emit output.message.completed event for error"
750 );
751 }
752 } else {
753 tracing::info!(
754 session_id = %context.session_id,
755 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
756 );
757 }
758
759 let completed_context = EventContext::from_execution_context(&context).with_span(
761 trace_id.clone(),
762 reason_span_id.clone(), Some(parent_span_id.clone()),
764 );
765 if let Err(emit_err) = self
766 .event_emitter
767 .emit(EventRequest::new(
768 context.session_id,
769 completed_context,
770 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
771 ))
772 .await
773 {
774 tracing::warn!(
775 session_id = %context.session_id,
776 error = %emit_err,
777 "ReasonAtom: failed to emit reason.completed event"
778 );
779 }
780
781 ReasonResult {
782 success: false,
783 text: user_error_text,
784 tool_calls: vec![],
785 has_tool_calls: false,
786 tool_definitions: vec![],
787 max_iterations: default_max_iterations(),
788 error: Some(error_msg.clone()),
789 user_facing_error: Some(user_error),
790 error_disclosure: Some(error_disclosure),
791 usage: None,
792 output_message_id,
793 time_to_first_token_ms: None,
794 response_id: None,
795 finish_reason: error_msg
796 .to_ascii_lowercase()
797 .contains("model refused")
798 .then(|| "refusal".to_string()),
799 locale: None,
800 network_access: None,
801 parallel_tool_calls: None,
802 }
803 }
804 };
805
806 Ok(result)
807 }
808
809 #[allow(clippy::too_many_arguments)]
811 async fn execute_llm_call(
812 &self,
813 session_id: SessionId,
814 harness_id: HarnessId,
815 agent_id: Option<AgentId>,
816 org_id: i64,
817 context: &ExecutionContext,
818 trace_id: &str,
819 reason_span_id: &str,
820 previous_response_id: Option<String>,
821 iteration: u32,
822 assembled: AssembledTurnContext,
823 ) -> Result<ReasonResult> {
824 let prior_usage = assembled.cumulative_usage();
825 let mut messages = assembled.messages;
826 let mut message_source_sequence = assembled.message_source_sequence;
827 let model_with_provider = assembled.model;
828 let resolved_model_id = assembled.resolved_model_id;
829 let resolved_locale = assembled.resolved_locale;
830 let compaction_policy = assembled.compaction_policy;
831 let resolved_capability_configs = assembled.resolved_capability_configs;
832 let runtime_agent = assembled.runtime_agent;
833 let embedder_metadata = assembled.embedder_metadata;
834
835 self.emit_capability_usage_snapshot(
836 session_id,
837 context,
838 &resolved_capability_configs,
839 &runtime_agent.tools,
840 )
841 .await;
842
843 let output_hooks =
844 collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
845 let guardrail_providers = output_hooks.streaming;
846 let post_output_providers = output_hooks.post_generation;
847 let annotation_providers = output_hooks.annotations;
848 let citation_verifiers = output_hooks.citation_verifiers;
849
850 let chat_driver = Arc::clone(&model_with_provider.driver);
852 let stateful_response_continuation =
853 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
854 let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
855 let mut checkpoint_suffix_message_count = 0usize;
856
857 if compaction_policy.is_some()
858 && let Some(store) = self.compaction_checkpoint_store.as_ref()
859 && let Some(checkpoint) = store
860 .get_latest(
861 session_id,
862 model_with_provider.provider_type.as_str(),
863 &model_with_provider.model,
864 )
865 .await?
866 && checkpoint.is_compatible(
867 model_with_provider.provider_type.as_str(),
868 &model_with_provider.model,
869 )
870 {
871 let filters = crate::capabilities::collect_message_filters_only(
872 &resolved_capability_configs,
873 &self.capability_registry,
874 );
875 let mut query =
876 crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
877 filters.apply_message_filters(&mut query);
878 let history = self.message_retriever.load_filtered_history(query).await?;
879 messages = history.messages;
880 checkpoint_suffix_message_count = messages.len();
881 filters.apply_post_load_filters(&mut messages);
882 if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
883 messages.insert(
884 0,
885 Message::system(format!(
886 "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
887 )),
888 );
889 }
890 message_source_sequence = history.source_sequence.or(message_source_sequence);
891 restored_checkpoint = Some(checkpoint);
892 }
893
894 let controls = resolve_request_controls(
895 &messages,
896 self.reasoning_effort_handle.as_ref(),
897 &model_with_provider.provider_type,
898 &model_with_provider.model,
899 );
900 let reasoning_effort = controls.reasoning_effort;
901 let speed = controls.speed;
902 let verbosity = controls.verbosity;
903
904 if let Some(ref store) = self.partial_stream_store {
908 let turn_id_str = context.turn_id.to_string();
909 match store.get_partial_stream(session_id, &turn_id_str).await {
910 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
911 return self
913 .finalize_partial_stream(
914 session_id,
915 context,
916 partial,
917 iteration,
918 &runtime_agent,
919 &resolved_capability_configs,
920 )
921 .await;
922 }
923 Ok(Some(_)) => {
924 let recovery_ctx = EventContext::from_execution_context(context);
927 let _ = self
928 .event_emitter
929 .emit(EventRequest::new(
930 session_id,
931 recovery_ctx,
932 ReasonRecoveredData {
933 turn_id: context.turn_id,
934 mode: RecoveryMode::Restart,
935 accumulated_len: 0,
936 },
937 ))
938 .await;
939 tracing::info!(
940 session_id = %session_id,
941 turn_id = %context.turn_id,
942 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
943 );
944 }
945 Ok(None) => {} Err(e) => {
947 tracing::warn!(
949 session_id = %session_id,
950 turn_id = %context.turn_id,
951 error = %e,
952 "ReasonAtom: partial-stream store error; proceeding with normal execution"
953 );
954 }
955 }
956 }
957
958 let repair_event_context = EventContext::from_execution_context(context);
962 let patched_messages = repair_dangling_tool_calls(
963 &messages,
964 self.durable_tool_result_store.as_deref(),
965 self.event_emitter.as_ref(),
966 session_id,
967 &repair_event_context,
968 &context.turn_id.to_string(),
969 )
970 .await;
971 let raw_tool_result_bytes = compaction_policy
972 .as_ref()
973 .map(|policy| policy.total_tool_result_bytes(&patched_messages))
974 .unwrap_or(0);
975
976 let model_view_providers = crate::capabilities::collect_model_view_providers(
979 &resolved_capability_configs,
980 &self.capability_registry,
981 Some(model_with_provider.model.as_str()),
982 );
983 let model_view_context = crate::capabilities::ModelViewContext {
984 session_id,
985 prior_usage: prior_usage.as_ref(),
986 };
987 let mut context_messages =
988 model_view_providers.apply_model_view(patched_messages, &model_view_context);
989 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
990 &context_messages,
991 stateful_response_continuation || restored_checkpoint.is_some(),
992 );
993
994 let mut volatile_suffix_len = 0usize;
1001 {
1002 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1003 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1004 &resolved_capability_configs,
1005 &self.capability_registry,
1006 Some(model_with_provider.model.as_str()),
1007 &facts_ctx,
1008 );
1009 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1010 context_messages.push(Message::user(block));
1011 volatile_suffix_len = 1;
1012 }
1013 }
1014
1015 let resolved_images = self.resolve_images(&context_messages).await;
1020
1021 let mut llm_messages = Vec::new();
1023
1024 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1026 if has_system_prompt {
1027 llm_messages.push(LlmMessage {
1028 role: LlmMessageRole::System,
1029 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1030 tool_calls: None,
1031 tool_call_id: None,
1032 phase: None,
1033 thinking: None,
1034 thinking_signature: None,
1035 });
1036 }
1037
1038 let messages_for_event: Vec<Message> = if has_system_prompt {
1040 std::iter::once(Message::system(&runtime_agent.system_prompt))
1041 .chain(context_messages.iter().cloned())
1042 .collect()
1043 } else {
1044 context_messages.clone()
1045 };
1046
1047 let mut stripped_error_count = 0u32;
1053 for msg in &context_messages {
1054 if is_error_placeholder_message(msg) {
1055 stripped_error_count += 1;
1056 continue;
1057 }
1058 let mut llm_msg =
1059 crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1060 if msg.role == MessageRole::User
1061 && let Some(ref actor) = msg.external_actor
1062 {
1063 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1064 }
1065 llm_messages.push(llm_msg);
1066 }
1067 if stripped_error_count > 0 {
1068 tracing::info!(
1069 session_id = %session_id,
1070 stripped_error_count,
1071 "ReasonAtom: stripped error placeholder messages from LLM input"
1072 );
1073 }
1074
1075 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1080 llm_messages,
1081 stateful_response_continuation || restored_checkpoint.is_some(),
1082 );
1083
1084 let mut llm_config_builder =
1086 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1087 if let Some(effort) = reasoning_effort.clone() {
1088 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1089 }
1090 if let Some(speed) = speed {
1091 llm_config_builder = llm_config_builder.speed(speed);
1092 }
1093 if let Some(verbosity) = verbosity {
1094 llm_config_builder = llm_config_builder.verbosity(verbosity);
1095 }
1096
1097 for (k, v) in &embedder_metadata {
1099 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1100 }
1101
1102 llm_config_builder = llm_config_builder
1106 .with_metadata("session_id", session_id.to_string())
1107 .with_metadata("harness_id", harness_id.to_string())
1108 .with_metadata("turn_id", context.turn_id.to_string())
1109 .with_metadata("exec_id", context.exec_id.to_string())
1110 .with_metadata("org_id", format!("org_{:032x}", org_id));
1111 if let Some(agent_id) = agent_id {
1112 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1113 }
1114
1115 if let Some(model_id) = &resolved_model_id {
1117 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1118 }
1119
1120 let mut llm_config = llm_config_builder
1121 .previous_response_id(previous_response_id.clone())
1122 .volatile_suffix_len(volatile_suffix_len)
1123 .build();
1124 if let Some(checkpoint) = restored_checkpoint.as_ref()
1125 && let crate::CompactionCheckpointPayload::ProviderOpaque { context } =
1126 &checkpoint.payload
1127 {
1128 llm_config.previous_response_id = None;
1129 llm_config.provider_opaque_context = Some(context.clone());
1130 }
1131
1132 tracing::debug!(
1133 session_id = %session_id,
1134 turn_id = %context.turn_id,
1135 model = %runtime_agent.model,
1136 message_count = %llm_messages.len(),
1137 "ReasonAtom: calling LLM"
1138 );
1139
1140 let streaming_event_context = EventContext::from_execution_context(context);
1143
1144 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1151 for (cap_id, cfg, provider) in &guardrail_providers {
1152 let ctx = OutputGuardrailContext {
1153 system_prompt: &runtime_agent.system_prompt,
1154 config: cfg,
1155 };
1156 let guardrail_id = provider.id().to_string();
1157 if let Some(run) = provider.arm(&ctx) {
1158 armed_guardrails.push(ArmedGuardrail {
1159 capability_id: cap_id.clone(),
1160 guardrail_id,
1161 run,
1162 });
1163 }
1164 }
1165 let buffer_output_deltas = !post_output_providers.is_empty();
1170 let output_message_id = MessageId::new();
1174 tracing::info!(
1175 session_id = %session_id,
1176 turn_id = %context.turn_id,
1177 "ReasonAtom: emitting output.message.started event"
1178 );
1179 if let Err(e) = self
1180 .event_emitter
1181 .emit(EventRequest::new(
1182 session_id,
1183 streaming_event_context.clone(),
1184 OutputMessageStartedData {
1185 turn_id: context.turn_id,
1186 message_id: output_message_id,
1187 model: Some(runtime_agent.model.clone()),
1188 iteration: Some(iteration),
1189 phase: None,
1192 },
1193 ))
1194 .await
1195 {
1196 tracing::warn!(
1197 session_id = %session_id,
1198 error = %e,
1199 "ReasonAtom: failed to emit output.message.started event"
1200 );
1201 } else {
1202 tracing::info!(
1203 session_id = %session_id,
1204 "ReasonAtom: output.message.started event emitted successfully"
1205 );
1206 }
1207
1208 let thinking_enabled = reasoning_effort.is_some();
1210 if thinking_enabled {
1211 tracing::info!(
1212 session_id = %session_id,
1213 turn_id = %context.turn_id,
1214 "ReasonAtom: emitting reason.thinking.started event"
1215 );
1216 if let Err(e) = self
1217 .event_emitter
1218 .emit(EventRequest::new(
1219 session_id,
1220 streaming_event_context.clone(),
1221 ReasonThinkingStartedData {
1222 turn_id: context.turn_id,
1223 model: Some(runtime_agent.model.clone()),
1224 },
1225 ))
1226 .await
1227 {
1228 tracing::warn!(
1229 session_id = %session_id,
1230 error = %e,
1231 "ReasonAtom: failed to emit reason.thinking.started event"
1232 );
1233 } else {
1234 tracing::info!(
1235 session_id = %session_id,
1236 "ReasonAtom: reason.thinking.started event emitted successfully"
1237 );
1238 }
1239 }
1240
1241 let llm_start = Instant::now();
1243
1244 let mut compaction_info: Option<LlmCompactionInfo> = None;
1248 let mut llm_messages_for_call = llm_messages.clone();
1249
1250 if let Some(policy) = compaction_policy.as_deref() {
1251 compaction_info = apply_proactive_compaction(
1252 ProactiveCompactionContext {
1253 chat_driver: chat_driver.as_ref(),
1254 policy,
1255 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1256 event_emitter: self.event_emitter.as_ref(),
1257 event_context: &streaming_event_context,
1258 session_id,
1259 message_source_sequence,
1260 provider_type: model_with_provider.provider_type.as_str(),
1261 model: &model_with_provider.model,
1262 system_prompt: has_system_prompt
1263 .then_some(runtime_agent.system_prompt.as_str()),
1264 stateful_response_continuation,
1265 checkpoint_restored: restored_checkpoint.is_some(),
1266 checkpoint_suffix_message_count,
1267 raw_tool_result_bytes,
1268 prior_usage: prior_usage.as_ref(),
1269 },
1270 &mut llm_messages_for_call,
1271 &mut llm_config,
1272 )
1273 .await?;
1274 }
1275
1276 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1279 let retry_config = self.provider_retry_config.clone();
1280 let mut stream_retry_metadata = RetryMetadata::default();
1281 let mut retry_started_at = None;
1282 let mut streamed_phase: Option<everruns_provider::ExecutionPhase> = None;
1287 let (
1288 text,
1289 thinking,
1290 thinking_signature,
1291 tool_calls,
1292 completion_metadata,
1293 time_to_first_token_ms,
1294 pending_delta,
1295 mut tripped,
1296 ) = 'stream_attempt: loop {
1297 let stream_result = if let Some(remaining) =
1298 remaining_retry_time(&retry_config, retry_started_at)
1299 {
1300 match tokio::time::timeout(
1301 remaining,
1302 chat_driver.chat_completion_stream(
1303 &crate::ProviderEndpoint::default(),
1304 llm_messages_for_call.clone(),
1305 &llm_config,
1306 ),
1307 )
1308 .await
1309 {
1310 Ok(result) => result,
1311 Err(_) => {
1312 return Err(AgentLoopError::llm_kind(
1313 crate::error::LlmErrorKind::Unavailable,
1314 format!(
1315 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1316 stream_retry_metadata.attempts,
1317 retry_config.max_retry_elapsed.as_secs_f64()
1318 ),
1319 )
1320 .with_retry_metadata(&stream_retry_metadata));
1321 }
1322 }
1323 } else {
1324 chat_driver
1325 .chat_completion_stream(
1326 &crate::ProviderEndpoint::default(),
1327 llm_messages_for_call.clone(),
1328 &llm_config,
1329 )
1330 .await
1331 };
1332 let mut stream = match stream_result {
1333 Ok(stream) => stream,
1334 Err(e) if e.is_request_too_large() => {
1335 let Some(policy) = compaction_policy.as_deref() else {
1336 tracing::warn!(
1337 session_id = %session_id,
1338 turn_id = %context.turn_id,
1339 "ReasonAtom: context too large and compaction capability is not enabled"
1340 );
1341 return Err(e);
1342 };
1343 let outcome = apply_reactive_compaction(
1344 ReactiveCompactionContext {
1345 chat_driver: chat_driver.as_ref(),
1346 policy,
1347 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1348 event_emitter: self.event_emitter.as_ref(),
1349 event_context: &streaming_event_context,
1350 session_id,
1351 message_source_sequence,
1352 provider_type: model_with_provider.provider_type.as_str(),
1353 model: &model_with_provider.model,
1354 summarization_model_fallback: &runtime_agent.model,
1355 system_prompt: has_system_prompt
1356 .then_some(runtime_agent.system_prompt.as_str()),
1357 stateful_response_continuation,
1358 },
1359 &mut llm_messages_for_call,
1360 &mut llm_config,
1361 )
1362 .await?;
1363 let Some(outcome) = outcome else {
1364 return Err(e);
1365 };
1366 if outcome.generation_info.is_some() {
1367 compaction_info = outcome.generation_info;
1368 }
1369
1370 chat_driver
1371 .chat_completion_stream(
1372 &crate::ProviderEndpoint::default(),
1373 llm_messages_for_call.clone(),
1374 &llm_config,
1375 )
1376 .await?
1377 }
1378 Err(e)
1379 if e.is_transient_llm_error()
1380 && !e.llm_retry_handled()
1381 && stream_retry_metadata.attempts < retry_config.max_retries =>
1382 {
1383 let proposed_wait =
1384 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1385 let Some(wait_duration) =
1386 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1387 else {
1388 return Err(AgentLoopError::llm_kind(
1389 e.llm_error_kind()
1390 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1391 format!(
1392 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1393 stream_retry_metadata.attempts
1394 ),
1395 )
1396 .with_retry_metadata(&stream_retry_metadata));
1397 };
1398 tracing::warn!(
1399 session_id = %session_id,
1400 turn_id = %context.turn_id,
1401 attempt = stream_retry_metadata.attempts + 1,
1402 max_retries = retry_config.max_retries,
1403 wait_secs = wait_duration.as_secs_f64(),
1404 error = %e,
1405 "ReasonAtom: transient provider failure before stream, retrying"
1406 );
1407 stream_retry_metadata.record_retry(wait_duration, None);
1408 tokio::time::sleep(wait_duration).await;
1409 continue 'stream_attempt;
1410 }
1411 Err(e) => return Err(e),
1412 };
1413
1414 let mut text = String::new();
1415 let mut thinking = String::new();
1416 let mut thinking_signature: Option<String> = None;
1417 let mut tool_calls = Vec::new();
1418 let mut termination = StreamTermination::Exhausted;
1419 let mut replay_state = StreamReplayState::default();
1420 let mut pending_delta = String::new();
1421 let mut pending_thinking_delta = String::new();
1422 let mut last_delta_emit = Instant::now();
1423 let mut last_thinking_delta_emit = Instant::now();
1424 let mut time_to_first_token_ms: Option<u64> = None;
1425
1426 let stall_timeout = self
1428 .provider_stall_timeout
1429 .unwrap_or(std::time::Duration::from_secs(120));
1430 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1431 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1432 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1433 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1434 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1435 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1437 let mut last_token_at_unix: u64 = unix_now_secs();
1442
1443 loop {
1444 let event = tokio::select! {
1445 biased;
1446 next = stream.next() => match next {
1447 Some(e) => e,
1448 None => break,
1449 },
1450 _ = &mut stall_sleep => {
1451 let stall_error =
1461 crate::driver_registry::LlmStreamError::new(format!(
1462 "provider stream stall: no tokens for {}s",
1463 stall_timeout.as_secs()
1464 ));
1465 tracing::warn!(
1466 session_id = %session_id,
1467 turn_id = %context.turn_id,
1468 stall_secs = stall_timeout.as_secs(),
1469 "ReasonAtom: provider stream stall timeout"
1470 );
1471 if replay_state.should_retry(
1472 &stall_error,
1473 stream_retry_metadata.attempts,
1474 retry_config.max_retries,
1475 ) {
1476 let proposed_wait = retry_config
1477 .calculate_backoff(stream_retry_metadata.attempts);
1478 let Some(wait_duration) = reserve_retry_wait(
1479 &retry_config,
1480 &mut retry_started_at,
1481 proposed_wait,
1482 ) else {
1483 return Err(AgentLoopError::llm_kind(
1484 crate::error::LlmErrorKind::Unavailable,
1485 format!(
1486 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1487 stall_error.message,
1488 stream_retry_metadata.attempts
1489 ),
1490 )
1491 .with_retry_metadata(&stream_retry_metadata));
1492 };
1493 tracing::warn!(
1494 session_id = %session_id,
1495 turn_id = %context.turn_id,
1496 attempt = stream_retry_metadata.attempts + 1,
1497 max_retries = retry_config.max_retries,
1498 wait_secs = wait_duration.as_secs_f64(),
1499 "ReasonAtom: provider stream stall, retrying"
1500 );
1501 stream_retry_metadata.record_retry(wait_duration, None);
1502 tokio::time::sleep(wait_duration).await;
1503 continue 'stream_attempt;
1504 }
1505 return Err(AgentLoopError::llm(stall_error.message));
1506 },
1507 _ = keepalive_ticker.tick() => {
1508 if let Some(ref hb) = self.stream_heartbeater {
1509 hb.heartbeat(crate::durability::StreamProgress {
1510 accumulated_len: text.len() + thinking.len(),
1511 last_delta_at: last_token_at_unix,
1512 })
1513 .await;
1514 last_stream_heartbeat = Instant::now();
1515 }
1516 continue;
1517 },
1518 };
1519 let event = event?;
1520 replay_state.observe(&event);
1521 let advanced_stall_deadline = advances_stall_deadline(&event);
1522 if advanced_stall_deadline {
1523 stall_sleep
1524 .as_mut()
1525 .reset(tokio::time::Instant::now() + stall_timeout);
1526 last_token_at_unix = unix_now_secs();
1527 }
1528 match event {
1529 LlmStreamEvent::TextDelta(delta) => {
1530 if delta.is_empty() {
1531 continue;
1532 }
1533 if time_to_first_token_ms.is_none() {
1535 let ttft = llm_start.elapsed().as_millis() as u64;
1536 time_to_first_token_ms = Some(ttft);
1537 tracing::info!(
1538 session_id = %session_id,
1539 time_to_first_token_ms = ttft,
1540 "ReasonAtom: received first token from LLM"
1541 );
1542 }
1543 text.push_str(&delta);
1544 pending_delta.push_str(&delta);
1545
1546 if !armed_guardrails.is_empty()
1553 && let Some(t) =
1554 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1555 {
1556 tracing::warn!(
1557 session_id = %session_id,
1558 turn_id = %context.turn_id,
1559 guardrail_capability_id = %t.capability_id,
1560 guardrail_id = %t.guardrail_id,
1561 reason_code = %t.block.reason_code,
1562 "ReasonAtom: output guardrail tripped, replacing assistant message"
1563 );
1564 pending_delta.clear();
1565 termination = StreamTermination::GuardrailBlocked(t);
1566 break;
1567 }
1568
1569 if !buffer_output_deltas
1571 && last_delta_emit.elapsed().as_millis() as u64
1572 >= DELTA_BATCH_INTERVAL_MS
1573 && !pending_delta.is_empty()
1574 {
1575 if let Err(e) = self
1576 .event_emitter
1577 .emit(EventRequest::new(
1578 session_id,
1579 streaming_event_context.clone(),
1580 OutputMessageDeltaData {
1581 turn_id: context.turn_id,
1582 message_id: output_message_id,
1583 delta: pending_delta.clone(),
1584 accumulated: text.clone(),
1585 phase: streamed_phase,
1586 },
1587 ))
1588 .await
1589 {
1590 tracing::warn!(
1591 session_id = %session_id,
1592 error = %e,
1593 "ReasonAtom: failed to emit output.message.delta event"
1594 );
1595 }
1596 pending_delta.clear();
1597 last_delta_emit = Instant::now();
1598 }
1599 }
1600 LlmStreamEvent::ThinkingDelta(delta) => {
1601 if delta.is_empty() {
1602 continue;
1603 }
1604 if let Some(t) = append_guarded_thinking_delta(
1605 &mut armed_guardrails,
1606 &mut thinking,
1607 &mut pending_thinking_delta,
1608 &delta,
1609 ) {
1610 tracing::warn!(
1611 session_id = %session_id,
1612 guardrail_capability_id = %t.capability_id,
1613 guardrail_id = %t.guardrail_id,
1614 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1615 );
1616 termination = StreamTermination::GuardrailBlocked(t);
1617 break;
1618 }
1619 tracing::debug!(
1620 session_id = %session_id,
1621 delta_len = delta.len(),
1622 total_thinking_len = thinking.len(),
1623 "ReasonAtom: received ThinkingDelta from LLM"
1624 );
1625
1626 if last_thinking_delta_emit.elapsed().as_millis() as u64
1628 >= DELTA_BATCH_INTERVAL_MS
1629 && !pending_thinking_delta.is_empty()
1630 {
1631 if let Err(e) = self
1632 .event_emitter
1633 .emit(EventRequest::new(
1634 session_id,
1635 streaming_event_context.clone(),
1636 ReasonThinkingDeltaData {
1637 turn_id: context.turn_id,
1638 delta: pending_thinking_delta.clone(),
1639 accumulated: thinking.clone(),
1640 },
1641 ))
1642 .await
1643 {
1644 tracing::warn!(
1645 session_id = %session_id,
1646 error = %e,
1647 "ReasonAtom: failed to emit reason.thinking.delta event"
1648 );
1649 }
1650 pending_thinking_delta.clear();
1651 last_thinking_delta_emit = Instant::now();
1652 }
1653 }
1654 LlmStreamEvent::ThinkingSignature(signature) => {
1655 tracing::debug!(
1657 session_id = %session_id,
1658 signature_len = signature.len(),
1659 "ReasonAtom: received ThinkingSignature from LLM"
1660 );
1661 thinking_signature = Some(signature);
1662 }
1663 LlmStreamEvent::ReasonItem {
1664 provider,
1665 model,
1666 item_id,
1667 encrypted_content,
1668 summary,
1669 token_count,
1670 } => {
1671 if let Some(sig) = encrypted_content.as_ref() {
1677 tracing::debug!(
1678 session_id = %session_id,
1679 signature_len = sig.len(),
1680 provider = %provider,
1681 item_id = %item_id,
1682 "ReasonAtom: captured encrypted reasoning content from ReasonItem"
1683 );
1684 thinking_signature = Some(sig.clone());
1685 }
1686 if let Err(e) = self
1687 .event_emitter
1688 .emit(EventRequest::new(
1689 session_id,
1690 streaming_event_context.clone(),
1691 ReasonItemData {
1692 turn_id: context.turn_id,
1693 provider,
1694 model,
1695 item_id,
1696 encrypted_content,
1697 summary,
1698 token_count,
1699 },
1700 ))
1701 .await
1702 {
1703 tracing::warn!(
1704 session_id = %session_id,
1705 error = %e,
1706 "ReasonAtom: failed to emit reason.item event"
1707 );
1708 }
1709 }
1710 LlmStreamEvent::ToolCalls(calls) => {
1711 tool_calls = calls;
1712 }
1713 LlmStreamEvent::MessagePhase(phase) => {
1714 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1723 streamed_phase,
1724 phase,
1725 );
1726 }
1727 LlmStreamEvent::Done(metadata) => {
1728 if !buffer_output_deltas
1732 && !pending_delta.is_empty()
1733 && let Err(e) = self
1734 .event_emitter
1735 .emit(EventRequest::new(
1736 session_id,
1737 streaming_event_context.clone(),
1738 OutputMessageDeltaData {
1739 turn_id: context.turn_id,
1740 message_id: output_message_id,
1741 delta: pending_delta.clone(),
1742 accumulated: text.clone(),
1743 phase: streamed_phase,
1744 },
1745 ))
1746 .await
1747 {
1748 tracing::warn!(
1749 session_id = %session_id,
1750 error = %e,
1751 "ReasonAtom: failed to emit final output.message.delta event"
1752 );
1753 }
1754
1755 if !pending_thinking_delta.is_empty()
1757 && let Err(e) = self
1758 .event_emitter
1759 .emit(EventRequest::new(
1760 session_id,
1761 streaming_event_context.clone(),
1762 ReasonThinkingDeltaData {
1763 turn_id: context.turn_id,
1764 delta: pending_thinking_delta.clone(),
1765 accumulated: thinking.clone(),
1766 },
1767 ))
1768 .await
1769 {
1770 tracing::warn!(
1771 session_id = %session_id,
1772 error = %e,
1773 "ReasonAtom: failed to emit final reason.thinking.delta event"
1774 );
1775 }
1776
1777 if !thinking.is_empty()
1779 && let Err(e) = self
1780 .event_emitter
1781 .emit(EventRequest::new(
1782 session_id,
1783 streaming_event_context.clone(),
1784 ReasonThinkingCompletedData {
1785 turn_id: context.turn_id,
1786 thinking: thinking.clone(),
1787 },
1788 ))
1789 .await
1790 {
1791 tracing::warn!(
1792 session_id = %session_id,
1793 error = %e,
1794 "ReasonAtom: failed to emit reason.thinking.completed event"
1795 );
1796 }
1797 termination = StreamTermination::Completed(*metadata);
1798 break;
1799 }
1800 LlmStreamEvent::Error(err) => {
1801 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
1806
1807 if has_partial_output {
1808 tracing::warn!(
1809 session_id = %session_id,
1810 error = %err,
1811 tool_call_count = tool_calls.len(),
1812 text_len = text.len(),
1813 "ReasonAtom: trailing stream error after valid output — treating as partial success"
1814 );
1815 termination = StreamTermination::PartialSuccess;
1819 break;
1820 }
1821
1822 if replay_state.should_retry(
1823 &err,
1824 stream_retry_metadata.attempts,
1825 retry_config.max_retries,
1826 ) {
1827 let proposed_wait =
1828 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1829 let Some(wait_duration) = reserve_retry_wait(
1830 &retry_config,
1831 &mut retry_started_at,
1832 proposed_wait,
1833 ) else {
1834 return Err(AgentLoopError::llm_kind(
1835 err.kind(),
1836 format!(
1837 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1838 stream_retry_metadata.attempts
1839 ),
1840 )
1841 .with_retry_metadata(&stream_retry_metadata));
1842 };
1843 tracing::warn!(
1844 session_id = %session_id,
1845 turn_id = %context.turn_id,
1846 attempt = stream_retry_metadata.attempts + 1,
1847 max_retries = retry_config.max_retries,
1848 wait_secs = wait_duration.as_secs_f64(),
1849 error_code = err.code.as_deref().unwrap_or("none"),
1850 error_status = err.status,
1851 error = %err,
1852 "ReasonAtom: transient stream error before output, retrying"
1853 );
1854 stream_retry_metadata.record_retry(wait_duration, None);
1855 tokio::time::sleep(wait_duration).await;
1856 continue 'stream_attempt;
1857 }
1858
1859 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
1861 let event_context = EventContext::from_execution_context(context)
1862 .with_span(
1863 trace_id.to_string(),
1864 Uuid::now_v7().to_string(),
1865 Some(reason_span_id.to_string()),
1866 );
1867 let tools_summary: Vec<ToolDefinitionSummary> =
1868 runtime_agent.tools.iter().map(|t| t.into()).collect();
1869 let generation_data = LlmGenerationData::failure(
1870 messages_for_event.clone(),
1871 tools_summary,
1872 runtime_agent.model.clone(),
1873 Some(model_with_provider.provider_type.to_string()),
1874 err.to_string(),
1875 Some(llm_duration_ms),
1876 time_to_first_token_ms,
1877 );
1878 let _ = self
1879 .event_emitter
1880 .emit(EventRequest::new(
1881 session_id,
1882 event_context,
1883 generation_data,
1884 ))
1885 .await;
1886 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
1887 }
1888 }
1889 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
1892 && let Some(ref hb) = self.stream_heartbeater
1893 {
1894 hb.heartbeat(crate::durability::StreamProgress {
1895 accumulated_len: text.len() + thinking.len(),
1896 last_delta_at: last_token_at_unix,
1897 })
1898 .await;
1899 last_stream_heartbeat = Instant::now();
1900 }
1901 }
1902 let (mut completion_metadata, tripped) = termination.into_parts();
1903 if let Some(metadata) = completion_metadata.as_mut() {
1904 metadata.retry_metadata =
1905 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
1906 }
1907
1908 break 'stream_attempt (
1909 text,
1910 thinking,
1911 thinking_signature,
1912 tool_calls,
1913 completion_metadata,
1914 time_to_first_token_ms,
1915 pending_delta,
1916 tripped,
1917 );
1918 };
1919 let (mut text, mut thinking, thinking_signature, mut tool_calls) =
1920 (text, thinking, thinking_signature, tool_calls);
1921
1922 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
1933 if tripped.is_none()
1934 && !annotation_providers.is_empty()
1935 && !text.is_empty()
1936 && tool_calls.is_empty()
1937 {
1938 text = filter_response_text(
1941 &self.capability_registry,
1942 &resolved_capability_configs,
1943 text,
1944 );
1945 let collected = collect_annotations(
1946 &annotation_providers,
1947 &runtime_agent.system_prompt,
1948 &text,
1949 &messages,
1950 self.utility_llm_service.as_ref(),
1951 )
1952 .await;
1953 text = collected.text;
1954 citation_annotations = collected.annotations;
1955
1956 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
1959 let guarded_output = post_generation_guardrail_text(&text, &citation_annotations);
1960 let ctx = PostGenerationOutputContext {
1961 system_prompt: &runtime_agent.system_prompt,
1962 message_text: &guarded_output,
1963 utility_llm_service: self.utility_llm_service.as_ref(),
1964 };
1965 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
1966 }
1967
1968 if tripped.is_none()
1971 && !citation_annotations.is_empty()
1972 && !citation_verifiers.is_empty()
1973 {
1974 citation_annotations = verify_annotations(
1975 &citation_verifiers,
1976 &text,
1977 self.utility_llm_service.as_ref(),
1978 citation_annotations,
1979 )
1980 .await;
1981 }
1982 }
1983
1984 if tripped.is_none()
1987 && citation_annotations.is_empty()
1988 && !post_output_providers.is_empty()
1989 && !text.is_empty()
1990 {
1991 let ctx = PostGenerationOutputContext {
1992 system_prompt: &runtime_agent.system_prompt,
1993 message_text: &text,
1994 utility_llm_service: self.utility_llm_service.as_ref(),
1995 };
1996 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
1997 }
1998
1999 if tripped.is_some() {
2000 citation_annotations.clear();
2001 }
2002
2003 if buffer_output_deltas
2006 && tripped.is_none()
2007 && !pending_delta.is_empty()
2008 && let Err(e) = self
2009 .event_emitter
2010 .emit(EventRequest::new(
2011 session_id,
2012 streaming_event_context.clone(),
2013 OutputMessageDeltaData {
2014 turn_id: context.turn_id,
2015 message_id: output_message_id,
2016 delta: pending_delta.clone(),
2017 accumulated: text.clone(),
2018 phase: streamed_phase,
2019 },
2020 ))
2021 .await
2022 {
2023 tracing::warn!(
2024 session_id = %session_id,
2025 error = %e,
2026 "ReasonAtom: failed to emit guarded output.message.delta event"
2027 );
2028 }
2029
2030 if let Some(ref t) = tripped {
2036 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2037 trace_id.to_string(),
2038 Uuid::now_v7().to_string(),
2039 Some(reason_span_id.to_string()),
2040 );
2041 if let Err(e) = self
2042 .event_emitter
2043 .emit(EventRequest::new(
2044 session_id,
2045 replaced_event_context,
2046 OutputMessageReplacedData {
2047 turn_id: context.turn_id,
2048 message_id: output_message_id,
2049 guardrail_capability_id: t.capability_id.clone(),
2050 guardrail_id: t.guardrail_id.clone(),
2051 reason_code: t.block.reason_code.clone(),
2052 replacement: t.block.replacement.clone(),
2053 },
2054 ))
2055 .await
2056 {
2057 tracing::warn!(
2058 session_id = %session_id,
2059 error = %e,
2060 "ReasonAtom: failed to emit output.message.replaced event"
2061 );
2062 }
2063 text = t.block.replacement.clone();
2064 tool_calls.clear();
2065 thinking.clear();
2066 }
2067
2068 if !tool_calls.is_empty() {
2072 self.apply_finalized_tool_call_hooks(
2073 session_id,
2074 context,
2075 &resolved_capability_configs,
2076 &runtime_agent.tools,
2077 &mut tool_calls,
2078 iteration,
2079 )
2080 .await;
2081 }
2082
2083 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2084
2085 let response_id = completion_metadata
2087 .as_ref()
2088 .and_then(|meta| meta.response_id.clone());
2089 let finish_reason = completion_metadata
2090 .as_ref()
2091 .and_then(|meta| meta.finish_reason.clone());
2092
2093 let usage = completion_metadata.as_ref().and_then(|meta| {
2101 match (meta.prompt_tokens, meta.completion_tokens) {
2102 (Some(input), Some(output)) => {
2103 let actual_cost_usd = meta.provider_cost_usd;
2104 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2105 &model_with_provider.provider_type,
2106 &runtime_agent.model,
2107 input,
2108 output,
2109 meta.cache_read_tokens.unwrap_or(0),
2110 meta.cache_creation_tokens.unwrap_or(0),
2111 );
2112 Some(
2113 TokenUsage::with_cache(
2114 input,
2115 output,
2116 meta.cache_read_tokens,
2117 meta.cache_creation_tokens,
2118 )
2119 .with_cost(actual_cost_usd, estimated_cost_usd),
2120 )
2121 }
2122 _ => None,
2123 }
2124 });
2125
2126 let event_context = EventContext::from_execution_context(context).with_span(
2128 trace_id.to_string(),
2129 Uuid::now_v7().to_string(),
2130 Some(reason_span_id.to_string()),
2131 );
2132 let tools_summary: Vec<ToolDefinitionSummary> =
2133 runtime_agent.tools.iter().map(|t| t.into()).collect();
2134 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2135 if tool_calls.is_empty() {
2136 "stop".to_string()
2137 } else {
2138 "tool_calls".to_string()
2139 }
2140 })]);
2141 let retry_info = completion_metadata
2143 .as_ref()
2144 .and_then(|meta| meta.retry_metadata.as_ref())
2145 .filter(|rm| rm.had_retries())
2146 .map(|rm| LlmRetryInfo {
2147 attempts: rm.attempts,
2148 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2149 });
2150 let mut generation_data = LlmGenerationData::success_with_retry(
2152 messages_for_event.clone(),
2153 tools_summary,
2154 Some(text.clone()).filter(|s| !s.is_empty()),
2155 tool_calls.clone(),
2156 runtime_agent.model.clone(),
2157 Some(model_with_provider.provider_type.to_string()),
2158 usage.clone(),
2159 Some(llm_duration_ms),
2160 time_to_first_token_ms,
2161 finish_reasons,
2162 response_id.clone(),
2163 retry_info,
2164 );
2165
2166 if let Some(info) = compaction_info {
2173 if let Some(compaction_cost) = info.cost_usd {
2174 match generation_data.metadata.usage.as_mut() {
2175 Some(usage) => {
2176 usage.actual_cost_usd =
2177 Some(usage.actual_cost_usd.unwrap_or(0.0) + compaction_cost);
2178 }
2179 None => {
2184 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2185 input_tokens: 0,
2186 output_tokens: 0,
2187 cache_read_tokens: None,
2188 cache_creation_tokens: None,
2189 actual_cost_usd: Some(compaction_cost),
2190 estimated_cost_usd: None,
2191 effective_cost_usd: None,
2192 });
2193 }
2194 }
2195 }
2196 generation_data = generation_data.with_compaction(info);
2197 }
2198
2199 if let Some(request_options) =
2200 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2201 {
2202 generation_data = generation_data.with_request_options(request_options);
2203 }
2204
2205 if let Err(e) = self
2206 .event_emitter
2207 .emit(EventRequest::new(
2208 session_id,
2209 event_context,
2210 generation_data,
2211 ))
2212 .await
2213 {
2214 tracing::warn!(
2215 session_id = %session_id,
2216 error = %e,
2217 "ReasonAtom: failed to emit llm.generation event"
2218 );
2219 }
2220
2221 let mut metadata = std::collections::HashMap::new();
2223 metadata.insert(
2224 "model".to_string(),
2225 serde_json::Value::String(runtime_agent.model.clone()),
2226 );
2227 if let Some(ref effort) = reasoning_effort {
2228 metadata.insert(
2229 "reasoning_effort".to_string(),
2230 serde_json::Value::String(effort.clone()),
2231 );
2232 }
2233 metadata.insert(
2240 "provider".to_string(),
2241 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2242 );
2243 if let Some(ref rid) = response_id {
2244 metadata.insert(
2245 "response_id".to_string(),
2246 serde_json::Value::String(rid.clone()),
2247 );
2248 }
2249
2250 let text = filter_response_text(
2254 &self.capability_registry,
2255 &resolved_capability_configs,
2256 text,
2257 );
2258 let has_tool_calls = !tool_calls.is_empty();
2259 let mut assistant_message = if has_tool_calls {
2260 Message::assistant_with_tools(&text, tool_calls.clone())
2261 } else {
2262 Message::assistant(&text)
2263 }
2264 .with_id(output_message_id);
2265 if !citation_annotations.is_empty() {
2268 for part in assistant_message.content.iter_mut() {
2269 if let crate::message::ContentPart::Text(t) = part {
2270 t.annotations = std::mem::take(&mut citation_annotations);
2271 break;
2272 }
2273 }
2274 }
2275 assistant_message.phase = completion_metadata
2279 .as_ref()
2280 .and_then(|meta| meta.phase.as_deref())
2281 .and_then(everruns_provider::ExecutionPhase::from_provider_str)
2282 .or_else(|| {
2283 Some(everruns_provider::ExecutionPhase::from_has_tool_calls(
2284 has_tool_calls,
2285 ))
2286 });
2287 assistant_message.metadata = Some(metadata);
2288 if !thinking.is_empty() {
2291 assistant_message.thinking = Some(thinking.clone());
2292 assistant_message.thinking_signature = thinking_signature.clone();
2293 }
2294 let message_event_context = EventContext::from_execution_context(context).with_span(
2297 trace_id.to_string(),
2298 Uuid::now_v7().to_string(),
2299 Some(reason_span_id.to_string()),
2300 );
2301 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2302 if let Some(ref u) = usage {
2303 output_message_data = output_message_data.with_usage(u.clone());
2304 }
2305 self.event_emitter
2306 .emit(EventRequest::new(
2307 session_id,
2308 message_event_context,
2309 output_message_data,
2310 ))
2311 .await?;
2312
2313 tracing::info!(
2314 session_id = %session_id,
2315 turn_id = %context.turn_id,
2316 has_tool_calls = %has_tool_calls,
2317 tool_count = %tool_calls.len(),
2318 "ReasonAtom: LLM call completed"
2319 );
2320
2321 Ok(ReasonResult {
2322 success: true,
2323 text,
2324 tool_calls,
2325 has_tool_calls,
2326 tool_definitions: runtime_agent.tools.clone(),
2327 max_iterations: runtime_agent.max_iterations,
2328 error: None,
2329 user_facing_error: None,
2330 error_disclosure: None,
2331 usage,
2332 output_message_id: Some(output_message_id),
2333 time_to_first_token_ms,
2334 response_id,
2335 finish_reason,
2336 locale: resolved_locale,
2337 network_access: runtime_agent.network_access.clone(),
2338 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2339 })
2340 }
2341
2342 async fn finalize_partial_stream(
2347 &self,
2348 session_id: SessionId,
2349 context: &ExecutionContext,
2350 partial: PartialStreamState,
2351 iteration: u32,
2352 runtime_agent: &crate::RuntimeAgent,
2353 resolved_capability_configs: &[crate::CapabilityRef],
2354 ) -> Result<ReasonResult> {
2355 let event_context = EventContext::from_execution_context(context);
2356 let turn_id = context.turn_id;
2357 let message_id = partial.message_id;
2358
2359 let _ = self
2361 .event_emitter
2362 .emit(EventRequest::new(
2363 session_id,
2364 event_context.clone(),
2365 OutputMessageStartedData {
2366 turn_id,
2367 message_id,
2368 model: None,
2369 iteration: Some(iteration),
2370 phase: None,
2373 },
2374 ))
2375 .await;
2376
2377 let accumulated = filter_response_text(
2380 &self.capability_registry,
2381 resolved_capability_configs,
2382 partial.accumulated,
2383 );
2384 let assistant_message = Message::assistant(&accumulated).with_id(message_id);
2385 let output_message_id = message_id;
2386 self.event_emitter
2387 .emit(EventRequest::new(
2388 session_id,
2389 event_context.clone(),
2390 OutputMessageCompletedData::new(assistant_message),
2391 ))
2392 .await?;
2393
2394 let accumulated_len = accumulated.len();
2396 let _ = self
2397 .event_emitter
2398 .emit(EventRequest::new(
2399 session_id,
2400 event_context.clone(),
2401 ReasonRecoveredData {
2402 turn_id,
2403 mode: RecoveryMode::Finalize,
2404 accumulated_len,
2405 },
2406 ))
2407 .await;
2408
2409 tracing::info!(
2410 session_id = %session_id,
2411 turn_id = %turn_id,
2412 accumulated_len,
2413 "ReasonAtom: finalized partial stream from persisted accumulated text"
2414 );
2415
2416 Ok(ReasonResult {
2417 success: true,
2418 text: accumulated,
2419 tool_calls: vec![],
2420 has_tool_calls: false,
2421 tool_definitions: runtime_agent.tools.clone(),
2422 max_iterations: runtime_agent.max_iterations,
2423 error: None,
2424 user_facing_error: None,
2425 error_disclosure: None,
2426 usage: None,
2427 output_message_id: Some(output_message_id),
2428 time_to_first_token_ms: None,
2429 response_id: None,
2430 finish_reason: Some("stop".to_string()),
2431 locale: None,
2432 network_access: None,
2433 parallel_tool_calls: None,
2435 })
2436 }
2437
2438 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2449 let mut resolved = HashMap::new();
2450
2451 let resolver = match &self.image_resolver {
2453 Some(r) => r,
2454 None => return resolved,
2455 };
2456
2457 let image_ids: Vec<Uuid> = messages
2459 .iter()
2460 .flat_map(crate::llm_conversions::extract_image_file_ids)
2461 .collect::<std::collections::HashSet<_>>()
2462 .into_iter()
2463 .collect();
2464
2465 if image_ids.is_empty() {
2466 return resolved;
2467 }
2468
2469 tracing::debug!(
2470 image_count = image_ids.len(),
2471 "ReasonAtom: resolving image_file references"
2472 );
2473
2474 for image_id in image_ids {
2476 match resolver.resolve_image(image_id).await {
2477 Ok(Some(image)) => {
2478 resolved.insert(image_id, image);
2479 }
2480 Ok(None) => {
2481 tracing::warn!(
2482 image_id = %image_id,
2483 "ReasonAtom: image not found during resolution"
2484 );
2485 }
2486 Err(e) => {
2487 tracing::warn!(
2488 image_id = %image_id,
2489 error = %e,
2490 "ReasonAtom: failed to resolve image"
2491 );
2492 }
2493 }
2494 }
2495
2496 tracing::debug!(
2497 resolved_count = resolved.len(),
2498 "ReasonAtom: image resolution complete"
2499 );
2500
2501 resolved
2502 }
2503}
2504
2505#[cfg(test)]
2510mod tests;