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::{ContentPart, 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};
58use everruns_provider::reasoning::{ReasoningContentPart, ReasoningText};
59
60mod compaction;
61mod error_policy;
62mod observability;
63mod output_hooks;
64mod request_controls;
65mod stream_state;
66mod transcript;
67
68use compaction::{
69 ProactiveCompactionContext, ReactiveCompactionContext, apply_proactive_compaction,
70 apply_reactive_compaction,
71};
72use error_policy::{
73 error_disclosure_override, filter_response_text, is_error_placeholder_message,
74 resolve_error_disclosure,
75};
76use observability::{build_request_options, capability_usage_snapshot_records};
77use output_hooks::collect_output_hooks;
78use request_controls::resolve_request_controls;
79use stream_state::{
80 StreamReplayState, StreamTermination, advances_stall_deadline, append_guarded_thinking_delta,
81 merge_retry_metadata,
82};
83use transcript::repair_dangling_tool_calls;
84
85#[allow(clippy::too_many_arguments)]
92async fn apply_finalized_tool_calls_hooks(
93 capability_registry: &CapabilityRegistry,
94 event_emitter: &dyn EventEmitter,
95 session_id: SessionId,
96 context: &ExecutionContext,
97 resolved_capability_configs: &[crate::CapabilityRef],
98 tool_definitions: &[ToolDefinition],
99 tool_calls: &mut [ToolCall],
100 iteration: u32,
101) {
102 let hook_context = crate::finalized_tool_calls::FinalizedToolCallsContext {
103 event_emitter,
104 session_id,
105 execution_context: context,
106 tool_definitions,
107 iteration,
108 };
109 for config in resolved_capability_configs {
110 let Some(capability) = capability_registry.get(config.capability_id()) else {
111 continue;
112 };
113 if let Some(hook) = capability.finalized_tool_calls_hook(config.config_value()) {
114 hook.apply(&hook_context, tool_calls).await;
115 }
116 }
117}
118
119fn unix_now_secs() -> u64 {
120 std::time::SystemTime::now()
121 .duration_since(std::time::UNIX_EPOCH)
122 .unwrap_or_default()
123 .as_secs()
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct ReasonInput {
129 pub context: ExecutionContext,
131 pub harness_id: HarnessId,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub agent_id: Option<AgentId>,
136 #[serde(default)]
138 pub org_id: i64,
139 #[serde(default)]
143 pub mcp_tool_definitions: Vec<ToolDefinition>,
144 #[serde(skip_serializing_if = "Option::is_none")]
147 pub previous_response_id: Option<String>,
148 #[serde(default = "default_iteration")]
151 pub iteration: u32,
152}
153
154fn default_iteration() -> u32 {
155 1
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160pub struct ReasonResult {
161 pub success: bool,
163 pub text: String,
165 #[serde(default)]
167 pub tool_calls: Vec<ToolCall>,
168 pub has_tool_calls: bool,
170 #[serde(default)]
172 pub tool_definitions: Vec<ToolDefinition>,
173 #[serde(default = "default_max_iterations")]
175 pub max_iterations: usize,
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub error: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub user_facing_error: Option<UserFacingError>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub error_disclosure: Option<ErrorDisclosure>,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub usage: Option<TokenUsage>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub output_message_id: Option<MessageId>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub time_to_first_token_ms: Option<u64>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub response_id: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub finish_reason: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub locale: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub network_access: Option<crate::network_access::NetworkAccessList>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub parallel_tool_calls: Option<bool>,
213}
214
215fn default_max_iterations() -> usize {
216 500
217}
218
219pub struct ReasonAtom {
238 context_resolver: Arc<dyn TurnContextResolver>,
239 message_retriever: Arc<dyn MessageRetriever>,
240 capability_registry: CapabilityRegistry,
241 event_emitter: PhaseEffectEmitter<dyn PhaseEffectSink>,
242 image_resolver: Option<Arc<dyn ImageResolver>>,
244 stream_heartbeater: Option<Arc<dyn crate::durability::StreamHeartbeater>>,
246 provider_stall_timeout: Option<std::time::Duration>,
248 provider_retry_config: LlmRetryConfig,
250 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
252 partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
254 reasoning_effort_handle: Option<crate::tool_context::ReasoningEffortHandle>,
258 utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
262 schedule_store: Option<Arc<dyn crate::session_services::SessionScheduleStore>>,
268 compaction_checkpoint_store: Option<Arc<dyn crate::CompactionCheckpointStore>>,
270}
271
272impl ReasonAtom {
273 pub fn new(
275 context_resolver: impl TurnContextResolver + 'static,
276 message_retriever: impl MessageRetriever + 'static,
277 capability_registry: CapabilityRegistry,
278 event_emitter: impl PhaseEffectSink + 'static,
279 ) -> Self {
280 Self {
281 context_resolver: Arc::new(context_resolver),
282 message_retriever: Arc::new(message_retriever),
283 capability_registry,
284 event_emitter: PhaseEffectEmitter::new(Arc::new(event_emitter)),
285 image_resolver: None,
286 stream_heartbeater: None,
287 provider_stall_timeout: None,
288 provider_retry_config: LlmRetryConfig::default(),
289 durable_tool_result_store: None,
290 partial_stream_store: None,
291 reasoning_effort_handle: None,
292 utility_llm_service: None,
293 schedule_store: None,
294 compaction_checkpoint_store: None,
295 }
296 }
297
298 pub fn with_schedule_store(
301 mut self,
302 store: Arc<dyn crate::session_services::SessionScheduleStore>,
303 ) -> Self {
304 self.schedule_store = Some(store);
305 self
306 }
307
308 pub fn with_compaction_checkpoint_store(
309 mut self,
310 store: Arc<dyn crate::CompactionCheckpointStore>,
311 ) -> Self {
312 self.compaction_checkpoint_store = Some(store);
313 self
314 }
315
316 fn collect_llm_error_hooks(
322 &self,
323 resolved_capability_configs: &[crate::CapabilityRef],
324 ) -> Vec<(
325 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
326 serde_json::Value,
327 )> {
328 resolved_capability_configs
329 .iter()
330 .filter_map(|cfg| {
331 let cap = self.capability_registry.get(cfg.capability_id())?;
332 let hook = cap.llm_error_hook()?;
333 Some((hook, cfg.config_value().clone()))
334 })
335 .collect()
336 }
337
338 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
351 self.image_resolver = Some(resolver);
352 self
353 }
354
355 pub fn with_stream_heartbeater(
357 mut self,
358 heartbeater: Arc<dyn crate::durability::StreamHeartbeater>,
359 ) -> Self {
360 self.stream_heartbeater = Some(heartbeater);
361 self
362 }
363
364 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
367 self.provider_stall_timeout = Some(timeout);
368 self
369 }
370
371 pub fn with_provider_retry_config(mut self, config: LlmRetryConfig) -> Self {
373 self.provider_retry_config = config;
374 self
375 }
376
377 pub fn with_durable_tool_result_store(
383 mut self,
384 store: Arc<dyn DurableToolResultStore>,
385 ) -> Self {
386 self.durable_tool_result_store = Some(store);
387 self
388 }
389
390 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
392 self.partial_stream_store = Some(store);
393 self
394 }
395
396 pub fn with_reasoning_effort_handle(
403 mut self,
404 handle: crate::tool_context::ReasoningEffortHandle,
405 ) -> Self {
406 self.reasoning_effort_handle = Some(handle);
407 self
408 }
409
410 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
413 self.utility_llm_service = Some(service);
414 self
415 }
416}
417
418impl ReasonAtom {
419 pub fn name(&self) -> &'static str {
421 "reason"
422 }
423
424 pub async fn execute(&self, input: ReasonInput) -> Result<ReasonResult> {
426 self.execute_inner(input, None).await
427 }
428}
429
430impl ReasonAtom {
431 pub async fn execute_with_assembled_context(
436 &self,
437 input: ReasonInput,
438 assembled: AssembledTurnContext,
439 ) -> Result<ReasonResult> {
440 self.execute_inner(input, Some(assembled)).await
441 }
442
443 async fn emit_capability_usage_snapshot(
444 &self,
445 session_id: SessionId,
446 context: &ExecutionContext,
447 resolved_capability_configs: &[crate::CapabilityRef],
448 tool_definitions: &[ToolDefinition],
449 ) {
450 let records = capability_usage_snapshot_records(
451 &self.capability_registry,
452 resolved_capability_configs,
453 tool_definitions,
454 );
455 if records.is_empty() {
456 return;
457 }
458
459 if let Err(error) = self
460 .event_emitter
461 .emit(EventRequest::new(
462 session_id,
463 EventContext::from_execution_context(context),
464 CapabilityUsageData { records },
465 ))
466 .await
467 {
468 tracing::warn!(
469 session_id = %session_id,
470 error = %error,
471 "ReasonAtom: failed to emit capability.usage event"
472 );
473 }
474 }
475
476 async fn apply_finalized_tool_call_hooks(
479 &self,
480 session_id: SessionId,
481 context: &ExecutionContext,
482 resolved_capability_configs: &[crate::CapabilityRef],
483 tool_definitions: &[ToolDefinition],
484 tool_calls: &mut [ToolCall],
485 iteration: u32,
486 ) {
487 apply_finalized_tool_calls_hooks(
488 &self.capability_registry,
489 self.event_emitter.as_ref(),
490 session_id,
491 context,
492 resolved_capability_configs,
493 tool_definitions,
494 tool_calls,
495 iteration,
496 )
497 .await;
498 }
499
500 async fn execute_inner(
501 &self,
502 input: ReasonInput,
503 assembled: Option<AssembledTurnContext>,
504 ) -> Result<ReasonResult> {
505 let ReasonInput {
506 context,
507 harness_id,
508 agent_id,
509 org_id,
510 mcp_tool_definitions,
511 previous_response_id,
512 iteration,
513 } = input;
514
515 tracing::info!(
516 session_id = %context.session_id,
517 turn_id = %context.turn_id,
518 exec_id = %context.exec_id,
519 harness_id = %harness_id,
520 agent_id = ?agent_id,
521 mcp_tools_count = %mcp_tool_definitions.len(),
522 "ReasonAtom: starting LLM call"
523 );
524
525 let trace_id = context.turn_id.to_string();
533 let reason_span_id = Uuid::now_v7().to_string();
534 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_execution_context(&context).with_span(
538 trace_id.clone(),
539 reason_span_id.clone(),
540 Some(parent_span_id.clone()),
541 );
542
543 let reason_start = Instant::now();
545
546 if let Err(e) = self
548 .event_emitter
549 .emit(EventRequest::new(
550 context.session_id,
551 event_context.clone(),
552 ReasonStartedData {
553 harness_id,
554 agent_id,
555 metadata: None, },
557 ))
558 .await
559 {
560 tracing::warn!(
561 session_id = %context.session_id,
562 error = %e,
563 "ReasonAtom: failed to emit reason.started event"
564 );
565 }
566
567 let assembled = match assembled {
571 Some(assembled) => Ok(assembled),
572 None => {
573 self.context_resolver
574 .resolve_turn_context(TurnContextRequest {
575 session_id: context.session_id,
576 harness_id,
577 agent_id,
578 mcp_tool_definitions: mcp_tool_definitions.clone(),
579 })
580 .await
581 }
582 };
583
584 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
585 Ok(assembled) => {
586 let error_disclosure = resolve_error_disclosure(
587 &self.capability_registry,
588 &assembled.resolved_capability_configs,
589 error_disclosure_override(&assembled.messages).as_deref(),
590 );
591 let error_hooks =
595 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
596 let error_context = UserFacingErrorContext::default()
597 .with_provider(assembled.model.provider_type.to_string())
598 .with_model_id(assembled.model.model.clone());
599 let call_result = self
600 .execute_llm_call(
601 context.session_id,
602 harness_id,
603 agent_id,
604 org_id,
605 &context,
606 &trace_id,
607 &reason_span_id,
608 previous_response_id,
609 iteration,
610 assembled,
611 )
612 .await;
613 (error_disclosure, error_context, error_hooks, call_result)
614 }
615 Err(error) => (
616 ErrorDisclosure::default(),
617 UserFacingErrorContext::default(),
618 Vec::new(),
619 Err(error),
620 ),
621 };
622
623 let result = match call_result {
625 Ok(result) => {
626 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
628
629 let completed_context = EventContext::from_execution_context(&context).with_span(
631 trace_id.clone(),
632 reason_span_id.clone(), Some(parent_span_id.clone()),
634 );
635 if let Err(e) = self
636 .event_emitter
637 .emit(EventRequest::new(
638 context.session_id,
639 completed_context,
640 ReasonCompletedData::success(
641 &result.text,
642 result.has_tool_calls,
643 result.tool_calls.len() as u32,
644 Some(reason_duration_ms),
645 result.usage.clone(),
646 ),
647 ))
648 .await
649 {
650 tracing::warn!(
651 session_id = %context.session_id,
652 error = %e,
653 "ReasonAtom: failed to emit reason.completed event"
654 );
655 }
656 result
657 }
658 Err(e) => {
659 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
661
662 tracing::warn!(
665 session_id = %context.session_id,
666 turn_id = %context.turn_id,
667 error = %e,
668 "ReasonAtom: LLM call failed"
669 );
670
671 let error_msg = e.to_string();
672 let mut source_error = e.user_facing_error(error_context);
673
674 let is_transient = e.is_transient_llm_error()
681 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
682
683 if !is_transient && !error_hooks.is_empty() {
689 let services = crate::llm_error_hook::LlmErrorHookServices {
690 schedule_store: self.schedule_store.clone(),
691 };
692 for (hook, config) in &error_hooks {
693 let outcome = {
694 let ctx = crate::llm_error_hook::LlmErrorContext {
695 session_id: context.session_id,
696 error_code: &source_error.code,
697 error_fields: &source_error.fields,
698 config,
699 services: &services,
700 };
701 hook.on_llm_error(&ctx).await
702 };
703 for (key, value) in outcome.extra_error_fields {
704 source_error = source_error.with_field(key, value);
705 }
706 }
707 }
708
709 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
710 let user_error_text = user_error.fallback_message();
711
712 let mut output_message_id = None;
713
714 if !is_transient {
715 let mut error_message = Message::assistant(&user_error_text);
717 let mut metadata = std::collections::HashMap::new();
718 user_error.apply_to_message_metadata(&mut metadata);
719 UserFacingError::apply_disclosure_to_message_metadata(
720 &mut metadata,
721 error_disclosure,
722 &source_error.code,
723 );
724 error_message.metadata = Some(metadata);
725
726 output_message_id = Some(error_message.id);
727
728 let error_msg_context = EventContext::from_execution_context(&context)
731 .with_span(
732 trace_id.clone(),
733 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
736 if let Err(emit_err) = self
737 .event_emitter
738 .emit(EventRequest::new(
739 context.session_id,
740 error_msg_context,
741 OutputMessageCompletedData::new(error_message)
742 .with_user_facing_error(&user_error)
743 .with_error_disclosure(error_disclosure),
744 ))
745 .await
746 {
747 tracing::warn!(
748 session_id = %context.session_id,
749 error = %emit_err,
750 "ReasonAtom: failed to emit output.message.completed event for error"
751 );
752 }
753 } else {
754 tracing::info!(
755 session_id = %context.session_id,
756 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
757 );
758 }
759
760 let completed_context = EventContext::from_execution_context(&context).with_span(
762 trace_id.clone(),
763 reason_span_id.clone(), Some(parent_span_id.clone()),
765 );
766 if let Err(emit_err) = self
767 .event_emitter
768 .emit(EventRequest::new(
769 context.session_id,
770 completed_context,
771 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
772 ))
773 .await
774 {
775 tracing::warn!(
776 session_id = %context.session_id,
777 error = %emit_err,
778 "ReasonAtom: failed to emit reason.completed event"
779 );
780 }
781
782 ReasonResult {
783 success: false,
784 text: user_error_text,
785 tool_calls: vec![],
786 has_tool_calls: false,
787 tool_definitions: vec![],
788 max_iterations: default_max_iterations(),
789 error: Some(error_msg.clone()),
790 user_facing_error: Some(user_error),
791 error_disclosure: Some(error_disclosure),
792 usage: None,
793 output_message_id,
794 time_to_first_token_ms: None,
795 response_id: None,
796 finish_reason: error_msg
797 .to_ascii_lowercase()
798 .contains("model refused")
799 .then(|| "refusal".to_string()),
800 locale: None,
801 network_access: None,
802 parallel_tool_calls: None,
803 }
804 }
805 };
806
807 Ok(result)
808 }
809
810 #[allow(clippy::too_many_arguments)]
812 async fn execute_llm_call(
813 &self,
814 session_id: SessionId,
815 harness_id: HarnessId,
816 agent_id: Option<AgentId>,
817 org_id: i64,
818 context: &ExecutionContext,
819 trace_id: &str,
820 reason_span_id: &str,
821 previous_response_id: Option<String>,
822 iteration: u32,
823 assembled: AssembledTurnContext,
824 ) -> Result<ReasonResult> {
825 let prior_usage = assembled.cumulative_usage();
826 let mut messages = assembled.messages;
827 let mut message_source_sequence = assembled.message_source_sequence;
828 let model_with_provider = assembled.model;
829 let resolved_model_id = assembled.resolved_model_id;
830 let resolved_locale = assembled.resolved_locale;
831 let compaction_policy = assembled.compaction_policy;
832 let resolved_capability_configs = assembled.resolved_capability_configs;
833 let runtime_agent = assembled.runtime_agent;
834 let embedder_metadata = assembled.embedder_metadata;
835
836 self.emit_capability_usage_snapshot(
837 session_id,
838 context,
839 &resolved_capability_configs,
840 &runtime_agent.tools,
841 )
842 .await;
843
844 let output_hooks =
845 collect_output_hooks(&self.capability_registry, &resolved_capability_configs);
846 let guardrail_providers = output_hooks.streaming;
847 let post_output_providers = output_hooks.post_generation;
848 let annotation_providers = output_hooks.annotations;
849 let citation_verifiers = output_hooks.citation_verifiers;
850
851 let chat_driver = Arc::clone(&model_with_provider.driver);
853 let stateful_response_continuation =
854 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
855 let mut restored_checkpoint: Option<crate::CompactionCheckpoint> = None;
856 let mut checkpoint_suffix_message_count = 0usize;
857
858 if compaction_policy.is_some()
859 && let Some(store) = self.compaction_checkpoint_store.as_ref()
860 && let Some(checkpoint) = store
861 .get_latest(
862 session_id,
863 model_with_provider.provider_type.as_str(),
864 &model_with_provider.model,
865 )
866 .await?
867 && checkpoint.is_compatible(
868 model_with_provider.provider_type.as_str(),
869 &model_with_provider.model,
870 )
871 {
872 let filters = crate::capabilities::collect_message_filters_only(
873 &resolved_capability_configs,
874 &self.capability_registry,
875 );
876 let mut query =
877 crate::MessageQuery::new(session_id).after_sequence(checkpoint.source_sequence);
878 filters.apply_message_filters(&mut query);
879 let history = self.message_retriever.load_filtered_history(query).await?;
880 messages = history.messages;
881 checkpoint_suffix_message_count = messages.len();
882 filters.apply_post_load_filters(&mut messages);
883 if let crate::CompactionCheckpointPayload::Summary { text } = &checkpoint.payload {
884 messages.insert(
885 0,
886 Message::system(format!(
887 "[CONVERSATION_SUMMARY]\n{text}\n[/CONVERSATION_SUMMARY]"
888 )),
889 );
890 }
891 message_source_sequence = history.source_sequence.or(message_source_sequence);
892 restored_checkpoint = Some(checkpoint);
893 }
894
895 let controls = resolve_request_controls(
896 &messages,
897 self.reasoning_effort_handle.as_ref(),
898 &model_with_provider.provider_type,
899 &model_with_provider.model,
900 );
901 let reasoning_effort = controls.reasoning_effort;
902 let speed = controls.speed;
903 let verbosity = controls.verbosity;
904
905 if let Some(ref store) = self.partial_stream_store {
909 let turn_id_str = context.turn_id.to_string();
910 match store.get_partial_stream(session_id, &turn_id_str).await {
911 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
912 return self
914 .finalize_partial_stream(
915 session_id,
916 context,
917 partial,
918 iteration,
919 &runtime_agent,
920 &resolved_capability_configs,
921 )
922 .await;
923 }
924 Ok(Some(_)) => {
925 let recovery_ctx = EventContext::from_execution_context(context);
928 let _ = self
929 .event_emitter
930 .emit(EventRequest::new(
931 session_id,
932 recovery_ctx,
933 ReasonRecoveredData {
934 turn_id: context.turn_id,
935 mode: RecoveryMode::Restart,
936 accumulated_len: 0,
937 },
938 ))
939 .await;
940 tracing::info!(
941 session_id = %session_id,
942 turn_id = %context.turn_id,
943 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
944 );
945 }
946 Ok(None) => {} Err(e) => {
948 tracing::warn!(
950 session_id = %session_id,
951 turn_id = %context.turn_id,
952 error = %e,
953 "ReasonAtom: partial-stream store error; proceeding with normal execution"
954 );
955 }
956 }
957 }
958
959 let repair_event_context = EventContext::from_execution_context(context);
963 let patched_messages = repair_dangling_tool_calls(
964 &messages,
965 self.durable_tool_result_store.as_deref(),
966 self.event_emitter.as_ref(),
967 session_id,
968 &repair_event_context,
969 &context.turn_id.to_string(),
970 )
971 .await;
972 let raw_tool_result_bytes = compaction_policy
973 .as_ref()
974 .map(|policy| policy.total_tool_result_bytes(&patched_messages))
975 .unwrap_or(0);
976
977 let model_view_providers = crate::capabilities::collect_model_view_providers(
980 &resolved_capability_configs,
981 &self.capability_registry,
982 Some(model_with_provider.model.as_str()),
983 );
984 let model_view_context = crate::capabilities::ModelViewContext {
985 session_id,
986 prior_usage: prior_usage.as_ref(),
987 };
988 let mut context_messages =
989 model_view_providers.apply_model_view(patched_messages, &model_view_context);
990 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
991 &context_messages,
992 stateful_response_continuation || restored_checkpoint.is_some(),
993 );
994
995 let mut volatile_suffix_len = 0usize;
1002 {
1003 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1004 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1005 &resolved_capability_configs,
1006 &self.capability_registry,
1007 Some(model_with_provider.model.as_str()),
1008 &facts_ctx,
1009 );
1010 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1011 context_messages.push(Message::user(block));
1012 volatile_suffix_len = 1;
1013 }
1014 }
1015
1016 let resolved_images = self.resolve_images(&context_messages).await;
1021
1022 let mut llm_messages = Vec::new();
1024
1025 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1027 if has_system_prompt {
1028 llm_messages.push(LlmMessage {
1029 role: LlmMessageRole::System,
1030 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1031 tool_calls: None,
1032 tool_call_id: None,
1033 phase: None,
1034 reasoning: Vec::new(),
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 {
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 reasoning,
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_model_name = llm_config.model.clone();
1301 let stream_result = if let Some(remaining) =
1302 remaining_retry_time(&retry_config, retry_started_at)
1303 {
1304 match tokio::time::timeout(
1305 remaining,
1306 chat_driver.chat_completion_stream(
1307 &crate::ProviderEndpoint::default(),
1308 llm_messages_for_call.clone(),
1309 &llm_config,
1310 ),
1311 )
1312 .await
1313 {
1314 Ok(result) => result,
1315 Err(_) => {
1316 return Err(AgentLoopError::llm_kind(
1317 crate::error::LlmErrorKind::Unavailable,
1318 format!(
1319 "provider retry time budget exhausted after {} retries over {:.1}s; the turn is safe to resume",
1320 stream_retry_metadata.attempts,
1321 retry_config.max_retry_elapsed.as_secs_f64()
1322 ),
1323 )
1324 .with_retry_metadata(&stream_retry_metadata));
1325 }
1326 }
1327 } else {
1328 chat_driver
1329 .chat_completion_stream(
1330 &crate::ProviderEndpoint::default(),
1331 llm_messages_for_call.clone(),
1332 &llm_config,
1333 )
1334 .await
1335 };
1336 let mut stream = match stream_result {
1337 Ok(stream) => stream,
1338 Err(e) if e.is_request_too_large() => {
1339 let Some(policy) = compaction_policy.as_deref() else {
1340 tracing::warn!(
1341 session_id = %session_id,
1342 turn_id = %context.turn_id,
1343 "ReasonAtom: context too large and compaction capability is not enabled"
1344 );
1345 return Err(e);
1346 };
1347 let outcome = apply_reactive_compaction(
1348 ReactiveCompactionContext {
1349 chat_driver: chat_driver.as_ref(),
1350 policy,
1351 checkpoint_store: self.compaction_checkpoint_store.as_ref(),
1352 event_emitter: self.event_emitter.as_ref(),
1353 event_context: &streaming_event_context,
1354 session_id,
1355 message_source_sequence,
1356 provider_type: model_with_provider.provider_type.as_str(),
1357 model: &model_with_provider.model,
1358 summarization_model_fallback: &runtime_agent.model,
1359 system_prompt: has_system_prompt
1360 .then_some(runtime_agent.system_prompt.as_str()),
1361 stateful_response_continuation,
1362 },
1363 &mut llm_messages_for_call,
1364 &mut llm_config,
1365 )
1366 .await?;
1367 let Some(outcome) = outcome else {
1368 return Err(e);
1369 };
1370 if outcome.generation_info.is_some() {
1371 compaction_info = outcome.generation_info;
1372 }
1373
1374 chat_driver
1375 .chat_completion_stream(
1376 &crate::ProviderEndpoint::default(),
1377 llm_messages_for_call.clone(),
1378 &llm_config,
1379 )
1380 .await?
1381 }
1382 Err(e)
1383 if e.is_transient_llm_error()
1384 && !e.llm_retry_handled()
1385 && stream_retry_metadata.attempts < retry_config.max_retries =>
1386 {
1387 let proposed_wait =
1388 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1389 let Some(wait_duration) =
1390 reserve_retry_wait(&retry_config, &mut retry_started_at, proposed_wait)
1391 else {
1392 return Err(AgentLoopError::llm_kind(
1393 e.llm_error_kind()
1394 .unwrap_or(crate::error::LlmErrorKind::Unavailable),
1395 format!(
1396 "{e}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1397 stream_retry_metadata.attempts
1398 ),
1399 )
1400 .with_retry_metadata(&stream_retry_metadata));
1401 };
1402 tracing::warn!(
1403 session_id = %session_id,
1404 turn_id = %context.turn_id,
1405 attempt = stream_retry_metadata.attempts + 1,
1406 max_retries = retry_config.max_retries,
1407 wait_secs = wait_duration.as_secs_f64(),
1408 error = %e,
1409 "ReasonAtom: transient provider failure before stream, retrying"
1410 );
1411 stream_retry_metadata.record_retry(wait_duration, None);
1412 tokio::time::sleep(wait_duration).await;
1413 continue 'stream_attempt;
1414 }
1415 Err(e) => return Err(e),
1416 };
1417
1418 let mut text = String::new();
1419 let mut reasoning: Vec<ReasoningContentPart> = Vec::new();
1423 let mut thinking = String::new();
1425 let mut tool_calls = Vec::new();
1426 let mut termination = StreamTermination::Exhausted;
1427 let mut replay_state = StreamReplayState::default();
1428 let mut pending_delta = String::new();
1429 let mut pending_thinking_delta = String::new();
1430 let mut last_delta_emit = Instant::now();
1431 let mut last_thinking_delta_emit = Instant::now();
1432 let mut time_to_first_token_ms: Option<u64> = None;
1433
1434 let stall_timeout = self
1436 .provider_stall_timeout
1437 .unwrap_or(std::time::Duration::from_secs(120));
1438 let initial_stall_timeout = remaining_retry_time(&retry_config, retry_started_at)
1439 .map_or(stall_timeout, |remaining| remaining.min(stall_timeout));
1440 let mut stall_sleep = Box::pin(tokio::time::sleep(initial_stall_timeout));
1441 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
1442 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1443 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
1445 let mut last_token_at_unix: u64 = unix_now_secs();
1450
1451 loop {
1452 let event = tokio::select! {
1453 biased;
1454 next = stream.next() => match next {
1455 Some(e) => e,
1456 None => break,
1457 },
1458 _ = &mut stall_sleep => {
1459 let stall_error =
1469 crate::driver_registry::LlmStreamError::new(format!(
1470 "provider stream stall: no tokens for {}s",
1471 stall_timeout.as_secs()
1472 ));
1473 tracing::warn!(
1474 session_id = %session_id,
1475 turn_id = %context.turn_id,
1476 stall_secs = stall_timeout.as_secs(),
1477 "ReasonAtom: provider stream stall timeout"
1478 );
1479 if replay_state.should_retry(
1480 &stall_error,
1481 stream_retry_metadata.attempts,
1482 retry_config.max_retries,
1483 ) {
1484 let proposed_wait = retry_config
1485 .calculate_backoff(stream_retry_metadata.attempts);
1486 let Some(wait_duration) = reserve_retry_wait(
1487 &retry_config,
1488 &mut retry_started_at,
1489 proposed_wait,
1490 ) else {
1491 return Err(AgentLoopError::llm_kind(
1492 crate::error::LlmErrorKind::Unavailable,
1493 format!(
1494 "{}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1495 stall_error.message,
1496 stream_retry_metadata.attempts
1497 ),
1498 )
1499 .with_retry_metadata(&stream_retry_metadata));
1500 };
1501 tracing::warn!(
1502 session_id = %session_id,
1503 turn_id = %context.turn_id,
1504 attempt = stream_retry_metadata.attempts + 1,
1505 max_retries = retry_config.max_retries,
1506 wait_secs = wait_duration.as_secs_f64(),
1507 "ReasonAtom: provider stream stall, retrying"
1508 );
1509 stream_retry_metadata.record_retry(wait_duration, None);
1510 tokio::time::sleep(wait_duration).await;
1511 continue 'stream_attempt;
1512 }
1513 return Err(AgentLoopError::llm(stall_error.message));
1514 },
1515 _ = keepalive_ticker.tick() => {
1516 if let Some(ref hb) = self.stream_heartbeater {
1517 hb.heartbeat(crate::durability::StreamProgress {
1518 accumulated_len: text.len() + thinking.len(),
1519 last_delta_at: last_token_at_unix,
1520 })
1521 .await;
1522 last_stream_heartbeat = Instant::now();
1523 }
1524 continue;
1525 },
1526 };
1527 let event = event?;
1528 replay_state.observe(&event);
1529 let advanced_stall_deadline = advances_stall_deadline(&event);
1530 if advanced_stall_deadline {
1531 stall_sleep
1532 .as_mut()
1533 .reset(tokio::time::Instant::now() + stall_timeout);
1534 last_token_at_unix = unix_now_secs();
1535 }
1536 match event {
1537 LlmStreamEvent::TextDelta(delta) => {
1538 if delta.is_empty() {
1539 continue;
1540 }
1541 if time_to_first_token_ms.is_none() {
1543 let ttft = llm_start.elapsed().as_millis() as u64;
1544 time_to_first_token_ms = Some(ttft);
1545 tracing::info!(
1546 session_id = %session_id,
1547 time_to_first_token_ms = ttft,
1548 "ReasonAtom: received first token from LLM"
1549 );
1550 }
1551 text.push_str(&delta);
1552 pending_delta.push_str(&delta);
1553
1554 if !armed_guardrails.is_empty()
1561 && let Some(t) =
1562 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
1563 {
1564 tracing::warn!(
1565 session_id = %session_id,
1566 turn_id = %context.turn_id,
1567 guardrail_capability_id = %t.capability_id,
1568 guardrail_id = %t.guardrail_id,
1569 reason_code = %t.block.reason_code,
1570 "ReasonAtom: output guardrail tripped, replacing assistant message"
1571 );
1572 pending_delta.clear();
1573 termination = StreamTermination::GuardrailBlocked(t);
1574 break;
1575 }
1576
1577 if !buffer_output_deltas
1579 && last_delta_emit.elapsed().as_millis() as u64
1580 >= DELTA_BATCH_INTERVAL_MS
1581 && !pending_delta.is_empty()
1582 {
1583 if let Err(e) = self
1584 .event_emitter
1585 .emit(EventRequest::new(
1586 session_id,
1587 streaming_event_context.clone(),
1588 OutputMessageDeltaData {
1589 turn_id: context.turn_id,
1590 message_id: output_message_id,
1591 delta: pending_delta.clone(),
1592 accumulated: text.clone(),
1593 phase: streamed_phase,
1594 },
1595 ))
1596 .await
1597 {
1598 tracing::warn!(
1599 session_id = %session_id,
1600 error = %e,
1601 "ReasonAtom: failed to emit output.message.delta event"
1602 );
1603 }
1604 pending_delta.clear();
1605 last_delta_emit = Instant::now();
1606 }
1607 }
1608 LlmStreamEvent::ReasoningDelta { delta, summary: _ } => {
1609 if delta.is_empty() {
1610 continue;
1611 }
1612 if let Some(t) = append_guarded_thinking_delta(
1613 &mut armed_guardrails,
1614 &mut thinking,
1615 &mut pending_thinking_delta,
1616 &delta,
1617 ) {
1618 tracing::warn!(
1619 session_id = %session_id,
1620 guardrail_capability_id = %t.capability_id,
1621 guardrail_id = %t.guardrail_id,
1622 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
1623 );
1624 termination = StreamTermination::GuardrailBlocked(t);
1625 break;
1626 }
1627 tracing::debug!(
1628 session_id = %session_id,
1629 delta_len = delta.len(),
1630 total_thinking_len = thinking.len(),
1631 "ReasonAtom: received ThinkingDelta from LLM"
1632 );
1633
1634 if last_thinking_delta_emit.elapsed().as_millis() as u64
1636 >= DELTA_BATCH_INTERVAL_MS
1637 && !pending_thinking_delta.is_empty()
1638 {
1639 if let Err(e) = self
1640 .event_emitter
1641 .emit(EventRequest::new(
1642 session_id,
1643 streaming_event_context.clone(),
1644 ReasonThinkingDeltaData {
1645 turn_id: context.turn_id,
1646 delta: pending_thinking_delta.clone(),
1647 accumulated: thinking.clone(),
1648 },
1649 ))
1650 .await
1651 {
1652 tracing::warn!(
1653 session_id = %session_id,
1654 error = %e,
1655 "ReasonAtom: failed to emit reason.thinking.delta event"
1656 );
1657 }
1658 pending_thinking_delta.clear();
1659 last_thinking_delta_emit = Instant::now();
1660 }
1661 }
1662 LlmStreamEvent::ReasoningItem(item) => {
1663 tracing::debug!(
1667 session_id = %session_id,
1668 provider = %item.provider,
1669 item_id = ?item.item_id,
1670 has_signature = item.signature.is_some(),
1671 has_encrypted = item.encrypted.is_some(),
1672 "ReasonAtom: captured reasoning artifact"
1673 );
1674 if let Err(e) = self
1675 .event_emitter
1676 .emit(EventRequest::new(
1677 session_id,
1678 streaming_event_context.clone(),
1679 ReasonItemData {
1680 turn_id: context.turn_id,
1681 provider: item.provider.clone(),
1682 model: Some(stream_model_name.clone()),
1683 item_id: item.item_id.clone().unwrap_or_default(),
1687 summary: item
1688 .display_text()
1689 .filter(|_| {
1690 !matches!(item.text, Some(ReasoningText::Plain { .. }))
1691 })
1692 .into_iter()
1693 .collect(),
1694 token_count: item.tokens,
1695 },
1696 ))
1697 .await
1698 {
1699 tracing::warn!(
1700 session_id = %session_id,
1701 error = %e,
1702 "ReasonAtom: failed to emit reason.item event"
1703 );
1704 }
1705 reasoning.push(item);
1706 }
1707 LlmStreamEvent::ToolCalls(calls) => {
1708 tool_calls = calls;
1709 }
1710 LlmStreamEvent::MessagePhase(phase) => {
1711 streamed_phase = everruns_provider::ExecutionPhase::refine_streamed_hint(
1720 streamed_phase,
1721 phase,
1722 );
1723 }
1724 LlmStreamEvent::Done(metadata) => {
1725 if !buffer_output_deltas
1729 && !pending_delta.is_empty()
1730 && let Err(e) = self
1731 .event_emitter
1732 .emit(EventRequest::new(
1733 session_id,
1734 streaming_event_context.clone(),
1735 OutputMessageDeltaData {
1736 turn_id: context.turn_id,
1737 message_id: output_message_id,
1738 delta: pending_delta.clone(),
1739 accumulated: text.clone(),
1740 phase: streamed_phase,
1741 },
1742 ))
1743 .await
1744 {
1745 tracing::warn!(
1746 session_id = %session_id,
1747 error = %e,
1748 "ReasonAtom: failed to emit final output.message.delta event"
1749 );
1750 }
1751
1752 if !pending_thinking_delta.is_empty()
1754 && let Err(e) = self
1755 .event_emitter
1756 .emit(EventRequest::new(
1757 session_id,
1758 streaming_event_context.clone(),
1759 ReasonThinkingDeltaData {
1760 turn_id: context.turn_id,
1761 delta: pending_thinking_delta.clone(),
1762 accumulated: thinking.clone(),
1763 },
1764 ))
1765 .await
1766 {
1767 tracing::warn!(
1768 session_id = %session_id,
1769 error = %e,
1770 "ReasonAtom: failed to emit final reason.thinking.delta event"
1771 );
1772 }
1773
1774 if !thinking.is_empty()
1776 && let Err(e) = self
1777 .event_emitter
1778 .emit(EventRequest::new(
1779 session_id,
1780 streaming_event_context.clone(),
1781 ReasonThinkingCompletedData {
1782 turn_id: context.turn_id,
1783 thinking: thinking.clone(),
1784 },
1785 ))
1786 .await
1787 {
1788 tracing::warn!(
1789 session_id = %session_id,
1790 error = %e,
1791 "ReasonAtom: failed to emit reason.thinking.completed event"
1792 );
1793 }
1794 termination = StreamTermination::Completed(metadata);
1795 break;
1796 }
1797 LlmStreamEvent::Error(err) => {
1798 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
1803
1804 if has_partial_output {
1805 tracing::warn!(
1806 session_id = %session_id,
1807 error = %err,
1808 tool_call_count = tool_calls.len(),
1809 text_len = text.len(),
1810 "ReasonAtom: trailing stream error after valid output — treating as partial success"
1811 );
1812 termination = StreamTermination::PartialSuccess;
1816 break;
1817 }
1818
1819 if replay_state.should_retry(
1820 &err,
1821 stream_retry_metadata.attempts,
1822 retry_config.max_retries,
1823 ) {
1824 let proposed_wait =
1825 retry_config.calculate_backoff(stream_retry_metadata.attempts);
1826 let Some(wait_duration) = reserve_retry_wait(
1827 &retry_config,
1828 &mut retry_started_at,
1829 proposed_wait,
1830 ) else {
1831 return Err(AgentLoopError::llm_kind(
1832 err.kind(),
1833 format!(
1834 "{err}; automatic recovery time budget exhausted after {} retries; the turn is safe to resume",
1835 stream_retry_metadata.attempts
1836 ),
1837 )
1838 .with_retry_metadata(&stream_retry_metadata));
1839 };
1840 tracing::warn!(
1841 session_id = %session_id,
1842 turn_id = %context.turn_id,
1843 attempt = stream_retry_metadata.attempts + 1,
1844 max_retries = retry_config.max_retries,
1845 wait_secs = wait_duration.as_secs_f64(),
1846 error_code = err.code.as_deref().unwrap_or("none"),
1847 error_status = err.status,
1848 error = %err,
1849 "ReasonAtom: transient stream error before output, retrying"
1850 );
1851 stream_retry_metadata.record_retry(wait_duration, None);
1852 tokio::time::sleep(wait_duration).await;
1853 continue 'stream_attempt;
1854 }
1855
1856 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
1858 let event_context = EventContext::from_execution_context(context)
1859 .with_span(
1860 trace_id.to_string(),
1861 Uuid::now_v7().to_string(),
1862 Some(reason_span_id.to_string()),
1863 );
1864 let tools_summary: Vec<ToolDefinitionSummary> =
1865 runtime_agent.tools.iter().map(|t| t.into()).collect();
1866 let generation_data = LlmGenerationData::failure(
1867 messages_for_event.clone(),
1868 tools_summary,
1869 runtime_agent.model.clone(),
1870 Some(model_with_provider.provider_type.to_string()),
1871 err.to_string(),
1872 Some(llm_duration_ms),
1873 time_to_first_token_ms,
1874 );
1875 let _ = self
1876 .event_emitter
1877 .emit(EventRequest::new(
1878 session_id,
1879 event_context,
1880 generation_data,
1881 ))
1882 .await;
1883 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
1884 }
1885 }
1886 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
1889 && let Some(ref hb) = self.stream_heartbeater
1890 {
1891 hb.heartbeat(crate::durability::StreamProgress {
1892 accumulated_len: text.len() + thinking.len(),
1893 last_delta_at: last_token_at_unix,
1894 })
1895 .await;
1896 last_stream_heartbeat = Instant::now();
1897 }
1898 }
1899 let (mut completion_metadata, tripped) = termination.into_parts();
1900 if let Some(metadata) = completion_metadata.as_mut() {
1901 metadata.retry_metadata =
1902 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
1903 }
1904
1905 break 'stream_attempt (
1906 text,
1907 thinking,
1908 reasoning,
1909 tool_calls,
1910 completion_metadata,
1911 time_to_first_token_ms,
1912 pending_delta,
1913 tripped,
1914 );
1915 };
1916 let (mut text, mut thinking, mut reasoning, mut tool_calls) =
1917 (text, thinking, reasoning, tool_calls);
1918
1919 let mut citation_annotations: Vec<crate::message::TextAnnotation> = Vec::new();
1930 if tripped.is_none()
1931 && !annotation_providers.is_empty()
1932 && !text.is_empty()
1933 && tool_calls.is_empty()
1934 {
1935 text = filter_response_text(
1938 &self.capability_registry,
1939 &resolved_capability_configs,
1940 text,
1941 );
1942 let collected = collect_annotations(
1943 &annotation_providers,
1944 &runtime_agent.system_prompt,
1945 &text,
1946 &messages,
1947 self.utility_llm_service.as_ref(),
1948 )
1949 .await;
1950 text = collected.text;
1951 citation_annotations = collected.annotations;
1952
1953 if !citation_annotations.is_empty() && !post_output_providers.is_empty() {
1956 let guarded_output = post_generation_guardrail_text(&text, &citation_annotations);
1957 let ctx = PostGenerationOutputContext {
1958 system_prompt: &runtime_agent.system_prompt,
1959 message_text: &guarded_output,
1960 utility_llm_service: self.utility_llm_service.as_ref(),
1961 };
1962 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
1963 }
1964
1965 if tripped.is_none()
1968 && !citation_annotations.is_empty()
1969 && !citation_verifiers.is_empty()
1970 {
1971 citation_annotations = verify_annotations(
1972 &citation_verifiers,
1973 &text,
1974 self.utility_llm_service.as_ref(),
1975 citation_annotations,
1976 )
1977 .await;
1978 }
1979 }
1980
1981 if tripped.is_none()
1984 && citation_annotations.is_empty()
1985 && !post_output_providers.is_empty()
1986 && !text.is_empty()
1987 {
1988 let ctx = PostGenerationOutputContext {
1989 system_prompt: &runtime_agent.system_prompt,
1990 message_text: &text,
1991 utility_llm_service: self.utility_llm_service.as_ref(),
1992 };
1993 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
1994 }
1995
1996 if tripped.is_some() {
1997 citation_annotations.clear();
1998 }
1999
2000 if buffer_output_deltas
2003 && tripped.is_none()
2004 && !pending_delta.is_empty()
2005 && let Err(e) = self
2006 .event_emitter
2007 .emit(EventRequest::new(
2008 session_id,
2009 streaming_event_context.clone(),
2010 OutputMessageDeltaData {
2011 turn_id: context.turn_id,
2012 message_id: output_message_id,
2013 delta: pending_delta.clone(),
2014 accumulated: text.clone(),
2015 phase: streamed_phase,
2016 },
2017 ))
2018 .await
2019 {
2020 tracing::warn!(
2021 session_id = %session_id,
2022 error = %e,
2023 "ReasonAtom: failed to emit guarded output.message.delta event"
2024 );
2025 }
2026
2027 if let Some(ref t) = tripped {
2033 let replaced_event_context = EventContext::from_execution_context(context).with_span(
2034 trace_id.to_string(),
2035 Uuid::now_v7().to_string(),
2036 Some(reason_span_id.to_string()),
2037 );
2038 if let Err(e) = self
2039 .event_emitter
2040 .emit(EventRequest::new(
2041 session_id,
2042 replaced_event_context,
2043 OutputMessageReplacedData {
2044 turn_id: context.turn_id,
2045 message_id: output_message_id,
2046 guardrail_capability_id: t.capability_id.clone(),
2047 guardrail_id: t.guardrail_id.clone(),
2048 reason_code: t.block.reason_code.clone(),
2049 replacement: t.block.replacement.clone(),
2050 },
2051 ))
2052 .await
2053 {
2054 tracing::warn!(
2055 session_id = %session_id,
2056 error = %e,
2057 "ReasonAtom: failed to emit output.message.replaced event"
2058 );
2059 }
2060 text = t.block.replacement.clone();
2061 tool_calls.clear();
2062 thinking.clear();
2063 }
2064
2065 if !tool_calls.is_empty() {
2069 self.apply_finalized_tool_call_hooks(
2070 session_id,
2071 context,
2072 &resolved_capability_configs,
2073 &runtime_agent.tools,
2074 &mut tool_calls,
2075 iteration,
2076 )
2077 .await;
2078 }
2079
2080 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2081
2082 let response_id = completion_metadata
2084 .as_ref()
2085 .and_then(|meta| meta.response_id.clone());
2086 let finish_reason = completion_metadata
2087 .as_ref()
2088 .and_then(|meta| meta.finish_reason.clone());
2089
2090 let usage = completion_metadata.as_ref().and_then(|meta| {
2098 match (meta.prompt_tokens, meta.completion_tokens) {
2099 (Some(input), Some(output)) => {
2100 let actual_cost_usd = meta.provider_cost_usd;
2101 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2102 &model_with_provider.provider_type,
2103 &runtime_agent.model,
2104 input,
2105 output,
2106 meta.cache_read_tokens.unwrap_or(0),
2107 meta.cache_creation_tokens.unwrap_or(0),
2108 );
2109 Some(
2110 TokenUsage::with_cache(
2111 input,
2112 output,
2113 meta.cache_read_tokens,
2114 meta.cache_creation_tokens,
2115 )
2116 .with_cost(actual_cost_usd, estimated_cost_usd),
2117 )
2118 }
2119 _ => None,
2120 }
2121 });
2122
2123 let event_context = EventContext::from_execution_context(context).with_span(
2125 trace_id.to_string(),
2126 Uuid::now_v7().to_string(),
2127 Some(reason_span_id.to_string()),
2128 );
2129 let tools_summary: Vec<ToolDefinitionSummary> =
2130 runtime_agent.tools.iter().map(|t| t.into()).collect();
2131 let finish_reasons = Some(vec![finish_reason.clone().unwrap_or_else(|| {
2132 if tool_calls.is_empty() {
2133 "stop".to_string()
2134 } else {
2135 "tool_calls".to_string()
2136 }
2137 })]);
2138 let retry_info = completion_metadata
2140 .as_ref()
2141 .and_then(|meta| meta.retry_metadata.as_ref())
2142 .filter(|rm| rm.had_retries())
2143 .map(|rm| LlmRetryInfo {
2144 attempts: rm.attempts,
2145 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2146 });
2147 let mut generation_data = LlmGenerationData::success_with_retry(
2149 messages_for_event.clone(),
2150 tools_summary,
2151 Some(text.clone()).filter(|s| !s.is_empty()),
2152 tool_calls.clone(),
2153 runtime_agent.model.clone(),
2154 Some(model_with_provider.provider_type.to_string()),
2155 usage.clone(),
2156 Some(llm_duration_ms),
2157 time_to_first_token_ms,
2158 finish_reasons,
2159 response_id.clone(),
2160 retry_info,
2161 );
2162
2163 if let Some(info) = compaction_info {
2170 if let Some(compaction_cost) = info.cost_usd {
2171 match generation_data.metadata.usage.as_mut() {
2172 Some(usage) => {
2173 usage.actual_cost_usd =
2174 Some(usage.actual_cost_usd.unwrap_or(0.0) + compaction_cost);
2175 }
2176 None => {
2181 generation_data.metadata.usage = Some(crate::events::TokenUsage {
2182 input_tokens: 0,
2183 output_tokens: 0,
2184 cache_read_tokens: None,
2185 cache_creation_tokens: None,
2186 actual_cost_usd: Some(compaction_cost),
2187 estimated_cost_usd: None,
2188 effective_cost_usd: None,
2189 });
2190 }
2191 }
2192 }
2193 generation_data = generation_data.with_compaction(info);
2194 }
2195
2196 if let Some(request_options) =
2197 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
2198 {
2199 generation_data = generation_data.with_request_options(request_options);
2200 }
2201
2202 if let Err(e) = self
2203 .event_emitter
2204 .emit(EventRequest::new(
2205 session_id,
2206 event_context,
2207 generation_data,
2208 ))
2209 .await
2210 {
2211 tracing::warn!(
2212 session_id = %session_id,
2213 error = %e,
2214 "ReasonAtom: failed to emit llm.generation event"
2215 );
2216 }
2217
2218 let mut metadata = std::collections::HashMap::new();
2220 metadata.insert(
2221 "model".to_string(),
2222 serde_json::Value::String(runtime_agent.model.clone()),
2223 );
2224 if let Some(effort) = reasoning_effort {
2225 metadata.insert(
2226 "reasoning_effort".to_string(),
2227 serde_json::Value::String(effort.as_str().to_string()),
2228 );
2229 }
2230 metadata.insert(
2237 "provider".to_string(),
2238 serde_json::Value::String(model_with_provider.provider_type.to_string()),
2239 );
2240 if let Some(ref rid) = response_id {
2241 metadata.insert(
2242 "response_id".to_string(),
2243 serde_json::Value::String(rid.clone()),
2244 );
2245 }
2246
2247 let text = filter_response_text(
2251 &self.capability_registry,
2252 &resolved_capability_configs,
2253 text,
2254 );
2255 let has_tool_calls = !tool_calls.is_empty();
2256 let mut assistant_message = if has_tool_calls {
2257 Message::assistant_with_tools(&text, tool_calls.clone())
2258 } else {
2259 Message::assistant(&text)
2260 }
2261 .with_id(output_message_id);
2262 if !citation_annotations.is_empty() {
2265 for part in assistant_message.content.iter_mut() {
2266 if let crate::message::ContentPart::Text(t) = part {
2267 t.annotations = std::mem::take(&mut citation_annotations);
2268 break;
2269 }
2270 }
2271 }
2272 let provider_type_for_reasoning = model_with_provider.provider_type.to_string();
2276 let provider_phase = completion_metadata
2280 .as_ref()
2281 .and_then(|meta| meta.phase.as_deref())
2282 .and_then(everruns_provider::ExecutionPhase::from_provider_str);
2283 let (phase, phase_source) = match provider_phase {
2284 Some(phase) => (phase, everruns_provider::PhaseSource::Provider),
2285 None => (
2286 everruns_provider::ExecutionPhase::from_has_tool_calls(has_tool_calls),
2287 everruns_provider::PhaseSource::Derived,
2288 ),
2289 };
2290 assistant_message.phase = Some(phase);
2291 assistant_message.phase_source = Some(phase_source);
2292 assistant_message.metadata = Some(metadata);
2293 if reasoning.is_empty() && !thinking.is_empty() {
2302 reasoning.push(
2303 ReasoningContentPart::opaque(provider_type_for_reasoning.clone()).with_text(
2304 ReasoningText::Plain {
2305 text: thinking.clone(),
2306 },
2307 ),
2308 );
2309 }
2310 if !reasoning.is_empty() {
2311 let mut content = Vec::with_capacity(reasoning.len() + assistant_message.content.len());
2312 content.extend(reasoning.drain(..).map(ContentPart::Reasoning));
2313 content.append(&mut assistant_message.content);
2314 assistant_message.content = content;
2315 }
2316 let message_event_context = EventContext::from_execution_context(context).with_span(
2319 trace_id.to_string(),
2320 Uuid::now_v7().to_string(),
2321 Some(reason_span_id.to_string()),
2322 );
2323 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
2324 if let Some(ref u) = usage {
2325 output_message_data = output_message_data.with_usage(u.clone());
2326 }
2327 self.event_emitter
2328 .emit(EventRequest::new(
2329 session_id,
2330 message_event_context,
2331 output_message_data,
2332 ))
2333 .await?;
2334
2335 tracing::info!(
2336 session_id = %session_id,
2337 turn_id = %context.turn_id,
2338 has_tool_calls = %has_tool_calls,
2339 tool_count = %tool_calls.len(),
2340 "ReasonAtom: LLM call completed"
2341 );
2342
2343 Ok(ReasonResult {
2344 success: true,
2345 text,
2346 tool_calls,
2347 has_tool_calls,
2348 tool_definitions: runtime_agent.tools.clone(),
2349 max_iterations: runtime_agent.max_iterations,
2350 error: None,
2351 user_facing_error: None,
2352 error_disclosure: None,
2353 usage,
2354 output_message_id: Some(output_message_id),
2355 time_to_first_token_ms,
2356 response_id,
2357 finish_reason,
2358 locale: resolved_locale,
2359 network_access: runtime_agent.network_access.clone(),
2360 parallel_tool_calls: runtime_agent.parallel_tool_calls,
2361 })
2362 }
2363
2364 async fn finalize_partial_stream(
2369 &self,
2370 session_id: SessionId,
2371 context: &ExecutionContext,
2372 partial: PartialStreamState,
2373 iteration: u32,
2374 runtime_agent: &crate::RuntimeAgent,
2375 resolved_capability_configs: &[crate::CapabilityRef],
2376 ) -> Result<ReasonResult> {
2377 let event_context = EventContext::from_execution_context(context);
2378 let turn_id = context.turn_id;
2379 let message_id = partial.message_id;
2380
2381 let _ = self
2383 .event_emitter
2384 .emit(EventRequest::new(
2385 session_id,
2386 event_context.clone(),
2387 OutputMessageStartedData {
2388 turn_id,
2389 message_id,
2390 model: None,
2391 iteration: Some(iteration),
2392 phase: None,
2395 },
2396 ))
2397 .await;
2398
2399 let accumulated = filter_response_text(
2402 &self.capability_registry,
2403 resolved_capability_configs,
2404 partial.accumulated,
2405 );
2406 let assistant_message = Message::assistant(&accumulated).with_id(message_id);
2407 let output_message_id = message_id;
2408 self.event_emitter
2409 .emit(EventRequest::new(
2410 session_id,
2411 event_context.clone(),
2412 OutputMessageCompletedData::new(assistant_message),
2413 ))
2414 .await?;
2415
2416 let accumulated_len = accumulated.len();
2418 let _ = self
2419 .event_emitter
2420 .emit(EventRequest::new(
2421 session_id,
2422 event_context.clone(),
2423 ReasonRecoveredData {
2424 turn_id,
2425 mode: RecoveryMode::Finalize,
2426 accumulated_len,
2427 },
2428 ))
2429 .await;
2430
2431 tracing::info!(
2432 session_id = %session_id,
2433 turn_id = %turn_id,
2434 accumulated_len,
2435 "ReasonAtom: finalized partial stream from persisted accumulated text"
2436 );
2437
2438 Ok(ReasonResult {
2439 success: true,
2440 text: accumulated,
2441 tool_calls: vec![],
2442 has_tool_calls: false,
2443 tool_definitions: runtime_agent.tools.clone(),
2444 max_iterations: runtime_agent.max_iterations,
2445 error: None,
2446 user_facing_error: None,
2447 error_disclosure: None,
2448 usage: None,
2449 output_message_id: Some(output_message_id),
2450 time_to_first_token_ms: None,
2451 response_id: None,
2452 finish_reason: Some("stop".to_string()),
2453 locale: None,
2454 network_access: None,
2455 parallel_tool_calls: None,
2457 })
2458 }
2459
2460 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
2471 let mut resolved = HashMap::new();
2472
2473 let resolver = match &self.image_resolver {
2475 Some(r) => r,
2476 None => return resolved,
2477 };
2478
2479 let image_ids: Vec<Uuid> = messages
2481 .iter()
2482 .flat_map(crate::llm_conversions::extract_image_file_ids)
2483 .collect::<std::collections::HashSet<_>>()
2484 .into_iter()
2485 .collect();
2486
2487 if image_ids.is_empty() {
2488 return resolved;
2489 }
2490
2491 tracing::debug!(
2492 image_count = image_ids.len(),
2493 "ReasonAtom: resolving image_file references"
2494 );
2495
2496 for image_id in image_ids {
2498 match resolver.resolve_image(image_id).await {
2499 Ok(Some(image)) => {
2500 resolved.insert(image_id, image);
2501 }
2502 Ok(None) => {
2503 tracing::warn!(
2504 image_id = %image_id,
2505 "ReasonAtom: image not found during resolution"
2506 );
2507 }
2508 Err(e) => {
2509 tracing::warn!(
2510 image_id = %image_id,
2511 error = %e,
2512 "ReasonAtom: failed to resolve image"
2513 );
2514 }
2515 }
2516 }
2517
2518 tracing::debug!(
2519 resolved_count = resolved.len(),
2520 "ReasonAtom: image resolution complete"
2521 );
2522
2523 resolved
2524 }
2525}
2526
2527#[cfg(test)]
2532mod tests;