1use async_trait::async_trait;
20use futures::StreamExt;
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23use std::collections::{BTreeSet, HashMap};
24use std::sync::Arc;
25use std::time::Instant;
26use uuid::Uuid;
27
28use super::{Atom, AtomContext};
29use crate::capabilities::CapabilityRegistry;
30use crate::driver_registry::{
31 DriverRegistry, LlmCompletionMetadata, LlmMessage, LlmMessageContent, LlmMessageRole,
32 LlmStreamEvent,
33};
34use crate::error::{AgentLoopError, Result};
35use crate::events::{
36 CapabilityUsageData, CapabilityUsageKind, CapabilityUsageRecord, EventContext, EventRequest,
37 LlmCompactionInfo, LlmGenerationData, LlmPromptCacheInfo, LlmRequestOptions, LlmRetryInfo,
38 LlmToolSearchInfo, OutputMessageCompletedData, OutputMessageDeltaData,
39 OutputMessageReplacedData, OutputMessageStartedData, ReasonCompletedData, ReasonItemData,
40 ReasonRecoveredData, ReasonStartedData, ReasonThinkingCompletedData, ReasonThinkingDeltaData,
41 ReasonThinkingStartedData, RecoveryMode, TokenUsage, ToolDefinitionSummary,
42 TranscriptRepairAction, TranscriptRepairedData,
43};
44use crate::llm_retry::{
45 LlmRetryConfig, RetryMetadata, is_transient_error_message, is_transient_stream_error,
46};
47use crate::message::{Message, MessageRole};
48use crate::message_retriever::MessageRetriever;
49use crate::openresponses_protocol::{
50 CompactInputItem, CompactRequest, compact_output_to_messages, messages_to_compact_input,
51};
52use crate::output_guardrail::{
53 ArmedGuardrail, OutputGuardrailContext, PostGenerationOutputContext, PostGenerationProvider,
54 TrippedGuardrail, evaluate_guardrails, evaluate_post_generation_guardrails,
55};
56use crate::runtime_context::{AssembledTurnContext, assemble_turn_context};
57use crate::tool_types::{ToolCall, ToolDefinition};
58use crate::traits::{
59 AgentStore, DurableToolCallStatus, DurableToolResultStore, EventEmitter, HarnessStore,
60 ImageResolver, PartialStreamState, PartialStreamStore, ProviderStore, ResolvedImage,
61 ResolvedModel, SessionStore,
62};
63use crate::typed_id::{AgentId, HarnessId, MessageId, SessionId};
64use crate::{ErrorDisclosure, UserFacingError, UserFacingErrorContext, user_facing_error_codes};
65
66#[allow(clippy::too_many_arguments)] async fn apply_tool_call_repair(
86 capability_registry: &CapabilityRegistry,
87 event_emitter: &dyn EventEmitter,
88 session_id: SessionId,
89 context: &AtomContext,
90 resolved_capability_configs: &[crate::AgentCapabilityConfig],
91 tool_definitions: &[ToolDefinition],
92 tool_calls: &mut [ToolCall],
93 iteration: u32,
94) {
95 use crate::capabilities::{
96 RepairOutcome, SalvageResult, TOOL_CALL_REPAIR_CAPABILITY_ID, ToolCallRepairConfig,
97 salvage_tool_arguments,
98 };
99
100 let Some(cfg) = resolved_capability_configs.iter().find(|c| {
102 capability_registry.canonical_id(c.capability_ref.as_str())
103 == Some(TOOL_CALL_REPAIR_CAPABILITY_ID)
104 }) else {
105 return;
106 };
107 let repair_config = ToolCallRepairConfig::from_json(&cfg.config);
108
109 for call in tool_calls.iter_mut() {
110 let schema = tool_definitions
112 .iter()
113 .find(|t| t.name() == call.name)
114 .map(|t| t.full_parameters().clone());
115
116 let outcome = match salvage_tool_arguments(&call.arguments, schema.as_ref()) {
117 SalvageResult::AlreadyValid => continue,
119 SalvageResult::Repaired(fixed) => {
120 call.arguments = fixed;
121 RepairOutcome::LocalSalvage
122 }
123 SalvageResult::Unsalvageable => {
130 repair_config.outcome_after_failed_salvage(iteration.saturating_sub(1))
131 }
132 };
133
134 tracing::info!(
135 session_id = %session_id,
136 turn_id = %context.turn_id,
137 tool_call_id = %call.id,
138 tool_name = %call.name,
139 outcome = outcome.label(),
140 "ReasonAtom: tool-call repair"
141 );
142
143 if let Err(e) = event_emitter
144 .emit(EventRequest::new(
145 session_id,
146 EventContext::from_atom_context(context),
147 crate::events::ToolCallRepairedData {
148 turn_id: context.turn_id,
149 tool_call_id: call.id.clone(),
150 tool_name: call.name.clone(),
151 outcome: outcome.label().to_string(),
152 },
153 ))
154 .await
155 {
156 tracing::warn!(
157 session_id = %session_id,
158 error = %e,
159 "ReasonAtom: failed to emit tool.call_repaired event"
160 );
161 }
162 }
163}
164
165async fn repair_dangling_tool_calls(
177 messages: &[Message],
178 durable_store: Option<&dyn DurableToolResultStore>,
179 event_emitter: &dyn EventEmitter,
180 session_id: crate::typed_id::SessionId,
181 event_context: &EventContext,
182 turn_id: &str,
183) -> Vec<Message> {
184 let mut result = Vec::new();
185
186 for (i, msg) in messages.iter().enumerate() {
187 result.push(msg.clone());
188
189 if msg.role != MessageRole::Agent || !msg.has_tool_calls() {
190 continue;
191 }
192
193 for tc in msg.tool_calls() {
194 let has_result = messages[(i + 1)..]
195 .iter()
196 .any(|m| m.role == MessageRole::ToolResult && m.tool_call_id() == Some(&tc.id));
197
198 if has_result {
199 continue;
200 }
201
202 let (repair_msg, action) = if let Some(store) = durable_store {
204 match store.get_tool_call_status(turn_id, &tc.id).await {
205 Ok(Some(DurableToolCallStatus::Settled { result_json })) => {
206 let repair = match serde_json::from_value::<crate::tool_types::ToolResult>(
208 result_json.clone(),
209 ) {
210 Ok(tr) => Message::tool_result(&tc.id, tr.result, tr.error),
211 Err(_) => Message::tool_result(&tc.id, Some(result_json), None),
212 };
213 (repair, TranscriptRepairAction::Replay)
214 }
215 Ok(Some(DurableToolCallStatus::Interrupted { result_json })) => {
216 let err = result_json
218 .as_ref()
219 .and_then(|v| {
220 serde_json::from_value::<crate::tool_types::ToolResult>(v.clone())
221 .ok()
222 })
223 .and_then(|tr| tr.error)
224 .unwrap_or_else(|| {
225 "tool execution did not complete before recovery; result unknown"
226 .to_string()
227 });
228 (
229 Message::tool_result(&tc.id, None, Some(err)),
230 TranscriptRepairAction::Replay,
231 )
232 }
233 Ok(Some(DurableToolCallStatus::Running)) => {
234 (
236 Message::tool_result(
237 &tc.id,
238 None,
239 Some(
240 "interrupted - tool execution was interrupted by worker \
241 failure and the result is uncertain; do not retry \
242 automatically"
243 .to_string(),
244 ),
245 ),
246 TranscriptRepairAction::Synthesize,
247 )
248 }
249 Ok(None) => {
250 (
252 Message::tool_result(
253 &tc.id,
254 None,
255 Some(
256 "interrupted - tool was not executed before recovery; \
257 safe to retry"
258 .to_string(),
259 ),
260 ),
261 TranscriptRepairAction::Synthesize,
262 )
263 }
264 Err(e) => {
265 tracing::warn!(
267 tool_call_id = %tc.id,
268 error = %e,
269 "transcript repair: durable store error; status unknown"
270 );
271 (
272 Message::tool_result(
273 &tc.id,
274 None,
275 Some(
276 "interrupted - tool execution status unknown due to \
277 store error; do not retry automatically"
278 .to_string(),
279 ),
280 ),
281 TranscriptRepairAction::Synthesize,
282 )
283 }
284 }
285 } else {
286 (
288 Message::tool_result(
289 &tc.id,
290 None,
291 Some(
292 "cancelled - another message came in before it could be completed"
293 .to_string(),
294 ),
295 ),
296 TranscriptRepairAction::Synthesize,
297 )
298 };
299
300 let repair_event = EventRequest::new(
302 session_id,
303 event_context.clone(),
304 TranscriptRepairedData {
305 tool_call_id: tc.id.clone(),
306 tool_name: Some(tc.name.clone()),
307 action,
308 },
309 );
310 if let Err(e) = event_emitter.emit(repair_event).await {
311 tracing::warn!(
312 tool_call_id = %tc.id,
313 error = %e,
314 "transcript repair: failed to emit transcript.repaired event"
315 );
316 }
317
318 result.push(repair_msg);
319 }
320 }
321
322 result
323}
324
325const ERROR_PLACEHOLDER_MESSAGES: &[&str] = &[
328 "I encountered an error while processing your request. Please try again later.",
329 "The AI provider is experiencing issues. Please try again shortly.",
330 "Rate limited by the AI provider. Please wait a moment.",
331 "The conversation has become too long for the model to process. Please start a new session or reduce the context size.",
332 "There is a misconfiguration with the AI provider. Please contact support.",
333 "The AI provider account is out of credits or quota. Add credits or raise the provider account limits to continue.",
334];
335
336fn stream_event_advances_stall_deadline(event: &LlmStreamEvent) -> bool {
338 match event {
339 LlmStreamEvent::TextDelta(delta) | LlmStreamEvent::ThinkingDelta(delta) => {
340 !delta.is_empty()
341 }
342 LlmStreamEvent::ReasonItem {
343 encrypted_content,
344 summary,
345 token_count,
346 ..
347 } => {
348 encrypted_content
349 .as_ref()
350 .is_some_and(|content| !content.is_empty())
351 || summary.iter().any(|item| !item.is_empty())
352 || token_count.is_some_and(|count| count > 0)
353 }
354 LlmStreamEvent::ToolCalls(calls) => !calls.is_empty(),
355 LlmStreamEvent::MessagePhase(_)
357 | LlmStreamEvent::ThinkingSignature(_)
358 | LlmStreamEvent::Done(_)
359 | LlmStreamEvent::Error(_) => false,
360 }
361}
362
363fn should_retry_stream_error(
364 error: &crate::driver_registry::LlmStreamError,
365 retry_attempts: u32,
366 max_retries: u32,
367 has_output: bool,
368) -> bool {
369 retry_attempts < max_retries && !has_output && is_transient_stream_error(error)
370}
371
372fn merge_retry_metadata(
373 existing: Option<RetryMetadata>,
374 additional: &RetryMetadata,
375) -> Option<RetryMetadata> {
376 if !additional.had_retries() {
377 return existing;
378 }
379
380 let mut merged = existing.unwrap_or_default();
381 merged.attempts += additional.attempts;
382 merged.total_retry_wait += additional.total_retry_wait;
383 if additional.last_rate_limit_info.is_some() {
384 merged.last_rate_limit_info = additional.last_rate_limit_info.clone();
385 }
386 Some(merged)
387}
388
389fn unix_now_secs() -> u64 {
390 std::time::SystemTime::now()
391 .duration_since(std::time::UNIX_EPOCH)
392 .unwrap_or_default()
393 .as_secs()
394}
395
396fn is_error_placeholder_message(msg: &Message) -> bool {
399 if msg.role != MessageRole::Agent {
400 return false;
401 }
402 if msg.has_tool_calls() {
404 return false;
405 }
406 if let Some(metadata) = &msg.metadata
407 && let Some(serde_json::Value::String(code)) = metadata.get("error_code")
408 {
409 return matches!(
410 code.as_str(),
411 user_facing_error_codes::BUDGET_EXHAUSTED
412 | user_facing_error_codes::BUDGET_PAUSED
413 | user_facing_error_codes::MODEL_UNAVAILABLE
414 | user_facing_error_codes::REQUEST_TOO_LARGE
415 | user_facing_error_codes::PROVIDER_RATE_LIMITED
416 | user_facing_error_codes::PROVIDER_USAGE_LIMIT_REACHED
417 | user_facing_error_codes::PROVIDER_MISCONFIGURED
418 | user_facing_error_codes::PROVIDER_QUOTA_EXHAUSTED
419 | user_facing_error_codes::PROVIDER_UNAVAILABLE
420 | user_facing_error_codes::DEPENDENCY_UNAVAILABLE
421 | user_facing_error_codes::PROCESSING_ERROR
422 );
423 }
424 let text = msg.text().unwrap_or("");
425 ERROR_PLACEHOLDER_MESSAGES.contains(&text) || is_dynamic_error_placeholder(text)
426}
427
428fn append_guarded_thinking_delta(
429 armed_guardrails: &mut [ArmedGuardrail],
430 thinking: &mut String,
431 pending_thinking_delta: &mut String,
432 delta: &str,
433) -> Option<TrippedGuardrail> {
434 thinking.push_str(delta);
435
436 if let Some(t) = evaluate_guardrails(armed_guardrails, thinking, delta) {
440 pending_thinking_delta.clear();
441 Some(t)
442 } else {
443 pending_thinking_delta.push_str(delta);
444 None
445 }
446}
447
448fn error_disclosure_override(messages: &[Message]) -> Option<String> {
452 messages
453 .iter()
454 .rev()
455 .find(|m| m.role == MessageRole::User)?
456 .controls
457 .as_ref()?
458 .error_disclosure
459 .clone()
460}
461
462fn is_dynamic_error_placeholder(text: &str) -> bool {
463 (text.starts_with("Budget exhausted.") && text.ends_with("Increase the budget to continue."))
464 || (text.starts_with("Budget paused.")
465 && text.ends_with("Increase or resume the budget to continue."))
466 || (text.starts_with("Budget paused with ")
467 && text.ends_with("Increase or resume the budget to continue."))
468 || (text.starts_with("Soft limit reached.") && text.ends_with("soft limit."))
469 || (text.starts_with("The model `") && text.ends_with("Please select a different model."))
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct ReasonInput {
479 pub context: AtomContext,
481 pub harness_id: HarnessId,
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub agent_id: Option<AgentId>,
486 #[serde(default)]
488 pub org_id: i64,
489 #[serde(default)]
493 pub mcp_tool_definitions: Vec<ToolDefinition>,
494 #[serde(skip_serializing_if = "Option::is_none")]
497 pub previous_response_id: Option<String>,
498 #[serde(default = "default_iteration")]
501 pub iteration: u32,
502}
503
504fn default_iteration() -> u32 {
505 1
506}
507
508#[derive(Debug, Clone, Default, Serialize, Deserialize)]
510pub struct ReasonResult {
511 pub success: bool,
513 pub text: String,
515 #[serde(default)]
517 pub tool_calls: Vec<ToolCall>,
518 pub has_tool_calls: bool,
520 #[serde(default)]
522 pub tool_definitions: Vec<ToolDefinition>,
523 #[serde(default = "default_max_iterations")]
525 pub max_iterations: usize,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub error: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub user_facing_error: Option<UserFacingError>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub error_disclosure: Option<ErrorDisclosure>,
537 #[serde(skip_serializing_if = "Option::is_none")]
539 pub usage: Option<TokenUsage>,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub output_message_id: Option<MessageId>,
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub time_to_first_token_ms: Option<u64>,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub response_id: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub locale: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub network_access: Option<crate::network_access::NetworkAccessList>,
555 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub parallel_tool_calls: Option<bool>,
560}
561
562fn default_max_iterations() -> usize {
563 500
564}
565
566fn build_request_options(
567 config: &crate::driver_registry::LlmCallConfig,
568 provider: &str,
569) -> Option<LlmRequestOptions> {
570 let prompt_cache = config
571 .prompt_cache
572 .as_ref()
573 .filter(|cfg| cfg.enabled)
574 .map(|cfg| LlmPromptCacheInfo {
575 enabled: true,
576 strategy: cfg.strategy,
577 provider_mode: match provider {
578 "openai" => Some("prompt_cache_key".to_string()),
579 "anthropic" => Some("cache_control".to_string()),
580 "gemini" => Some(
581 if cfg.gemini_cached_content.is_some() {
582 "cached_content"
583 } else {
584 "implicit"
585 }
586 .to_string(),
587 ),
588 _ => None,
589 },
590 });
591
592 let tool_search = config
593 .tool_search
594 .as_ref()
595 .filter(|cfg| cfg.enabled)
596 .map(|cfg| LlmToolSearchInfo {
597 enabled: true,
598 threshold: cfg.threshold,
599 });
600
601 let mut provider_options = HashMap::new();
602 if provider == "openai" && config.previous_response_id.is_some() {
603 provider_options.insert(
604 "openai".to_string(),
605 json!({ "previous_response_id": true }),
606 );
607 }
608 if provider == "gemini"
609 && config
610 .prompt_cache
611 .as_ref()
612 .filter(|cfg| cfg.enabled)
613 .and_then(|cfg| cfg.gemini_cached_content.as_ref())
614 .is_some()
615 {
616 provider_options.insert("gemini".to_string(), json!({ "cached_content": true }));
617 }
618
619 let request_options = LlmRequestOptions {
620 prompt_cache,
621 tool_search,
622 provider_options,
623 metadata: config.metadata.clone(),
624 };
625
626 (!request_options.is_empty()).then_some(request_options)
627}
628
629fn capability_name_snapshot(registry: &CapabilityRegistry, capability_id: &str) -> Option<String> {
630 registry
631 .get(capability_id)
632 .map(|capability| capability.name().to_string())
633}
634
635fn capability_usage_snapshot_records(
636 registry: &CapabilityRegistry,
637 resolved_capability_configs: &[crate::AgentCapabilityConfig],
638 tool_definitions: &[ToolDefinition],
639) -> Vec<CapabilityUsageRecord> {
640 let mut records = Vec::new();
641 let mut seen = BTreeSet::new();
642
643 for config in resolved_capability_configs {
644 let capability_id = config.capability_id().to_string();
645 if seen.insert((
646 "resolved".to_string(),
647 capability_id.clone(),
648 None::<String>,
649 )) {
650 records.push(CapabilityUsageRecord {
651 capability_name: capability_name_snapshot(registry, &capability_id),
652 capability_id,
653 usage_kind: CapabilityUsageKind::Resolved,
654 tool_name: None,
655 usage_count: Some(1),
656 duration_ms: None,
657 });
658 }
659 }
660
661 for tool in tool_definitions {
662 let Some((capability_id, capability_name)) = tool.capability_attribution() else {
663 continue;
664 };
665 let capability_id = capability_id.to_string();
666 let tool_name = tool.name().to_string();
667 if seen.insert((
668 "exposed".to_string(),
669 capability_id.clone(),
670 Some(tool_name.clone()),
671 )) {
672 records.push(CapabilityUsageRecord {
673 capability_name: capability_name
674 .map(str::to_string)
675 .or_else(|| capability_name_snapshot(registry, &capability_id)),
676 capability_id,
677 usage_kind: CapabilityUsageKind::Exposed,
678 tool_name: Some(tool_name),
679 usage_count: Some(1),
680 duration_ms: None,
681 });
682 }
683 }
684
685 records
686}
687
688pub struct ReasonAtom {
707 harness_store: Arc<dyn HarnessStore>,
708 agent_store: Arc<dyn AgentStore>,
709 session_store: Arc<dyn SessionStore>,
710 message_retriever: Arc<dyn MessageRetriever>,
711 provider_store: Arc<dyn ProviderStore>,
712 capability_registry: CapabilityRegistry,
713 driver_registry: DriverRegistry,
714 event_emitter: Arc<dyn EventEmitter>,
715 image_resolver: Option<Arc<dyn ImageResolver>>,
717 file_store: Option<Arc<dyn crate::traits::SessionFileSystem>>,
720 stream_heartbeater: Option<Arc<dyn crate::traits::StreamHeartbeater>>,
722 provider_stall_timeout: Option<std::time::Duration>,
724 durable_tool_result_store: Option<Arc<dyn DurableToolResultStore>>,
726 partial_stream_store: Option<Arc<dyn PartialStreamStore>>,
728 reasoning_effort_handle: Option<crate::traits::ReasoningEffortHandle>,
732 utility_llm_service: Option<Arc<dyn crate::UtilityLlmService>>,
736 schedule_store: Option<Arc<dyn crate::traits::SessionScheduleStore>>,
742}
743
744impl ReasonAtom {
745 #[allow(clippy::too_many_arguments)]
747 pub fn new(
748 harness_store: impl HarnessStore + 'static,
749 agent_store: impl AgentStore + 'static,
750 session_store: impl SessionStore + 'static,
751 message_retriever: impl MessageRetriever + 'static,
752 provider_store: impl ProviderStore + 'static,
753 capability_registry: CapabilityRegistry,
754 driver_registry: DriverRegistry,
755 event_emitter: impl EventEmitter + 'static,
756 ) -> Self {
757 Self {
758 harness_store: Arc::new(harness_store),
759 agent_store: Arc::new(agent_store),
760 session_store: Arc::new(session_store),
761 message_retriever: Arc::new(message_retriever),
762 provider_store: Arc::new(provider_store),
763 capability_registry,
764 driver_registry,
765 event_emitter: Arc::new(event_emitter),
766 image_resolver: None,
767 file_store: None,
768 stream_heartbeater: None,
769 provider_stall_timeout: None,
770 durable_tool_result_store: None,
771 partial_stream_store: None,
772 reasoning_effort_handle: None,
773 utility_llm_service: None,
774 schedule_store: None,
775 }
776 }
777
778 pub fn with_schedule_store(
781 mut self,
782 store: Arc<dyn crate::traits::SessionScheduleStore>,
783 ) -> Self {
784 self.schedule_store = Some(store);
785 self
786 }
787
788 fn collect_llm_error_hooks(
794 &self,
795 resolved_capability_configs: &[crate::AgentCapabilityConfig],
796 ) -> Vec<(
797 Arc<dyn crate::llm_error_hook::LlmErrorHook>,
798 serde_json::Value,
799 )> {
800 resolved_capability_configs
801 .iter()
802 .filter_map(|cfg| {
803 let cap = self.capability_registry.get(cfg.capability_ref.as_str())?;
804 let hook = cap.llm_error_hook()?;
805 Some((hook, cfg.config.clone()))
806 })
807 .collect()
808 }
809
810 pub fn with_file_store(
817 mut self,
818 file_store: Arc<dyn crate::traits::SessionFileSystem>,
819 ) -> Self {
820 self.file_store = Some(file_store);
821 self
822 }
823
824 pub fn with_image_resolver(mut self, resolver: Arc<dyn ImageResolver>) -> Self {
837 self.image_resolver = Some(resolver);
838 self
839 }
840
841 pub fn with_stream_heartbeater(
843 mut self,
844 heartbeater: Arc<dyn crate::traits::StreamHeartbeater>,
845 ) -> Self {
846 self.stream_heartbeater = Some(heartbeater);
847 self
848 }
849
850 pub fn with_provider_stall_timeout(mut self, timeout: std::time::Duration) -> Self {
853 self.provider_stall_timeout = Some(timeout);
854 self
855 }
856
857 pub fn with_durable_tool_result_store(
863 mut self,
864 store: Arc<dyn DurableToolResultStore>,
865 ) -> Self {
866 self.durable_tool_result_store = Some(store);
867 self
868 }
869
870 pub fn with_partial_stream_store(mut self, store: Arc<dyn PartialStreamStore>) -> Self {
872 self.partial_stream_store = Some(store);
873 self
874 }
875
876 pub fn with_reasoning_effort_handle(
883 mut self,
884 handle: crate::traits::ReasoningEffortHandle,
885 ) -> Self {
886 self.reasoning_effort_handle = Some(handle);
887 self
888 }
889
890 pub fn with_utility_llm_service(mut self, service: Arc<dyn crate::UtilityLlmService>) -> Self {
893 self.utility_llm_service = Some(service);
894 self
895 }
896}
897
898#[async_trait]
899impl Atom for ReasonAtom {
900 type Input = ReasonInput;
901 type Output = ReasonResult;
902
903 fn name(&self) -> &'static str {
904 "reason"
905 }
906
907 async fn execute(&self, input: Self::Input) -> Result<Self::Output> {
908 self.execute_inner(input, None).await
909 }
910}
911
912impl ReasonAtom {
913 pub async fn execute_with_assembled_context(
918 &self,
919 input: ReasonInput,
920 assembled: AssembledTurnContext,
921 ) -> Result<ReasonResult> {
922 self.execute_inner(input, Some(assembled)).await
923 }
924
925 async fn emit_capability_usage_snapshot(
926 &self,
927 session_id: SessionId,
928 context: &AtomContext,
929 resolved_capability_configs: &[crate::AgentCapabilityConfig],
930 tool_definitions: &[ToolDefinition],
931 ) {
932 let records = capability_usage_snapshot_records(
933 &self.capability_registry,
934 resolved_capability_configs,
935 tool_definitions,
936 );
937 if records.is_empty() {
938 return;
939 }
940
941 if let Err(error) = self
942 .event_emitter
943 .emit(EventRequest::new(
944 session_id,
945 EventContext::from_atom_context(context),
946 CapabilityUsageData { records },
947 ))
948 .await
949 {
950 tracing::warn!(
951 session_id = %session_id,
952 error = %error,
953 "ReasonAtom: failed to emit capability.usage event"
954 );
955 }
956 }
957
958 async fn repair_malformed_tool_calls(
967 &self,
968 session_id: SessionId,
969 context: &AtomContext,
970 resolved_capability_configs: &[crate::AgentCapabilityConfig],
971 tool_definitions: &[ToolDefinition],
972 tool_calls: &mut [ToolCall],
973 iteration: u32,
974 ) {
975 apply_tool_call_repair(
976 &self.capability_registry,
977 self.event_emitter.as_ref(),
978 session_id,
979 context,
980 resolved_capability_configs,
981 tool_definitions,
982 tool_calls,
983 iteration,
984 )
985 .await;
986 }
987
988 async fn execute_inner(
989 &self,
990 input: ReasonInput,
991 assembled: Option<AssembledTurnContext>,
992 ) -> Result<ReasonResult> {
993 let ReasonInput {
994 context,
995 harness_id,
996 agent_id,
997 org_id,
998 mcp_tool_definitions,
999 previous_response_id,
1000 iteration,
1001 } = input;
1002
1003 tracing::info!(
1004 session_id = %context.session_id,
1005 turn_id = %context.turn_id,
1006 exec_id = %context.exec_id,
1007 harness_id = %harness_id,
1008 agent_id = ?agent_id,
1009 mcp_tools_count = %mcp_tool_definitions.len(),
1010 "ReasonAtom: starting LLM call"
1011 );
1012
1013 let trace_id = context.turn_id.to_string();
1021 let reason_span_id = Uuid::now_v7().to_string();
1022 let parent_span_id = trace_id.clone(); let event_context = EventContext::from_atom_context(&context).with_span(
1026 trace_id.clone(),
1027 reason_span_id.clone(),
1028 Some(parent_span_id.clone()),
1029 );
1030
1031 let reason_start = Instant::now();
1033
1034 if let Err(e) = self
1036 .event_emitter
1037 .emit(EventRequest::new(
1038 context.session_id,
1039 event_context.clone(),
1040 ReasonStartedData {
1041 harness_id,
1042 agent_id,
1043 metadata: None, },
1045 ))
1046 .await
1047 {
1048 tracing::warn!(
1049 session_id = %context.session_id,
1050 error = %e,
1051 "ReasonAtom: failed to emit reason.started event"
1052 );
1053 }
1054
1055 let assembled = match assembled {
1059 Some(assembled) => Ok(assembled),
1060 None => {
1061 assemble_turn_context(
1062 self.harness_store.as_ref(),
1063 self.agent_store.as_ref(),
1064 self.session_store.as_ref(),
1065 self.message_retriever.as_ref(),
1066 self.provider_store.as_ref(),
1067 &self.capability_registry,
1068 context.session_id,
1069 harness_id,
1070 agent_id,
1071 &mcp_tool_definitions,
1072 self.file_store.clone(),
1073 )
1074 .await
1075 }
1076 };
1077
1078 let (error_disclosure, error_context, error_hooks, call_result) = match assembled {
1079 Ok(assembled) => {
1080 let error_disclosure = crate::capabilities::resolve_error_disclosure(
1081 &assembled.resolved_capability_configs,
1082 error_disclosure_override(&assembled.messages).as_deref(),
1083 );
1084 let error_hooks =
1088 self.collect_llm_error_hooks(&assembled.resolved_capability_configs);
1089 let error_context = UserFacingErrorContext::default()
1090 .with_provider(assembled.model_with_provider.provider_type.to_string())
1091 .with_model_id(assembled.model_with_provider.model.clone());
1092 let call_result = self
1093 .execute_llm_call(
1094 context.session_id,
1095 harness_id,
1096 agent_id,
1097 org_id,
1098 &context,
1099 &trace_id,
1100 &reason_span_id,
1101 previous_response_id,
1102 iteration,
1103 assembled,
1104 )
1105 .await;
1106 (error_disclosure, error_context, error_hooks, call_result)
1107 }
1108 Err(error) => (
1109 ErrorDisclosure::default(),
1110 UserFacingErrorContext::default(),
1111 Vec::new(),
1112 Err(error),
1113 ),
1114 };
1115
1116 let result = match call_result {
1118 Ok(result) => {
1119 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
1121
1122 let completed_context = EventContext::from_atom_context(&context).with_span(
1124 trace_id.clone(),
1125 reason_span_id.clone(), Some(parent_span_id.clone()),
1127 );
1128 if let Err(e) = self
1129 .event_emitter
1130 .emit(EventRequest::new(
1131 context.session_id,
1132 completed_context,
1133 ReasonCompletedData::success(
1134 &result.text,
1135 result.has_tool_calls,
1136 result.tool_calls.len() as u32,
1137 Some(reason_duration_ms),
1138 result.usage.clone(),
1139 ),
1140 ))
1141 .await
1142 {
1143 tracing::warn!(
1144 session_id = %context.session_id,
1145 error = %e,
1146 "ReasonAtom: failed to emit reason.completed event"
1147 );
1148 }
1149 result
1150 }
1151 Err(e) => {
1152 let reason_duration_ms = reason_start.elapsed().as_millis() as u64;
1154
1155 tracing::warn!(
1158 session_id = %context.session_id,
1159 turn_id = %context.turn_id,
1160 error = %e,
1161 "ReasonAtom: LLM call failed"
1162 );
1163
1164 let error_msg = e.to_string();
1165 let mut source_error = e.user_facing_error(error_context);
1166
1167 let is_transient = e.is_transient_llm_error()
1174 || (e.llm_error_kind().is_none() && is_transient_error_message(&error_msg));
1175
1176 if !is_transient && !error_hooks.is_empty() {
1182 let services = crate::llm_error_hook::LlmErrorHookServices {
1183 schedule_store: self.schedule_store.clone(),
1184 };
1185 for (hook, config) in &error_hooks {
1186 let outcome = {
1187 let ctx = crate::llm_error_hook::LlmErrorContext {
1188 session_id: context.session_id,
1189 error_code: &source_error.code,
1190 error_fields: &source_error.fields,
1191 config,
1192 services: &services,
1193 };
1194 hook.on_llm_error(&ctx).await
1195 };
1196 for (key, value) in outcome.extra_error_fields {
1197 source_error = source_error.with_field(key, value);
1198 }
1199 }
1200 }
1201
1202 let user_error = source_error.apply_disclosure(error_disclosure, Some(&error_msg));
1203 let user_error_text = user_error.fallback_message();
1204
1205 let mut output_message_id = None;
1206
1207 if !is_transient {
1208 let mut error_message = Message::assistant(&user_error_text);
1210 let mut metadata = std::collections::HashMap::new();
1211 user_error.apply_to_message_metadata(&mut metadata);
1212 UserFacingError::apply_disclosure_to_message_metadata(
1213 &mut metadata,
1214 error_disclosure,
1215 &source_error.code,
1216 );
1217 error_message.metadata = Some(metadata);
1218
1219 output_message_id = Some(error_message.id);
1220
1221 let error_msg_context = EventContext::from_atom_context(&context).with_span(
1224 trace_id.clone(),
1225 Uuid::now_v7().to_string(), Some(reason_span_id.clone()), );
1228 if let Err(emit_err) = self
1229 .event_emitter
1230 .emit(EventRequest::new(
1231 context.session_id,
1232 error_msg_context,
1233 OutputMessageCompletedData::new(error_message)
1234 .with_user_facing_error(&user_error)
1235 .with_error_disclosure(error_disclosure),
1236 ))
1237 .await
1238 {
1239 tracing::warn!(
1240 session_id = %context.session_id,
1241 error = %emit_err,
1242 "ReasonAtom: failed to emit output.message.completed event for error"
1243 );
1244 }
1245 } else {
1246 tracing::info!(
1247 session_id = %context.session_id,
1248 "ReasonAtom: skipping error event for transient LLM error (will be retried)"
1249 );
1250 }
1251
1252 let completed_context = EventContext::from_atom_context(&context).with_span(
1254 trace_id.clone(),
1255 reason_span_id.clone(), Some(parent_span_id.clone()),
1257 );
1258 if let Err(emit_err) = self
1259 .event_emitter
1260 .emit(EventRequest::new(
1261 context.session_id,
1262 completed_context,
1263 ReasonCompletedData::failure(error_msg.clone(), Some(reason_duration_ms)),
1264 ))
1265 .await
1266 {
1267 tracing::warn!(
1268 session_id = %context.session_id,
1269 error = %emit_err,
1270 "ReasonAtom: failed to emit reason.completed event"
1271 );
1272 }
1273
1274 ReasonResult {
1275 success: false,
1276 text: user_error_text,
1277 tool_calls: vec![],
1278 has_tool_calls: false,
1279 tool_definitions: vec![],
1280 max_iterations: default_max_iterations(),
1281 error: Some(error_msg),
1282 user_facing_error: Some(user_error),
1283 error_disclosure: Some(error_disclosure),
1284 usage: None,
1285 output_message_id,
1286 time_to_first_token_ms: None,
1287 response_id: None,
1288 locale: None,
1289 network_access: None,
1290 parallel_tool_calls: None,
1291 }
1292 }
1293 };
1294
1295 Ok(result)
1296 }
1297
1298 #[allow(clippy::too_many_arguments)]
1300 async fn execute_llm_call(
1301 &self,
1302 session_id: SessionId,
1303 harness_id: HarnessId,
1304 agent_id: Option<AgentId>,
1305 org_id: i64,
1306 context: &AtomContext,
1307 trace_id: &str,
1308 reason_span_id: &str,
1309 previous_response_id: Option<String>,
1310 iteration: u32,
1311 assembled: AssembledTurnContext,
1312 ) -> Result<ReasonResult> {
1313 let messages = assembled.messages;
1314 let prior_usage = assembled.session.usage.clone();
1315 let model_with_provider = assembled.model_with_provider;
1316 let resolved_model_id = assembled.resolved_model_id;
1317 let resolved_locale = assembled.resolved_locale;
1318 let compaction_config = assembled.compaction_config;
1319 let resolved_capability_configs = assembled.resolved_capability_configs;
1320 let runtime_agent = assembled.runtime_agent;
1321 let embedder_metadata = assembled.embedder_metadata;
1322
1323 self.emit_capability_usage_snapshot(
1324 session_id,
1325 context,
1326 &resolved_capability_configs,
1327 &runtime_agent.tools,
1328 )
1329 .await;
1330
1331 let guardrail_providers: Vec<(
1338 &str,
1339 &serde_json::Value,
1340 Arc<dyn crate::output_guardrail::OutputGuardrail>,
1341 )> = resolved_capability_configs
1342 .iter()
1343 .filter_map(|cfg| {
1344 let cap_id = cfg.capability_ref.as_str();
1345 let cap = self.capability_registry.get(cap_id)?;
1346 let guards = cap.output_guardrails();
1347 if guards.is_empty() {
1348 return None;
1349 }
1350 Some(
1351 guards
1352 .into_iter()
1353 .map(move |g| (cap_id, &cfg.config, g))
1354 .collect::<Vec<_>>(),
1355 )
1356 })
1357 .flatten()
1358 .collect();
1359
1360 let post_output_providers: Vec<PostGenerationProvider> = resolved_capability_configs
1366 .iter()
1367 .filter_map(|cfg| {
1368 let cap_id = cfg.capability_ref.as_str();
1369 let cap = self.capability_registry.get(cap_id)?;
1370 let providers = cap.post_output_guardrails_with_config(&cfg.config);
1371 if providers.is_empty() {
1372 return None;
1373 }
1374 Some(
1375 providers
1376 .into_iter()
1377 .map(move |provider| PostGenerationProvider {
1378 capability_id: cap_id.to_string(),
1379 provider,
1380 })
1381 .collect::<Vec<_>>(),
1382 )
1383 })
1384 .flatten()
1385 .collect();
1386
1387 let chat_driver = self.create_chat_driver(&model_with_provider)?;
1389 let stateful_response_continuation =
1390 previous_response_id.is_some() && chat_driver.supports_stateful_responses();
1391
1392 let handle_effort = self
1403 .reasoning_effort_handle
1404 .as_ref()
1405 .and_then(|handle| handle.get());
1406 let raw_reasoning_effort = handle_effort.or_else(|| {
1407 messages
1408 .iter()
1409 .rev()
1410 .find(|m| m.role == MessageRole::User)
1411 .and_then(|m| m.controls.as_ref())
1412 .and_then(|c| c.reasoning.as_ref())
1413 .and_then(|r| r.effort.clone())
1414 });
1415 let reasoning_effort = raw_reasoning_effort.filter(|effort| {
1416 if effort.eq_ignore_ascii_case("none") {
1418 return false;
1419 }
1420 let profile = crate::model_profiles::get_model_profile(
1423 &model_with_provider.provider_type,
1424 &model_with_provider.model,
1425 );
1426 match profile {
1427 Some(p) if !p.reasoning => {
1428 tracing::warn!(
1429 model = %model_with_provider.model,
1430 effort = %effort,
1431 "Stripping reasoning_effort: model does not support reasoning"
1432 );
1433 false
1434 }
1435 _ => true,
1436 }
1437 });
1438
1439 let speed = messages
1444 .iter()
1445 .rev()
1446 .find(|m| m.role == MessageRole::User)
1447 .and_then(|m| m.controls.as_ref())
1448 .and_then(|c| c.speed.clone())
1449 .filter(|speed| {
1450 let profile = crate::model_profiles::get_model_profile(
1451 &model_with_provider.provider_type,
1452 &model_with_provider.model,
1453 );
1454 let Some(p) = profile else {
1455 return true;
1456 };
1457 let Some(speed_config) = p.speed else {
1458 tracing::warn!(
1459 model = %model_with_provider.model,
1460 speed = %speed,
1461 "Stripping speed: model does not support service tiers"
1462 );
1463 return false;
1464 };
1465 let supported = speed_config.values.iter().any(|value| {
1466 matches!(
1467 (&value.value, speed.as_str()),
1468 (crate::model::Speed::Flex, "flex")
1469 | (crate::model::Speed::Default, "default")
1470 | (crate::model::Speed::Priority, "priority")
1471 )
1472 });
1473 if !supported {
1474 tracing::warn!(
1475 model = %model_with_provider.model,
1476 speed = %speed,
1477 "Stripping speed: model does not support requested service tier"
1478 );
1479 }
1480 supported
1481 });
1482
1483 let verbosity = messages
1488 .iter()
1489 .rev()
1490 .find(|m| m.role == MessageRole::User)
1491 .and_then(|m| m.controls.as_ref())
1492 .and_then(|c| c.verbosity.clone())
1493 .filter(|verbosity| {
1494 let profile = crate::model_profiles::get_model_profile(
1495 &model_with_provider.provider_type,
1496 &model_with_provider.model,
1497 );
1498 match profile {
1499 Some(p) if p.verbosity.is_none() => {
1500 tracing::warn!(
1501 model = %model_with_provider.model,
1502 verbosity = %verbosity,
1503 "Stripping verbosity: model does not support verbosity control"
1504 );
1505 false
1506 }
1507 _ => true,
1508 }
1509 });
1510
1511 if let Some(ref store) = self.partial_stream_store {
1515 let turn_id_str = context.turn_id.to_string();
1516 match store.get_partial_stream(session_id, &turn_id_str).await {
1517 Ok(Some(partial)) if !partial.accumulated.is_empty() => {
1518 return self
1520 .finalize_partial_stream(
1521 session_id,
1522 context,
1523 partial,
1524 iteration,
1525 runtime_agent.max_iterations,
1526 &runtime_agent.tools,
1527 )
1528 .await;
1529 }
1530 Ok(Some(_)) => {
1531 let recovery_ctx = EventContext::from_atom_context(context);
1534 let _ = self
1535 .event_emitter
1536 .emit(EventRequest::new(
1537 session_id,
1538 recovery_ctx,
1539 ReasonRecoveredData {
1540 turn_id: context.turn_id,
1541 mode: RecoveryMode::Restart,
1542 accumulated_len: 0,
1543 },
1544 ))
1545 .await;
1546 tracing::info!(
1547 session_id = %session_id,
1548 turn_id = %context.turn_id,
1549 "ReasonAtom: partial stream detected with empty accumulated; restarting clean"
1550 );
1551 }
1552 Ok(None) => {} Err(e) => {
1554 tracing::warn!(
1556 session_id = %session_id,
1557 turn_id = %context.turn_id,
1558 error = %e,
1559 "ReasonAtom: partial-stream store error; proceeding with normal execution"
1560 );
1561 }
1562 }
1563 }
1564
1565 let repair_event_context = EventContext::from_atom_context(context);
1569 let patched_messages = repair_dangling_tool_calls(
1570 &messages,
1571 self.durable_tool_result_store.as_deref(),
1572 self.event_emitter.as_ref(),
1573 session_id,
1574 &repair_event_context,
1575 &context.turn_id.to_string(),
1576 )
1577 .await;
1578
1579 let model_view_providers = crate::capabilities::collect_model_view_providers(
1582 &resolved_capability_configs,
1583 &self.capability_registry,
1584 Some(model_with_provider.model.as_str()),
1585 );
1586 let model_view_context = crate::capabilities::ModelViewContext {
1587 session_id,
1588 prior_usage: prior_usage.as_ref(),
1589 };
1590 let mut context_messages =
1591 model_view_providers.apply_model_view(patched_messages, &model_view_context);
1592 context_messages = crate::tool_call_integrity::retain_complete_message_tool_exchanges(
1593 &context_messages,
1594 stateful_response_continuation,
1595 );
1596
1597 let mut volatile_suffix_len = 0usize;
1604 {
1605 let facts_ctx = crate::capabilities::FactsContext::new(session_id);
1606 let dynamic_facts = crate::capabilities::collect_dynamic_facts(
1607 &resolved_capability_configs,
1608 &self.capability_registry,
1609 Some(model_with_provider.model.as_str()),
1610 &facts_ctx,
1611 );
1612 if let Some(block) = crate::capabilities::render_facts_block(&dynamic_facts) {
1613 context_messages.push(Message::user(block));
1614 volatile_suffix_len = 1;
1615 }
1616 }
1617
1618 let resolved_images = self.resolve_images(&context_messages).await;
1623
1624 let mut llm_messages = Vec::new();
1626
1627 let has_system_prompt = !runtime_agent.system_prompt.is_empty();
1629 if has_system_prompt {
1630 llm_messages.push(LlmMessage {
1631 role: LlmMessageRole::System,
1632 content: LlmMessageContent::Text(runtime_agent.system_prompt.clone()),
1633 tool_calls: None,
1634 tool_call_id: None,
1635 phase: None,
1636 thinking: None,
1637 thinking_signature: None,
1638 });
1639 }
1640
1641 let messages_for_event: Vec<Message> = if has_system_prompt {
1643 std::iter::once(Message::system(&runtime_agent.system_prompt))
1644 .chain(context_messages.iter().cloned())
1645 .collect()
1646 } else {
1647 context_messages.clone()
1648 };
1649
1650 let mut stripped_error_count = 0u32;
1656 for msg in &context_messages {
1657 if is_error_placeholder_message(msg) {
1658 stripped_error_count += 1;
1659 continue;
1660 }
1661 let mut llm_msg =
1662 crate::llm_conversions::llm_message_from_message_with_images(msg, &resolved_images);
1663 if msg.role == MessageRole::User
1664 && let Some(ref actor) = msg.external_actor
1665 {
1666 llm_msg.prepend_text_prefix(&format!("[{}] ", actor.display_label()));
1667 }
1668 llm_messages.push(llm_msg);
1669 }
1670 if stripped_error_count > 0 {
1671 tracing::info!(
1672 session_id = %session_id,
1673 stripped_error_count,
1674 "ReasonAtom: stripped error placeholder messages from LLM input"
1675 );
1676 }
1677
1678 llm_messages = crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1683 llm_messages,
1684 stateful_response_continuation,
1685 );
1686
1687 let mut llm_config_builder =
1689 crate::llm_conversions::llm_call_config_builder_from_agent(&runtime_agent);
1690 if let Some(effort) = reasoning_effort.clone() {
1691 llm_config_builder = llm_config_builder.reasoning_effort(effort);
1692 }
1693 if let Some(speed) = speed {
1694 llm_config_builder = llm_config_builder.speed(speed);
1695 }
1696 if let Some(verbosity) = verbosity {
1697 llm_config_builder = llm_config_builder.verbosity(verbosity);
1698 }
1699
1700 for (k, v) in &embedder_metadata {
1702 llm_config_builder = llm_config_builder.with_metadata(k, v.clone());
1703 }
1704
1705 llm_config_builder = llm_config_builder
1709 .with_metadata("session_id", session_id.to_string())
1710 .with_metadata("harness_id", harness_id.to_string())
1711 .with_metadata("turn_id", context.turn_id.to_string())
1712 .with_metadata("exec_id", context.exec_id.to_string())
1713 .with_metadata("org_id", format!("org_{:032x}", org_id));
1714 if let Some(agent_id) = agent_id {
1715 llm_config_builder = llm_config_builder.with_metadata("agent_id", agent_id.to_string());
1716 }
1717
1718 if let Some(model_id) = &resolved_model_id {
1720 llm_config_builder = llm_config_builder.with_metadata("model_id", model_id.to_string());
1721 }
1722
1723 let llm_config = llm_config_builder
1724 .previous_response_id(previous_response_id.clone())
1725 .volatile_suffix_len(volatile_suffix_len)
1726 .build();
1727
1728 tracing::debug!(
1729 session_id = %session_id,
1730 turn_id = %context.turn_id,
1731 model = %runtime_agent.model,
1732 message_count = %llm_messages.len(),
1733 "ReasonAtom: calling LLM"
1734 );
1735
1736 let streaming_event_context = EventContext::from_atom_context(context);
1739
1740 let mut armed_guardrails: Vec<ArmedGuardrail> = Vec::new();
1747 for (cap_id, cfg, provider) in &guardrail_providers {
1748 let ctx = OutputGuardrailContext {
1749 system_prompt: &runtime_agent.system_prompt,
1750 config: cfg,
1751 };
1752 let guardrail_id = provider.id().to_string();
1753 if let Some(run) = provider.arm(&ctx) {
1754 armed_guardrails.push(ArmedGuardrail {
1755 capability_id: (*cap_id).to_string(),
1756 guardrail_id,
1757 run,
1758 });
1759 }
1760 }
1761 let mut tripped: Option<TrippedGuardrail> = None;
1762 let buffer_output_deltas = !post_output_providers.is_empty();
1767 let output_message_id = MessageId::new();
1771 tracing::info!(
1772 session_id = %session_id,
1773 turn_id = %context.turn_id,
1774 "ReasonAtom: emitting output.message.started event"
1775 );
1776 if let Err(e) = self
1777 .event_emitter
1778 .emit(EventRequest::new(
1779 session_id,
1780 streaming_event_context.clone(),
1781 OutputMessageStartedData {
1782 turn_id: context.turn_id,
1783 message_id: output_message_id,
1784 model: Some(runtime_agent.model.clone()),
1785 iteration: Some(iteration),
1786 phase: None,
1789 },
1790 ))
1791 .await
1792 {
1793 tracing::warn!(
1794 session_id = %session_id,
1795 error = %e,
1796 "ReasonAtom: failed to emit output.message.started event"
1797 );
1798 } else {
1799 tracing::info!(
1800 session_id = %session_id,
1801 "ReasonAtom: output.message.started event emitted successfully"
1802 );
1803 }
1804
1805 let thinking_enabled = reasoning_effort.is_some();
1807 if thinking_enabled {
1808 tracing::info!(
1809 session_id = %session_id,
1810 turn_id = %context.turn_id,
1811 "ReasonAtom: emitting reason.thinking.started event"
1812 );
1813 if let Err(e) = self
1814 .event_emitter
1815 .emit(EventRequest::new(
1816 session_id,
1817 streaming_event_context.clone(),
1818 ReasonThinkingStartedData {
1819 turn_id: context.turn_id,
1820 model: Some(runtime_agent.model.clone()),
1821 },
1822 ))
1823 .await
1824 {
1825 tracing::warn!(
1826 session_id = %session_id,
1827 error = %e,
1828 "ReasonAtom: failed to emit reason.thinking.started event"
1829 );
1830 } else {
1831 tracing::info!(
1832 session_id = %session_id,
1833 "ReasonAtom: reason.thinking.started event emitted successfully"
1834 );
1835 }
1836 }
1837
1838 let llm_start = Instant::now();
1840
1841 let mut compaction_info: Option<LlmCompactionInfo> = None;
1845 let mut llm_messages_for_call = llm_messages.clone();
1846
1847 if let Some(ref config) = compaction_config {
1850 let context_window = crate::model_profiles::get_model_profile(
1851 &model_with_provider.provider_type,
1852 &model_with_provider.model,
1853 )
1854 .and_then(|p| p.limits.map(|l| l.context as usize))
1855 .unwrap_or(128_000);
1856
1857 if crate::capabilities::should_compact_proactively(
1858 &llm_messages_for_call,
1859 config,
1860 context_window,
1861 ) {
1862 use crate::capabilities::{
1863 CompactionStrategy, aggressive_trim, apply_observation_masking,
1864 estimate_total_tokens,
1865 };
1866 use crate::events::{
1867 CompactionReason, CompactionStepData, ContextCompactedData,
1868 ContextCompactingData,
1869 };
1870
1871 let messages_before = llm_messages_for_call.len();
1872 let cascade_start = Instant::now();
1873 let mut strategies_used: Vec<String> = Vec::new();
1874 let mut steps: Vec<CompactionStepData> = Vec::new();
1875
1876 tracing::info!(
1877 session_id = %session_id,
1878 strategy = %config.strategy,
1879 messages = messages_before,
1880 "ReasonAtom: proactive compaction triggered (budget threshold exceeded)"
1881 );
1882
1883 let _ = self
1885 .event_emitter
1886 .emit(EventRequest::new(
1887 session_id,
1888 streaming_event_context.clone(),
1889 ContextCompactingData {
1890 reason: CompactionReason::ProactiveBudget,
1891 strategy: config.strategy.to_string(),
1892 messages_before,
1893 },
1894 ))
1895 .await;
1896
1897 let run_masking = matches!(
1898 config.strategy,
1899 CompactionStrategy::Auto | CompactionStrategy::ObservationMasking
1900 );
1901
1902 if run_masking {
1904 let step_start = Instant::now();
1905 let conversation_msgs = if has_system_prompt {
1906 &llm_messages_for_call[1..]
1907 } else {
1908 &llm_messages_for_call[..]
1909 };
1910
1911 let masking_result =
1912 apply_observation_masking(conversation_msgs, &config.observation_masking);
1913
1914 if masking_result.masked_count > 0 {
1915 let mut new_messages = Vec::new();
1916 if has_system_prompt {
1917 new_messages.push(llm_messages_for_call[0].clone());
1918 }
1919 new_messages.extend(masking_result.messages);
1920 llm_messages_for_call = new_messages;
1921
1922 let step_duration = step_start.elapsed().as_millis() as u64;
1923 strategies_used.push("observation_masking".to_string());
1924 steps.push(CompactionStepData {
1925 strategy: "observation_masking".to_string(),
1926 messages_after: llm_messages_for_call.len(),
1927 duration_ms: step_duration,
1928 });
1929 }
1930 }
1931
1932 let budget_tokens = (context_window as f32 * config.budget_percent) as usize;
1934 if estimate_total_tokens(&llm_messages_for_call) > budget_tokens {
1935 let step_start = Instant::now();
1936 llm_messages_for_call =
1937 aggressive_trim(&llm_messages_for_call, budget_tokens, has_system_prompt);
1938
1939 let step_duration = step_start.elapsed().as_millis() as u64;
1940 strategies_used.push("aggressive_trim".to_string());
1941 steps.push(CompactionStepData {
1942 strategy: "aggressive_trim".to_string(),
1943 messages_after: llm_messages_for_call.len(),
1944 duration_ms: step_duration,
1945 });
1946 }
1947
1948 let cascade_duration = cascade_start.elapsed().as_millis() as u64;
1949 let messages_after = llm_messages_for_call.len();
1950
1951 if !strategies_used.is_empty() {
1952 let strategy_used = strategies_used.join("+");
1953
1954 let _ = self
1955 .event_emitter
1956 .emit(EventRequest::new(
1957 session_id,
1958 streaming_event_context.clone(),
1959 ContextCompactedData {
1960 strategy_used: strategy_used.clone(),
1961 messages_before,
1962 messages_after,
1963 duration_ms: cascade_duration,
1964 steps,
1965 },
1966 ))
1967 .await;
1968
1969 tracing::info!(
1970 session_id = %session_id,
1971 strategy = %strategy_used,
1972 messages_before,
1973 messages_after,
1974 duration_ms = cascade_duration,
1975 "ReasonAtom: proactive compaction completed"
1976 );
1977 }
1978 }
1979 }
1980
1981 const DELTA_BATCH_INTERVAL_MS: u64 = 100;
1984 let retry_config = LlmRetryConfig::default();
1985 let mut stream_retry_metadata = RetryMetadata::default();
1986 let mut streamed_phase: Option<crate::message::ExecutionPhase> = None;
1991 let (
1992 text,
1993 thinking,
1994 thinking_signature,
1995 tool_calls,
1996 completion_metadata,
1997 time_to_first_token_ms,
1998 pending_delta,
1999 ) = 'stream_attempt: loop {
2000 let mut stream = match chat_driver
2001 .chat_completion_stream(llm_messages_for_call.clone(), &llm_config)
2002 .await
2003 {
2004 Ok(stream) => stream,
2005 Err(e) if e.is_request_too_large() => {
2006 use crate::capabilities::{CompactionStrategy, apply_observation_masking};
2008 use crate::events::{
2009 CompactionReason, CompactionStepData, ContextCompactedData,
2010 ContextCompactingData,
2011 };
2012
2013 let Some(config) = compaction_config.clone() else {
2014 tracing::warn!(
2015 session_id = %session_id,
2016 turn_id = %context.turn_id,
2017 "ReasonAtom: context too large and compaction capability is not enabled"
2018 );
2019 return Err(e);
2020 };
2021 let messages_before = llm_messages_for_call.len();
2022
2023 tracing::info!(
2024 session_id = %session_id,
2025 turn_id = %context.turn_id,
2026 strategy = %config.strategy,
2027 messages = messages_before,
2028 "ReasonAtom: context too large, attempting compaction"
2029 );
2030
2031 let _ = self
2033 .event_emitter
2034 .emit(EventRequest::new(
2035 session_id,
2036 streaming_event_context.clone(),
2037 ContextCompactingData {
2038 reason: CompactionReason::RequestTooLarge,
2039 strategy: config.strategy.to_string(),
2040 messages_before,
2041 },
2042 ))
2043 .await;
2044
2045 let cascade_start = Instant::now();
2046 let mut steps: Vec<CompactionStepData> = Vec::new();
2047 let mut strategies_used: Vec<String> = Vec::new();
2048
2049 let run_masking = matches!(
2051 config.strategy,
2052 CompactionStrategy::Auto | CompactionStrategy::ObservationMasking
2053 );
2054 let run_native = matches!(
2055 config.strategy,
2056 CompactionStrategy::Auto | CompactionStrategy::Native
2057 ) && chat_driver.supports_compact();
2058 let run_summarization = matches!(
2059 config.strategy,
2060 CompactionStrategy::Auto | CompactionStrategy::Summarization
2061 );
2062
2063 if run_masking {
2065 let step_start = Instant::now();
2066 let conversation_msgs = if has_system_prompt {
2067 &llm_messages_for_call[1..]
2068 } else {
2069 &llm_messages_for_call[..]
2070 };
2071
2072 let masking_result = apply_observation_masking(
2073 conversation_msgs,
2074 &config.observation_masking,
2075 );
2076
2077 if masking_result.masked_count > 0 {
2078 let mut new_messages = Vec::new();
2079 if has_system_prompt {
2080 new_messages.push(llm_messages_for_call[0].clone());
2081 }
2082 new_messages.extend(masking_result.messages);
2083 llm_messages_for_call = new_messages;
2084
2085 let step_duration = step_start.elapsed().as_millis() as u64;
2086 strategies_used.push("observation_masking".to_string());
2087 steps.push(CompactionStepData {
2088 strategy: "observation_masking".to_string(),
2089 messages_after: llm_messages_for_call.len(),
2090 duration_ms: step_duration,
2091 });
2092
2093 tracing::info!(
2094 session_id = %session_id,
2095 masked_count = masking_result.masked_count,
2096 duration_ms = step_duration,
2097 "ReasonAtom: observation masking applied"
2098 );
2099 }
2100 }
2101
2102 if run_native {
2104 let step_start = Instant::now();
2105 let messages_to_compact = if has_system_prompt {
2106 &llm_messages_for_call[1..]
2107 } else {
2108 &llm_messages_for_call[..]
2109 };
2110
2111 let compact_input = messages_to_compact_input(messages_to_compact);
2112 let input_count = compact_input.len();
2113
2114 let compact_request = CompactRequest {
2115 model: runtime_agent.model.clone(),
2116 input: compact_input,
2117 previous_response_id: previous_response_id.clone(),
2118 instructions: if has_system_prompt {
2119 Some(runtime_agent.system_prompt.clone())
2120 } else {
2121 None
2122 },
2123 };
2124
2125 match chat_driver.compact(compact_request).await {
2126 Ok(Some(compact_response)) => {
2127 let (compacted_messages, compaction_items) =
2128 compact_output_to_messages(&compact_response.output);
2129
2130 let input_tokens_after = compact_response
2131 .usage
2132 .as_ref()
2133 .and_then(|u| u.output_tokens);
2134
2135 compaction_info = Some(LlmCompactionInfo::new(
2136 Some(input_count as u32),
2137 input_tokens_after,
2138 Some(step_start.elapsed().as_millis() as u64),
2139 ));
2140
2141 let mut compacted_llm_messages = Vec::new();
2142 if has_system_prompt {
2143 compacted_llm_messages.push(llm_messages_for_call[0].clone());
2144 }
2145 compacted_llm_messages.extend(compacted_messages);
2146
2147 for item in compaction_items {
2148 if let CompactInputItem::Compaction { encrypted_content } = item
2149 {
2150 compacted_llm_messages.push(LlmMessage {
2151 role: LlmMessageRole::System,
2152 content: LlmMessageContent::Text(format!(
2153 "[COMPACTED_CONTEXT:{encrypted_content}]"
2154 )),
2155 tool_calls: None,
2156 tool_call_id: None,
2157 phase: None,
2158 thinking: None,
2159 thinking_signature: None,
2160 });
2161 }
2162 }
2163
2164 llm_messages_for_call =
2165 crate::tool_call_integrity::retain_complete_llm_tool_exchanges(
2166 compacted_llm_messages,
2167 );
2168
2169 let step_duration = step_start.elapsed().as_millis() as u64;
2170 strategies_used.push("native".to_string());
2171 steps.push(CompactionStepData {
2172 strategy: "native".to_string(),
2173 messages_after: llm_messages_for_call.len(),
2174 duration_ms: step_duration,
2175 });
2176
2177 tracing::info!(
2178 session_id = %session_id,
2179 duration_ms = step_duration,
2180 messages_after = llm_messages_for_call.len(),
2181 "ReasonAtom: native compaction applied"
2182 );
2183 }
2184 Ok(None) | Err(_) => {
2185 tracing::warn!(
2186 session_id = %session_id,
2187 "ReasonAtom: native compaction unavailable, continuing cascade"
2188 );
2189 }
2190 }
2191 }
2192
2193 if run_summarization && !strategies_used.contains(&"native".to_string()) {
2196 use crate::capabilities::{
2197 build_summarization_prompt, compose_summary_with_recent,
2198 format_messages_for_summarization,
2199 };
2200
2201 let step_start = Instant::now();
2202 let conversation_msgs = if has_system_prompt {
2203 &llm_messages_for_call[1..]
2204 } else {
2205 &llm_messages_for_call[..]
2206 };
2207
2208 let keep_recent = 10.min(conversation_msgs.len());
2210 let to_summarize =
2211 &conversation_msgs[..conversation_msgs.len() - keep_recent];
2212 let recent = &conversation_msgs[conversation_msgs.len() - keep_recent..];
2213
2214 if !to_summarize.is_empty() {
2215 let summary_prompt = build_summarization_prompt(&config.summarization);
2216 let messages_text = format_messages_for_summarization(to_summarize);
2217
2218 let summary_messages = vec![
2220 LlmMessage {
2221 role: LlmMessageRole::System,
2222 content: LlmMessageContent::Text(summary_prompt),
2223 tool_calls: None,
2224 tool_call_id: None,
2225 phase: None,
2226 thinking: None,
2227 thinking_signature: None,
2228 },
2229 LlmMessage {
2230 role: LlmMessageRole::User,
2231 content: LlmMessageContent::Text(messages_text),
2232 tool_calls: None,
2233 tool_call_id: None,
2234 phase: None,
2235 thinking: None,
2236 thinking_signature: None,
2237 },
2238 ];
2239
2240 let summary_config = crate::driver_registry::LlmCallConfig {
2241 speed: None,
2242 verbosity: None,
2243 model: config
2244 .summarization
2245 .model
2246 .clone()
2247 .unwrap_or_else(|| runtime_agent.model.clone()),
2248 temperature: Some(0.0),
2249 max_tokens: Some(2000),
2250 tools: vec![],
2251 reasoning_effort: None,
2252 metadata: HashMap::new(),
2253 previous_response_id: None,
2254 tool_search: None,
2255 prompt_cache: None,
2256 openrouter_routing: None,
2257 parallel_tool_calls: None,
2258 volatile_suffix_len: 0,
2259 };
2260
2261 match chat_driver
2262 .chat_completion(summary_messages, &summary_config)
2263 .await
2264 {
2265 Ok(response) => {
2266 let summary_text = response.text;
2267 let system_message =
2268 has_system_prompt.then(|| llm_messages_for_call[0].clone());
2269 llm_messages_for_call = compose_summary_with_recent(
2270 system_message,
2271 &summary_text,
2272 recent,
2273 );
2274
2275 let step_duration = step_start.elapsed().as_millis() as u64;
2276 strategies_used.push("summarization".to_string());
2277 steps.push(CompactionStepData {
2278 strategy: "summarization".to_string(),
2279 messages_after: llm_messages_for_call.len(),
2280 duration_ms: step_duration,
2281 });
2282
2283 tracing::info!(
2284 session_id = %session_id,
2285 duration_ms = step_duration,
2286 messages_after = llm_messages_for_call.len(),
2287 "ReasonAtom: summarization applied"
2288 );
2289 }
2290 Err(e) => {
2291 tracing::warn!(
2292 session_id = %session_id,
2293 error = %e,
2294 "ReasonAtom: summarization failed, continuing"
2295 );
2296 }
2297 }
2298 }
2299 }
2300
2301 if strategies_used.is_empty()
2305 || llm_messages_for_call.len() > messages_before / 2
2306 {
2307 use crate::capabilities::aggressive_trim;
2308 let step_start = Instant::now();
2309 let estimated_total =
2311 crate::capabilities::estimate_total_tokens(&llm_messages_for_call);
2312 let target = estimated_total / 2;
2313 let trimmed =
2314 aggressive_trim(&llm_messages_for_call, target, has_system_prompt);
2315 if trimmed.len() < llm_messages_for_call.len() {
2316 llm_messages_for_call = trimmed;
2317 let step_duration = step_start.elapsed().as_millis() as u64;
2318 strategies_used.push("aggressive_trim".to_string());
2319 steps.push(CompactionStepData {
2320 strategy: "aggressive_trim".to_string(),
2321 messages_after: llm_messages_for_call.len(),
2322 duration_ms: step_duration,
2323 });
2324 tracing::info!(
2325 session_id = %session_id,
2326 messages_after = llm_messages_for_call.len(),
2327 "ReasonAtom: aggressive trim applied (last resort)"
2328 );
2329 }
2330 }
2331
2332 let cascade_duration = cascade_start.elapsed().as_millis() as u64;
2333 let messages_after = llm_messages_for_call.len();
2334
2335 let strategy_used = if strategies_used.is_empty() {
2337 "none".to_string()
2338 } else {
2339 strategies_used.join("+")
2340 };
2341
2342 let _ = self
2343 .event_emitter
2344 .emit(EventRequest::new(
2345 session_id,
2346 streaming_event_context.clone(),
2347 ContextCompactedData {
2348 strategy_used: strategy_used.clone(),
2349 messages_before,
2350 messages_after,
2351 duration_ms: cascade_duration,
2352 steps,
2353 },
2354 ))
2355 .await;
2356
2357 tracing::info!(
2358 session_id = %session_id,
2359 strategy = %strategy_used,
2360 messages_before,
2361 messages_after,
2362 duration_ms = cascade_duration,
2363 "ReasonAtom: compaction cascade completed, retrying LLM call"
2364 );
2365
2366 chat_driver
2367 .chat_completion_stream(llm_messages_for_call.clone(), &llm_config)
2368 .await?
2369 }
2370 Err(e) => return Err(e),
2371 };
2372
2373 let mut text = String::new();
2374 let mut thinking = String::new();
2375 let mut thinking_signature: Option<String> = None;
2376 let mut tool_calls = Vec::new();
2377 let mut completion_metadata: Option<LlmCompletionMetadata> = None;
2378 let mut stream_has_output = false;
2379 let mut pending_delta = String::new();
2380 let mut pending_thinking_delta = String::new();
2381 let mut last_delta_emit = Instant::now();
2382 let mut last_thinking_delta_emit = Instant::now();
2383 let mut time_to_first_token_ms: Option<u64> = None;
2384
2385 let stall_timeout = self
2387 .provider_stall_timeout
2388 .unwrap_or(std::time::Duration::from_secs(120));
2389 let mut stall_sleep = Box::pin(tokio::time::sleep(stall_timeout));
2390 let mut keepalive_ticker = tokio::time::interval(std::time::Duration::from_secs(12));
2391 keepalive_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2392 keepalive_ticker.tick().await; let mut last_stream_heartbeat = Instant::now();
2394 let mut last_token_at_unix: u64 = unix_now_secs();
2399
2400 loop {
2401 let event = tokio::select! {
2402 biased;
2403 next = stream.next() => match next {
2404 Some(e) => e,
2405 None => break,
2406 },
2407 _ = &mut stall_sleep => {
2408 tracing::warn!(
2409 session_id = %session_id,
2410 turn_id = %context.turn_id,
2411 stall_secs = stall_timeout.as_secs(),
2412 "ReasonAtom: provider stream stall timeout"
2413 );
2414 return Err(AgentLoopError::llm(format!(
2415 "provider stream stall: no tokens for {}s",
2416 stall_timeout.as_secs()
2417 )));
2418 },
2419 _ = keepalive_ticker.tick() => {
2420 if let Some(ref hb) = self.stream_heartbeater {
2421 hb.heartbeat(crate::traits::StreamProgress {
2422 accumulated_len: text.len() + thinking.len(),
2423 last_delta_at: last_token_at_unix,
2424 })
2425 .await;
2426 last_stream_heartbeat = Instant::now();
2427 }
2428 continue;
2429 },
2430 };
2431 let event = event?;
2432 let advances_stall_deadline = stream_event_advances_stall_deadline(&event);
2433 if advances_stall_deadline {
2434 stall_sleep
2435 .as_mut()
2436 .reset(tokio::time::Instant::now() + stall_timeout);
2437 last_token_at_unix = unix_now_secs();
2438 }
2439 match event {
2440 LlmStreamEvent::TextDelta(delta) => {
2441 if delta.is_empty() {
2442 continue;
2443 }
2444 stream_has_output = true;
2445 if time_to_first_token_ms.is_none() {
2447 let ttft = llm_start.elapsed().as_millis() as u64;
2448 time_to_first_token_ms = Some(ttft);
2449 tracing::info!(
2450 session_id = %session_id,
2451 time_to_first_token_ms = ttft,
2452 "ReasonAtom: received first token from LLM"
2453 );
2454 }
2455 text.push_str(&delta);
2456 pending_delta.push_str(&delta);
2457
2458 if !armed_guardrails.is_empty()
2465 && let Some(t) =
2466 evaluate_guardrails(&mut armed_guardrails, &text, &delta)
2467 {
2468 tracing::warn!(
2469 session_id = %session_id,
2470 turn_id = %context.turn_id,
2471 guardrail_capability_id = %t.capability_id,
2472 guardrail_id = %t.guardrail_id,
2473 reason_code = %t.block.reason_code,
2474 "ReasonAtom: output guardrail tripped, replacing assistant message"
2475 );
2476 pending_delta.clear();
2477 tripped = Some(t);
2478 break;
2479 }
2480
2481 if !buffer_output_deltas
2483 && last_delta_emit.elapsed().as_millis() as u64
2484 >= DELTA_BATCH_INTERVAL_MS
2485 && !pending_delta.is_empty()
2486 {
2487 if let Err(e) = self
2488 .event_emitter
2489 .emit(EventRequest::new(
2490 session_id,
2491 streaming_event_context.clone(),
2492 OutputMessageDeltaData {
2493 turn_id: context.turn_id,
2494 message_id: output_message_id,
2495 delta: pending_delta.clone(),
2496 accumulated: text.clone(),
2497 phase: streamed_phase,
2498 },
2499 ))
2500 .await
2501 {
2502 tracing::warn!(
2503 session_id = %session_id,
2504 error = %e,
2505 "ReasonAtom: failed to emit output.message.delta event"
2506 );
2507 }
2508 pending_delta.clear();
2509 last_delta_emit = Instant::now();
2510 }
2511 }
2512 LlmStreamEvent::ThinkingDelta(delta) => {
2513 if delta.is_empty() {
2514 continue;
2515 }
2516 stream_has_output = true;
2517 if let Some(t) = append_guarded_thinking_delta(
2518 &mut armed_guardrails,
2519 &mut thinking,
2520 &mut pending_thinking_delta,
2521 &delta,
2522 ) {
2523 tracing::warn!(
2524 session_id = %session_id,
2525 guardrail_capability_id = %t.capability_id,
2526 guardrail_id = %t.guardrail_id,
2527 "ReasonAtom: output guardrail tripped on thinking stream, replacing assistant message"
2528 );
2529 tripped = Some(t);
2530 break;
2531 }
2532 tracing::debug!(
2533 session_id = %session_id,
2534 delta_len = delta.len(),
2535 total_thinking_len = thinking.len(),
2536 "ReasonAtom: received ThinkingDelta from LLM"
2537 );
2538
2539 if last_thinking_delta_emit.elapsed().as_millis() as u64
2541 >= DELTA_BATCH_INTERVAL_MS
2542 && !pending_thinking_delta.is_empty()
2543 {
2544 if let Err(e) = self
2545 .event_emitter
2546 .emit(EventRequest::new(
2547 session_id,
2548 streaming_event_context.clone(),
2549 ReasonThinkingDeltaData {
2550 turn_id: context.turn_id,
2551 delta: pending_thinking_delta.clone(),
2552 accumulated: thinking.clone(),
2553 },
2554 ))
2555 .await
2556 {
2557 tracing::warn!(
2558 session_id = %session_id,
2559 error = %e,
2560 "ReasonAtom: failed to emit reason.thinking.delta event"
2561 );
2562 }
2563 pending_thinking_delta.clear();
2564 last_thinking_delta_emit = Instant::now();
2565 }
2566 }
2567 LlmStreamEvent::ThinkingSignature(signature) => {
2568 stream_has_output = true;
2569 tracing::debug!(
2571 session_id = %session_id,
2572 signature_len = signature.len(),
2573 "ReasonAtom: received ThinkingSignature from LLM"
2574 );
2575 thinking_signature = Some(signature);
2576 }
2577 LlmStreamEvent::ReasonItem {
2578 provider,
2579 model,
2580 item_id,
2581 encrypted_content,
2582 summary,
2583 token_count,
2584 } => {
2585 stream_has_output = true;
2586 if let Some(sig) = encrypted_content.as_ref() {
2592 tracing::debug!(
2593 session_id = %session_id,
2594 signature_len = sig.len(),
2595 provider = %provider,
2596 item_id = %item_id,
2597 "ReasonAtom: captured encrypted reasoning content from ReasonItem"
2598 );
2599 thinking_signature = Some(sig.clone());
2600 }
2601 if let Err(e) = self
2602 .event_emitter
2603 .emit(EventRequest::new(
2604 session_id,
2605 streaming_event_context.clone(),
2606 ReasonItemData {
2607 turn_id: context.turn_id,
2608 provider,
2609 model,
2610 item_id,
2611 encrypted_content,
2612 summary,
2613 token_count,
2614 },
2615 ))
2616 .await
2617 {
2618 tracing::warn!(
2619 session_id = %session_id,
2620 error = %e,
2621 "ReasonAtom: failed to emit reason.item event"
2622 );
2623 }
2624 }
2625 LlmStreamEvent::ToolCalls(calls) => {
2626 stream_has_output |= !calls.is_empty();
2627 tool_calls = calls;
2628 }
2629 LlmStreamEvent::MessagePhase(phase) => {
2630 streamed_phase = crate::message::ExecutionPhase::refine_streamed_hint(
2639 streamed_phase,
2640 phase,
2641 );
2642 }
2643 LlmStreamEvent::Done(metadata) => {
2644 if !buffer_output_deltas
2648 && !pending_delta.is_empty()
2649 && let Err(e) = self
2650 .event_emitter
2651 .emit(EventRequest::new(
2652 session_id,
2653 streaming_event_context.clone(),
2654 OutputMessageDeltaData {
2655 turn_id: context.turn_id,
2656 message_id: output_message_id,
2657 delta: pending_delta.clone(),
2658 accumulated: text.clone(),
2659 phase: streamed_phase,
2660 },
2661 ))
2662 .await
2663 {
2664 tracing::warn!(
2665 session_id = %session_id,
2666 error = %e,
2667 "ReasonAtom: failed to emit final output.message.delta event"
2668 );
2669 }
2670
2671 if !pending_thinking_delta.is_empty()
2673 && let Err(e) = self
2674 .event_emitter
2675 .emit(EventRequest::new(
2676 session_id,
2677 streaming_event_context.clone(),
2678 ReasonThinkingDeltaData {
2679 turn_id: context.turn_id,
2680 delta: pending_thinking_delta.clone(),
2681 accumulated: thinking.clone(),
2682 },
2683 ))
2684 .await
2685 {
2686 tracing::warn!(
2687 session_id = %session_id,
2688 error = %e,
2689 "ReasonAtom: failed to emit final reason.thinking.delta event"
2690 );
2691 }
2692
2693 if !thinking.is_empty()
2695 && let Err(e) = self
2696 .event_emitter
2697 .emit(EventRequest::new(
2698 session_id,
2699 streaming_event_context.clone(),
2700 ReasonThinkingCompletedData {
2701 turn_id: context.turn_id,
2702 thinking: thinking.clone(),
2703 },
2704 ))
2705 .await
2706 {
2707 tracing::warn!(
2708 session_id = %session_id,
2709 error = %e,
2710 "ReasonAtom: failed to emit reason.thinking.completed event"
2711 );
2712 }
2713 completion_metadata = Some(*metadata);
2714 break;
2715 }
2716 LlmStreamEvent::Error(err) => {
2717 let has_partial_output = !tool_calls.is_empty() || !text.is_empty();
2722
2723 if has_partial_output {
2724 tracing::warn!(
2725 session_id = %session_id,
2726 error = %err,
2727 tool_call_count = tool_calls.len(),
2728 text_len = text.len(),
2729 "ReasonAtom: trailing stream error after valid output — treating as partial success"
2730 );
2731 break;
2735 }
2736
2737 if should_retry_stream_error(
2738 &err,
2739 stream_retry_metadata.attempts,
2740 retry_config.max_retries,
2741 stream_has_output,
2742 ) {
2743 let wait_duration =
2744 retry_config.calculate_backoff(stream_retry_metadata.attempts);
2745 tracing::warn!(
2746 session_id = %session_id,
2747 turn_id = %context.turn_id,
2748 attempt = stream_retry_metadata.attempts + 1,
2749 max_retries = retry_config.max_retries,
2750 wait_secs = wait_duration.as_secs_f64(),
2751 error_code = err.code.as_deref().unwrap_or("none"),
2752 error_status = err.status,
2753 error = %err,
2754 "ReasonAtom: transient stream error before output, retrying"
2755 );
2756 stream_retry_metadata.record_retry(wait_duration, None);
2757 tokio::time::sleep(wait_duration).await;
2758 continue 'stream_attempt;
2759 }
2760
2761 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2763 let event_context = EventContext::from_atom_context(context).with_span(
2764 trace_id.to_string(),
2765 Uuid::now_v7().to_string(),
2766 Some(reason_span_id.to_string()),
2767 );
2768 let tools_summary: Vec<ToolDefinitionSummary> =
2769 runtime_agent.tools.iter().map(|t| t.into()).collect();
2770 let generation_data = LlmGenerationData::failure(
2771 messages_for_event.clone(),
2772 tools_summary,
2773 runtime_agent.model.clone(),
2774 Some(model_with_provider.provider_type.to_string()),
2775 err.to_string(),
2776 Some(llm_duration_ms),
2777 time_to_first_token_ms,
2778 );
2779 let _ = self
2780 .event_emitter
2781 .emit(EventRequest::new(
2782 session_id,
2783 event_context,
2784 generation_data,
2785 ))
2786 .await;
2787 return Err(AgentLoopError::llm_kind(err.kind(), err.to_string()));
2788 }
2789 }
2790 if last_stream_heartbeat.elapsed().as_millis() as u64 >= 5_000
2793 && let Some(ref hb) = self.stream_heartbeater
2794 {
2795 hb.heartbeat(crate::traits::StreamProgress {
2796 accumulated_len: text.len() + thinking.len(),
2797 last_delta_at: last_token_at_unix,
2798 })
2799 .await;
2800 last_stream_heartbeat = Instant::now();
2801 }
2802 }
2803 if let Some(metadata) = completion_metadata.as_mut() {
2804 metadata.retry_metadata =
2805 merge_retry_metadata(metadata.retry_metadata.take(), &stream_retry_metadata);
2806 }
2807
2808 break 'stream_attempt (
2809 text,
2810 thinking,
2811 thinking_signature,
2812 tool_calls,
2813 completion_metadata,
2814 time_to_first_token_ms,
2815 pending_delta,
2816 );
2817 };
2818 let (mut text, mut thinking, thinking_signature, mut tool_calls) =
2819 (text, thinking, thinking_signature, tool_calls);
2820
2821 if tripped.is_none() && !post_output_providers.is_empty() && !text.is_empty() {
2830 let ctx = PostGenerationOutputContext {
2831 system_prompt: &runtime_agent.system_prompt,
2832 message_text: &text,
2833 utility_llm_service: self.utility_llm_service.as_ref(),
2834 };
2835 tripped = evaluate_post_generation_guardrails(&post_output_providers, &ctx).await;
2836 }
2837
2838 if buffer_output_deltas
2841 && tripped.is_none()
2842 && !pending_delta.is_empty()
2843 && let Err(e) = self
2844 .event_emitter
2845 .emit(EventRequest::new(
2846 session_id,
2847 streaming_event_context.clone(),
2848 OutputMessageDeltaData {
2849 turn_id: context.turn_id,
2850 message_id: output_message_id,
2851 delta: pending_delta.clone(),
2852 accumulated: text.clone(),
2853 phase: streamed_phase,
2854 },
2855 ))
2856 .await
2857 {
2858 tracing::warn!(
2859 session_id = %session_id,
2860 error = %e,
2861 "ReasonAtom: failed to emit guarded output.message.delta event"
2862 );
2863 }
2864
2865 if let Some(ref t) = tripped {
2871 let replaced_event_context = EventContext::from_atom_context(context).with_span(
2872 trace_id.to_string(),
2873 Uuid::now_v7().to_string(),
2874 Some(reason_span_id.to_string()),
2875 );
2876 if let Err(e) = self
2877 .event_emitter
2878 .emit(EventRequest::new(
2879 session_id,
2880 replaced_event_context,
2881 OutputMessageReplacedData {
2882 turn_id: context.turn_id,
2883 message_id: output_message_id,
2884 guardrail_capability_id: t.capability_id.clone(),
2885 guardrail_id: t.guardrail_id.clone(),
2886 reason_code: t.block.reason_code.clone(),
2887 replacement: t.block.replacement.clone(),
2888 },
2889 ))
2890 .await
2891 {
2892 tracing::warn!(
2893 session_id = %session_id,
2894 error = %e,
2895 "ReasonAtom: failed to emit output.message.replaced event"
2896 );
2897 }
2898 text = t.block.replacement.clone();
2899 tool_calls.clear();
2900 thinking.clear();
2901 }
2902
2903 if !tool_calls.is_empty() {
2911 self.repair_malformed_tool_calls(
2912 session_id,
2913 context,
2914 &resolved_capability_configs,
2915 &runtime_agent.tools,
2916 &mut tool_calls,
2917 iteration,
2918 )
2919 .await;
2920 }
2921
2922 let llm_duration_ms = llm_start.elapsed().as_millis() as u64;
2923
2924 let response_id = completion_metadata
2926 .as_ref()
2927 .and_then(|meta| meta.response_id.clone());
2928
2929 let usage = completion_metadata.as_ref().and_then(|meta| {
2937 match (meta.prompt_tokens, meta.completion_tokens) {
2938 (Some(input), Some(output)) => {
2939 let actual_cost_usd = meta.provider_cost_usd;
2940 let estimated_cost_usd = crate::model_profiles::estimate_cost_usd(
2941 &model_with_provider.provider_type,
2942 &runtime_agent.model,
2943 input,
2944 output,
2945 meta.cache_read_tokens.unwrap_or(0),
2946 meta.cache_creation_tokens.unwrap_or(0),
2947 );
2948 Some(
2949 TokenUsage::with_cache(
2950 input,
2951 output,
2952 meta.cache_read_tokens,
2953 meta.cache_creation_tokens,
2954 )
2955 .with_cost(actual_cost_usd, estimated_cost_usd),
2956 )
2957 }
2958 _ => None,
2959 }
2960 });
2961
2962 let event_context = EventContext::from_atom_context(context).with_span(
2964 trace_id.to_string(),
2965 Uuid::now_v7().to_string(),
2966 Some(reason_span_id.to_string()),
2967 );
2968 let tools_summary: Vec<ToolDefinitionSummary> =
2969 runtime_agent.tools.iter().map(|t| t.into()).collect();
2970 let finish_reasons = if !tool_calls.is_empty() {
2972 Some(vec!["tool_calls".to_string()])
2973 } else {
2974 Some(vec!["stop".to_string()])
2975 };
2976 let retry_info = completion_metadata
2978 .as_ref()
2979 .and_then(|meta| meta.retry_metadata.as_ref())
2980 .filter(|rm| rm.had_retries())
2981 .map(|rm| LlmRetryInfo {
2982 attempts: rm.attempts,
2983 total_wait_ms: rm.total_retry_wait.as_millis() as u64,
2984 });
2985 let mut generation_data = LlmGenerationData::success_with_retry(
2987 messages_for_event.clone(),
2988 tools_summary,
2989 Some(text.clone()).filter(|s| !s.is_empty()),
2990 tool_calls.clone(),
2991 runtime_agent.model.clone(),
2992 Some(model_with_provider.provider_type.to_string()),
2993 usage.clone(),
2994 Some(llm_duration_ms),
2995 time_to_first_token_ms,
2996 finish_reasons,
2997 response_id.clone(),
2998 retry_info,
2999 );
3000
3001 if let Some(info) = compaction_info {
3003 generation_data = generation_data.with_compaction(info);
3004 }
3005
3006 if let Some(request_options) =
3007 build_request_options(&llm_config, &model_with_provider.provider_type.to_string())
3008 {
3009 generation_data = generation_data.with_request_options(request_options);
3010 }
3011
3012 if let Err(e) = self
3013 .event_emitter
3014 .emit(EventRequest::new(
3015 session_id,
3016 event_context,
3017 generation_data,
3018 ))
3019 .await
3020 {
3021 tracing::warn!(
3022 session_id = %session_id,
3023 error = %e,
3024 "ReasonAtom: failed to emit llm.generation event"
3025 );
3026 }
3027
3028 let mut metadata = std::collections::HashMap::new();
3030 metadata.insert(
3031 "model".to_string(),
3032 serde_json::Value::String(runtime_agent.model.clone()),
3033 );
3034 if let Some(ref effort) = reasoning_effort {
3035 metadata.insert(
3036 "reasoning_effort".to_string(),
3037 serde_json::Value::String(effort.clone()),
3038 );
3039 }
3040 metadata.insert(
3047 "provider".to_string(),
3048 serde_json::Value::String(model_with_provider.provider_type.to_string()),
3049 );
3050 if let Some(ref rid) = response_id {
3051 metadata.insert(
3052 "response_id".to_string(),
3053 serde_json::Value::String(rid.clone()),
3054 );
3055 }
3056
3057 let text = crate::capabilities::strip_leading_timestamp_annotations(&text);
3062 let has_tool_calls = !tool_calls.is_empty();
3063 let mut assistant_message = if has_tool_calls {
3064 Message::assistant_with_tools(&text, tool_calls.clone())
3065 } else {
3066 Message::assistant(&text)
3067 }
3068 .with_id(output_message_id);
3069 assistant_message.phase = completion_metadata
3073 .as_ref()
3074 .and_then(|meta| meta.phase.as_deref())
3075 .and_then(crate::message::ExecutionPhase::from_provider_str)
3076 .or_else(|| {
3077 Some(crate::message::ExecutionPhase::from_has_tool_calls(
3078 has_tool_calls,
3079 ))
3080 });
3081 assistant_message.metadata = Some(metadata);
3082 if !thinking.is_empty() {
3085 assistant_message.thinking = Some(thinking.clone());
3086 assistant_message.thinking_signature = thinking_signature.clone();
3087 }
3088 let message_event_context = EventContext::from_atom_context(context).with_span(
3091 trace_id.to_string(),
3092 Uuid::now_v7().to_string(),
3093 Some(reason_span_id.to_string()),
3094 );
3095 let mut output_message_data = OutputMessageCompletedData::new(assistant_message);
3096 if let Some(ref u) = usage {
3097 output_message_data = output_message_data.with_usage(u.clone());
3098 }
3099 self.event_emitter
3100 .emit(EventRequest::new(
3101 session_id,
3102 message_event_context,
3103 output_message_data,
3104 ))
3105 .await?;
3106
3107 tracing::info!(
3108 session_id = %session_id,
3109 turn_id = %context.turn_id,
3110 has_tool_calls = %has_tool_calls,
3111 tool_count = %tool_calls.len(),
3112 "ReasonAtom: LLM call completed"
3113 );
3114
3115 Ok(ReasonResult {
3116 success: true,
3117 text,
3118 tool_calls,
3119 has_tool_calls,
3120 tool_definitions: runtime_agent.tools.clone(),
3121 max_iterations: runtime_agent.max_iterations,
3122 error: None,
3123 user_facing_error: None,
3124 error_disclosure: None,
3125 usage,
3126 output_message_id: Some(output_message_id),
3127 time_to_first_token_ms,
3128 response_id,
3129 locale: resolved_locale,
3130 network_access: runtime_agent.network_access.clone(),
3131 parallel_tool_calls: runtime_agent.parallel_tool_calls,
3132 })
3133 }
3134
3135 async fn finalize_partial_stream(
3140 &self,
3141 session_id: SessionId,
3142 context: &AtomContext,
3143 partial: PartialStreamState,
3144 iteration: u32,
3145 max_iterations: usize,
3146 tool_definitions: &[ToolDefinition],
3147 ) -> Result<ReasonResult> {
3148 let event_context = EventContext::from_atom_context(context);
3149 let turn_id = context.turn_id;
3150 let message_id = partial.message_id;
3151
3152 let _ = self
3154 .event_emitter
3155 .emit(EventRequest::new(
3156 session_id,
3157 event_context.clone(),
3158 OutputMessageStartedData {
3159 turn_id,
3160 message_id,
3161 model: None,
3162 iteration: Some(iteration),
3163 phase: None,
3166 },
3167 ))
3168 .await;
3169
3170 let accumulated =
3173 crate::capabilities::strip_leading_timestamp_annotations(&partial.accumulated);
3174 let assistant_message = Message::assistant(&accumulated).with_id(message_id);
3175 let output_message_id = message_id;
3176 self.event_emitter
3177 .emit(EventRequest::new(
3178 session_id,
3179 event_context.clone(),
3180 OutputMessageCompletedData::new(assistant_message),
3181 ))
3182 .await?;
3183
3184 let accumulated_len = accumulated.len();
3186 let _ = self
3187 .event_emitter
3188 .emit(EventRequest::new(
3189 session_id,
3190 event_context.clone(),
3191 ReasonRecoveredData {
3192 turn_id,
3193 mode: RecoveryMode::Finalize,
3194 accumulated_len,
3195 },
3196 ))
3197 .await;
3198
3199 tracing::info!(
3200 session_id = %session_id,
3201 turn_id = %turn_id,
3202 accumulated_len,
3203 "ReasonAtom: finalized partial stream from persisted accumulated text"
3204 );
3205
3206 Ok(ReasonResult {
3207 success: true,
3208 text: accumulated,
3209 tool_calls: vec![],
3210 has_tool_calls: false,
3211 tool_definitions: tool_definitions.to_vec(),
3212 max_iterations,
3213 error: None,
3214 user_facing_error: None,
3215 error_disclosure: None,
3216 usage: None,
3217 output_message_id: Some(output_message_id),
3218 time_to_first_token_ms: None,
3219 response_id: None,
3220 locale: None,
3221 network_access: None,
3222 parallel_tool_calls: None,
3224 })
3225 }
3226
3227 fn create_chat_driver(
3230 &self,
3231 model: &ResolvedModel,
3232 ) -> Result<crate::driver_registry::BoxedChatDriver> {
3233 self.driver_registry
3234 .create_chat_driver(&crate::llm_conversions::provider_config_from_resolved_model(model))
3235 }
3236
3237 async fn resolve_images(&self, messages: &[Message]) -> HashMap<Uuid, ResolvedImage> {
3248 let mut resolved = HashMap::new();
3249
3250 let resolver = match &self.image_resolver {
3252 Some(r) => r,
3253 None => return resolved,
3254 };
3255
3256 let image_ids: Vec<Uuid> = messages
3258 .iter()
3259 .flat_map(crate::llm_conversions::extract_image_file_ids)
3260 .collect::<std::collections::HashSet<_>>()
3261 .into_iter()
3262 .collect();
3263
3264 if image_ids.is_empty() {
3265 return resolved;
3266 }
3267
3268 tracing::debug!(
3269 image_count = image_ids.len(),
3270 "ReasonAtom: resolving image_file references"
3271 );
3272
3273 for image_id in image_ids {
3275 match resolver.resolve_image(image_id).await {
3276 Ok(Some(image)) => {
3277 resolved.insert(image_id, image);
3278 }
3279 Ok(None) => {
3280 tracing::warn!(
3281 image_id = %image_id,
3282 "ReasonAtom: image not found during resolution"
3283 );
3284 }
3285 Err(e) => {
3286 tracing::warn!(
3287 image_id = %image_id,
3288 error = %e,
3289 "ReasonAtom: failed to resolve image"
3290 );
3291 }
3292 }
3293 }
3294
3295 tracing::debug!(
3296 resolved_count = resolved.len(),
3297 "ReasonAtom: image resolution complete"
3298 );
3299
3300 resolved
3301 }
3302}
3303
3304#[cfg(test)]
3309mod tests {
3310 use super::*;
3311 use crate::driver_registry::{LlmCallConfig, PromptCacheConfig, PromptCacheStrategy};
3312 use std::collections::HashMap;
3313
3314 struct BlockWhenDeltaContains {
3315 needle: &'static str,
3316 }
3317
3318 impl crate::output_guardrail::OutputGuardrailRun for BlockWhenDeltaContains {
3319 fn check(
3320 &mut self,
3321 _accumulated: &str,
3322 delta: &str,
3323 ) -> crate::output_guardrail::GuardrailDecision {
3324 if delta.contains(self.needle) {
3325 crate::output_guardrail::GuardrailDecision::block("test_leak", "[blocked]")
3326 } else {
3327 crate::output_guardrail::GuardrailDecision::Pass
3328 }
3329 }
3330 }
3331
3332 fn test_armed_guardrail() -> ArmedGuardrail {
3333 ArmedGuardrail {
3334 capability_id: "test_capability".to_string(),
3335 guardrail_id: "test_guardrail".to_string(),
3336 run: Box::new(BlockWhenDeltaContains { needle: "secret" }),
3337 }
3338 }
3339
3340 #[test]
3341 fn test_append_guarded_thinking_delta_blocks_before_pending_emit() {
3342 let mut guardrails = vec![test_armed_guardrail()];
3343 let mut thinking = "safe ".to_string();
3344 let mut pending = "safe ".to_string();
3345
3346 let tripped = append_guarded_thinking_delta(
3347 &mut guardrails,
3348 &mut thinking,
3349 &mut pending,
3350 "secret instructions",
3351 )
3352 .expect("thinking delta should trip guardrail");
3353
3354 assert_eq!(tripped.capability_id, "test_capability");
3355 assert_eq!(tripped.guardrail_id, "test_guardrail");
3356 assert_eq!(tripped.block.reason_code, "test_leak");
3357 assert_eq!(tripped.block.replacement, "[blocked]");
3358 assert_eq!(thinking, "safe secret instructions");
3359 assert!(pending.is_empty());
3360 }
3361
3362 #[test]
3363 fn test_append_guarded_thinking_delta_allows_safe_pending_emit() {
3364 let mut guardrails = vec![test_armed_guardrail()];
3365 let mut thinking = String::new();
3366 let mut pending = String::new();
3367
3368 let tripped = append_guarded_thinking_delta(
3369 &mut guardrails,
3370 &mut thinking,
3371 &mut pending,
3372 "ordinary reasoning",
3373 );
3374
3375 assert!(tripped.is_none());
3376 assert_eq!(thinking, "ordinary reasoning");
3377 assert_eq!(pending, "ordinary reasoning");
3378 }
3379
3380 #[test]
3381 fn test_reason_result_default() {
3382 let result = ReasonResult::default();
3383 assert!(!result.success);
3384 assert!(result.text.is_empty());
3385 assert!(result.tool_calls.is_empty());
3386 assert!(!result.has_tool_calls);
3387 assert_eq!(result.max_iterations, 0);
3389 }
3390
3391 #[test]
3392 fn test_reason_result_serde_default() {
3393 let json = r#"{"success":true,"text":"","has_tool_calls":false}"#;
3395 let result: ReasonResult = serde_json::from_str(json).unwrap();
3396 assert_eq!(result.max_iterations, 500);
3397 }
3398
3399 #[test]
3400 fn test_capability_usage_snapshot_keeps_resolved_and_exposed_separate() {
3401 let registry = CapabilityRegistry::with_builtins();
3402 let tool = ToolDefinition::Builtin(crate::tool_types::BuiltinTool {
3403 name: "demo_tool".to_string(),
3404 display_name: None,
3405 description: "demo".to_string(),
3406 parameters: json!({"type": "object"}),
3407 policy: crate::tool_types::ToolPolicy::Auto,
3408 category: None,
3409 deferrable: crate::tool_types::DeferrablePolicy::default(),
3410 hints: crate::tool_types::ToolHints::default(),
3411 full_parameters: None,
3412 })
3413 .with_capability_attribution("cap:demo", Some("Demo Capability"));
3414
3415 let records = capability_usage_snapshot_records(
3416 ®istry,
3417 &[crate::AgentCapabilityConfig::new("current_time")],
3418 &[tool],
3419 );
3420
3421 assert!(records.iter().any(|record| {
3422 matches!(record.usage_kind, CapabilityUsageKind::Resolved)
3423 && record.capability_id == "current_time"
3424 && record.tool_name.is_none()
3425 }));
3426 assert!(records.iter().any(|record| {
3427 matches!(record.usage_kind, CapabilityUsageKind::Exposed)
3428 && record.capability_id == "cap:demo"
3429 && record.tool_name.as_deref() == Some("demo_tool")
3430 }));
3431 }
3432
3433 #[test]
3434 fn stream_stall_deadline_ignores_empty_keepalive_events() {
3435 assert!(!stream_event_advances_stall_deadline(
3436 &LlmStreamEvent::TextDelta(String::new())
3437 ));
3438 assert!(!stream_event_advances_stall_deadline(
3439 &LlmStreamEvent::ThinkingDelta(String::new())
3440 ));
3441 assert!(!stream_event_advances_stall_deadline(
3442 &LlmStreamEvent::ThinkingSignature("signature".to_string())
3443 ));
3444 assert!(!stream_event_advances_stall_deadline(
3445 &LlmStreamEvent::ReasonItem {
3446 provider: "openai".to_string(),
3447 model: None,
3448 item_id: "item_1".to_string(),
3449 encrypted_content: None,
3450 summary: vec![String::new()],
3451 token_count: Some(0),
3452 }
3453 ));
3454 }
3455
3456 #[test]
3457 fn stream_stall_deadline_advances_on_output_progress() {
3458 assert!(stream_event_advances_stall_deadline(
3459 &LlmStreamEvent::TextDelta("hello".to_string())
3460 ));
3461 assert!(stream_event_advances_stall_deadline(
3462 &LlmStreamEvent::ThinkingDelta("thinking".to_string())
3463 ));
3464 assert!(stream_event_advances_stall_deadline(
3465 &LlmStreamEvent::ReasonItem {
3466 provider: "openai".to_string(),
3467 model: Some("gpt-5.4".to_string()),
3468 item_id: "item_1".to_string(),
3469 encrypted_content: Some("encrypted".to_string()),
3470 summary: vec![],
3471 token_count: None,
3472 }
3473 ));
3474 assert!(stream_event_advances_stall_deadline(
3475 &LlmStreamEvent::ReasonItem {
3476 provider: "openai".to_string(),
3477 model: None,
3478 item_id: "item_2".to_string(),
3479 encrypted_content: None,
3480 summary: vec!["summary".to_string()],
3481 token_count: None,
3482 }
3483 ));
3484 assert!(stream_event_advances_stall_deadline(
3485 &LlmStreamEvent::ReasonItem {
3486 provider: "openai".to_string(),
3487 model: None,
3488 item_id: "item_3".to_string(),
3489 encrypted_content: None,
3490 summary: vec![],
3491 token_count: Some(1),
3492 }
3493 ));
3494 assert!(stream_event_advances_stall_deadline(
3495 &LlmStreamEvent::ToolCalls(vec![ToolCall {
3496 id: "call_1".to_string(),
3497 name: "demo".to_string(),
3498 arguments: json!({}),
3499 }])
3500 ));
3501 }
3502
3503 #[tokio::test]
3504 async fn test_repair_dangling_tool_calls_no_tool_calls() {
3505 use crate::events::EventContext;
3506 use crate::typed_id::SessionId;
3507 let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
3508 let emitter = crate::traits::NoopEventEmitter;
3509 let session_id = SessionId::new();
3510 let ctx = EventContext::empty();
3511 let patched =
3512 repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3513 .await;
3514 assert_eq!(patched.len(), 2);
3515 }
3516
3517 #[tokio::test]
3518 async fn test_repair_dangling_tool_calls_with_result() {
3519 use crate::events::EventContext;
3520 use crate::typed_id::SessionId;
3521 let tool_call = ToolCall {
3522 id: "call_123".to_string(),
3523 name: "get_weather".to_string(),
3524 arguments: serde_json::json!({"city": "NYC"}),
3525 };
3526
3527 let messages = vec![
3528 Message::user("What's the weather?"),
3529 Message::assistant_with_tools("Let me check", vec![tool_call]),
3530 Message::tool_result("call_123", Some(serde_json::json!({"temp": 72})), None),
3531 ];
3532
3533 let emitter = crate::traits::NoopEventEmitter;
3534 let session_id = SessionId::new();
3535 let ctx = EventContext::empty();
3536 let patched =
3537 repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3538 .await;
3539 assert_eq!(patched.len(), 3);
3540 }
3541
3542 #[tokio::test]
3543 async fn test_repair_dangling_tool_calls_missing_result_no_store() {
3544 use crate::events::EventContext;
3545 use crate::typed_id::SessionId;
3546 let tool_call = ToolCall {
3547 id: "call_456".to_string(),
3548 name: "search_web".to_string(),
3549 arguments: serde_json::json!({"query": "rust"}),
3550 };
3551
3552 let messages = vec![
3553 Message::user("Search for rust"),
3554 Message::assistant_with_tools("Searching...", vec![tool_call]),
3555 Message::user("Actually, never mind"),
3556 ];
3557
3558 let emitter = crate::traits::NoopEventEmitter;
3559 let session_id = SessionId::new();
3560 let ctx = EventContext::empty();
3561 let patched =
3562 repair_dangling_tool_calls(&messages, None, &emitter, session_id, &ctx, "turn_01")
3563 .await;
3564 assert_eq!(patched.len(), 4);
3566 assert_eq!(patched[2].role, MessageRole::ToolResult);
3567 assert_eq!(patched[2].tool_call_id(), Some("call_456"));
3568 }
3569
3570 #[tokio::test]
3571 async fn test_repair_dangling_tool_calls_settled_result_replayed() {
3572 use crate::events::EventContext;
3573 use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3574 use crate::typed_id::SessionId;
3575
3576 struct MockSettledStore;
3577 #[async_trait::async_trait]
3578 impl DurableToolResultStore for MockSettledStore {
3579 async fn try_claim_tool_call(
3580 &self,
3581 _: &str,
3582 _: &str,
3583 _: &str,
3584 _: &str,
3585 ) -> crate::error::Result<ToolCallClaimResult> {
3586 Ok(ToolCallClaimResult::Claimed {
3587 claim_token: uuid::Uuid::new_v4(),
3588 })
3589 }
3590 async fn settle_tool_call(
3591 &self,
3592 _: &str,
3593 _: &str,
3594 _: serde_json::Value,
3595 _: &str,
3596 _: uuid::Uuid,
3597 ) -> crate::error::Result<bool> {
3598 Ok(true)
3599 }
3600 async fn get_tool_call_status(
3601 &self,
3602 _turn_id: &str,
3603 _tool_call_id: &str,
3604 ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3605 Ok(Some(DurableToolCallStatus::Settled {
3606 result_json: serde_json::json!({
3607 "tool_call_id": "call_789",
3608 "result": {"answer": 42},
3609 "error": null,
3610 "images": null,
3611 "connection_required": null,
3612 "raw_output": null
3613 }),
3614 }))
3615 }
3616 }
3617
3618 let tool_call = ToolCall {
3619 id: "call_789".to_string(),
3620 name: "compute".to_string(),
3621 arguments: serde_json::json!({"x": 21}),
3622 };
3623 let messages = vec![
3624 Message::user("Compute"),
3625 Message::assistant_with_tools("Computing...", vec![tool_call]),
3626 ];
3627
3628 let store = MockSettledStore;
3629 let emitter = crate::traits::NoopEventEmitter;
3630 let session_id = SessionId::new();
3631 let ctx = EventContext::empty();
3632 let patched = repair_dangling_tool_calls(
3633 &messages,
3634 Some(&store as &dyn DurableToolResultStore),
3635 &emitter,
3636 session_id,
3637 &ctx,
3638 "turn_01",
3639 )
3640 .await;
3641
3642 assert_eq!(patched.len(), 3);
3644 assert_eq!(patched[2].role, MessageRole::ToolResult);
3645 assert_eq!(patched[2].tool_call_id(), Some("call_789"));
3646 }
3647
3648 #[tokio::test]
3649 async fn test_repair_dangling_tool_calls_interrupted_result_replayed() {
3650 use crate::events::EventContext;
3651 use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3652 use crate::typed_id::SessionId;
3653
3654 struct MockInterruptedStore;
3655 #[async_trait::async_trait]
3656 impl DurableToolResultStore for MockInterruptedStore {
3657 async fn try_claim_tool_call(
3658 &self,
3659 _: &str,
3660 _: &str,
3661 _: &str,
3662 _: &str,
3663 ) -> crate::error::Result<ToolCallClaimResult> {
3664 Ok(ToolCallClaimResult::Claimed {
3665 claim_token: uuid::Uuid::new_v4(),
3666 })
3667 }
3668 async fn settle_tool_call(
3669 &self,
3670 _: &str,
3671 _: &str,
3672 _: serde_json::Value,
3673 _: &str,
3674 _: uuid::Uuid,
3675 ) -> crate::error::Result<bool> {
3676 Ok(true)
3677 }
3678 async fn get_tool_call_status(
3679 &self,
3680 _turn_id: &str,
3681 _tool_call_id: &str,
3682 ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3683 Ok(Some(DurableToolCallStatus::Interrupted {
3684 result_json: None,
3685 }))
3686 }
3687 }
3688
3689 let tool_call = ToolCall {
3690 id: "call_int".to_string(),
3691 name: "slow_op".to_string(),
3692 arguments: serde_json::json!({}),
3693 };
3694 let messages = vec![
3695 Message::user("Do it"),
3696 Message::assistant_with_tools("Doing...", vec![tool_call]),
3697 ];
3698
3699 let store = MockInterruptedStore;
3700 let emitter = crate::traits::NoopEventEmitter;
3701 let session_id = SessionId::new();
3702 let ctx = EventContext::empty();
3703 let patched = repair_dangling_tool_calls(
3704 &messages,
3705 Some(&store as &dyn DurableToolResultStore),
3706 &emitter,
3707 session_id,
3708 &ctx,
3709 "turn_01",
3710 )
3711 .await;
3712
3713 assert_eq!(patched.len(), 3);
3714 let repair = &patched[2];
3715 assert_eq!(repair.role, MessageRole::ToolResult);
3716 assert_eq!(repair.tool_call_id(), Some("call_int"));
3717 let content = format!("{:?}", repair);
3719 assert!(
3720 content.contains("interrupted") || content.contains("not complete"),
3721 "expected interrupted message, got: {content}"
3722 );
3723 }
3724
3725 #[tokio::test]
3726 async fn test_repair_dangling_tool_calls_running_synthesized() {
3727 use crate::events::EventContext;
3728 use crate::traits::{DurableToolCallStatus, DurableToolResultStore, ToolCallClaimResult};
3729 use crate::typed_id::SessionId;
3730
3731 struct MockRunningStore;
3732 #[async_trait::async_trait]
3733 impl DurableToolResultStore for MockRunningStore {
3734 async fn try_claim_tool_call(
3735 &self,
3736 _: &str,
3737 _: &str,
3738 _: &str,
3739 _: &str,
3740 ) -> crate::error::Result<ToolCallClaimResult> {
3741 Ok(ToolCallClaimResult::Claimed {
3742 claim_token: uuid::Uuid::new_v4(),
3743 })
3744 }
3745 async fn settle_tool_call(
3746 &self,
3747 _: &str,
3748 _: &str,
3749 _: serde_json::Value,
3750 _: &str,
3751 _: uuid::Uuid,
3752 ) -> crate::error::Result<bool> {
3753 Ok(true)
3754 }
3755 async fn get_tool_call_status(
3756 &self,
3757 _turn_id: &str,
3758 _tool_call_id: &str,
3759 ) -> crate::error::Result<Option<DurableToolCallStatus>> {
3760 Ok(Some(DurableToolCallStatus::Running))
3761 }
3762 }
3763
3764 let tool_call = ToolCall {
3765 id: "call_run".to_string(),
3766 name: "long_job".to_string(),
3767 arguments: serde_json::json!({}),
3768 };
3769 let messages = vec![
3770 Message::user("Start job"),
3771 Message::assistant_with_tools("Starting...", vec![tool_call]),
3772 ];
3773
3774 let store = MockRunningStore;
3775 let emitter = crate::traits::NoopEventEmitter;
3776 let session_id = SessionId::new();
3777 let ctx = EventContext::empty();
3778 let patched = repair_dangling_tool_calls(
3779 &messages,
3780 Some(&store as &dyn DurableToolResultStore),
3781 &emitter,
3782 session_id,
3783 &ctx,
3784 "turn_01",
3785 )
3786 .await;
3787
3788 assert_eq!(patched.len(), 3);
3789 let repair = &patched[2];
3790 assert_eq!(repair.role, MessageRole::ToolResult);
3791 assert_eq!(repair.tool_call_id(), Some("call_run"));
3792 let content = format!("{:?}", repair);
3794 assert!(
3795 content.contains("uncertain") || content.contains("do not retry"),
3796 "expected uncertain/do-not-retry message, got: {content}"
3797 );
3798 }
3799
3800 #[tokio::test]
3801 async fn test_repair_dangling_tool_calls_store_error_unknown() {
3802 use crate::error::AgentLoopError;
3803 use crate::events::EventContext;
3804 use crate::traits::{DurableToolResultStore, ToolCallClaimResult};
3805 use crate::typed_id::SessionId;
3806
3807 struct MockErrorStore;
3808 #[async_trait::async_trait]
3809 impl DurableToolResultStore for MockErrorStore {
3810 async fn try_claim_tool_call(
3811 &self,
3812 _: &str,
3813 _: &str,
3814 _: &str,
3815 _: &str,
3816 ) -> crate::error::Result<ToolCallClaimResult> {
3817 Ok(ToolCallClaimResult::Claimed {
3818 claim_token: uuid::Uuid::new_v4(),
3819 })
3820 }
3821 async fn settle_tool_call(
3822 &self,
3823 _: &str,
3824 _: &str,
3825 _: serde_json::Value,
3826 _: &str,
3827 _: uuid::Uuid,
3828 ) -> crate::error::Result<bool> {
3829 Ok(true)
3830 }
3831 async fn get_tool_call_status(
3832 &self,
3833 _turn_id: &str,
3834 _tool_call_id: &str,
3835 ) -> crate::error::Result<Option<crate::traits::DurableToolCallStatus>> {
3836 Err(AgentLoopError::tool("simulated store failure"))
3837 }
3838 }
3839
3840 let tool_call = ToolCall {
3841 id: "call_err".to_string(),
3842 name: "risky_op".to_string(),
3843 arguments: serde_json::json!({}),
3844 };
3845 let messages = vec![
3846 Message::user("Do risky op"),
3847 Message::assistant_with_tools("On it...", vec![tool_call]),
3848 ];
3849
3850 let store = MockErrorStore;
3851 let emitter = crate::traits::NoopEventEmitter;
3852 let session_id = SessionId::new();
3853 let ctx = EventContext::empty();
3854 let patched = repair_dangling_tool_calls(
3855 &messages,
3856 Some(&store as &dyn DurableToolResultStore),
3857 &emitter,
3858 session_id,
3859 &ctx,
3860 "turn_01",
3861 )
3862 .await;
3863
3864 assert_eq!(patched.len(), 3);
3865 let repair = &patched[2];
3866 assert_eq!(repair.role, MessageRole::ToolResult);
3867 assert_eq!(repair.tool_call_id(), Some("call_err"));
3868 let content = format!("{:?}", repair);
3870 assert!(
3871 !content.contains("safe to retry"),
3872 "store error must not say 'safe to retry', got: {content}"
3873 );
3874 assert!(
3875 content.contains("do not retry") || content.contains("status unknown"),
3876 "expected do-not-retry/status-unknown message, got: {content}"
3877 );
3878 }
3879
3880 #[test]
3881 fn test_build_request_options_for_openai_prompt_cache() {
3882 let config = LlmCallConfig {
3883 speed: None,
3884 verbosity: None,
3885 model: "gpt-5.4".to_string(),
3886 temperature: None,
3887 max_tokens: None,
3888 tools: vec![],
3889 reasoning_effort: None,
3890 metadata: HashMap::new(),
3891 previous_response_id: Some("resp_123".to_string()),
3892 tool_search: None,
3893 prompt_cache: Some(PromptCacheConfig {
3894 enabled: true,
3895 strategy: PromptCacheStrategy::Auto,
3896 gemini_cached_content: None,
3897 }),
3898 openrouter_routing: None,
3899 parallel_tool_calls: None,
3900 volatile_suffix_len: 0,
3901 };
3902
3903 let request_options = build_request_options(&config, "openai").unwrap();
3904 assert_eq!(
3905 request_options
3906 .prompt_cache
3907 .and_then(|info| info.provider_mode),
3908 Some("prompt_cache_key".to_string())
3909 );
3910 assert_eq!(
3911 request_options.provider_options.get("openai"),
3912 Some(&json!({ "previous_response_id": true }))
3913 );
3914 }
3915
3916 #[test]
3917 fn test_build_request_options_for_gemini_explicit_cache() {
3918 let config = LlmCallConfig {
3919 speed: None,
3920 verbosity: None,
3921 model: "gemini-2.5-pro".to_string(),
3922 temperature: None,
3923 max_tokens: None,
3924 tools: vec![],
3925 reasoning_effort: None,
3926 metadata: HashMap::new(),
3927 previous_response_id: None,
3928 tool_search: None,
3929 prompt_cache: Some(PromptCacheConfig {
3930 enabled: true,
3931 strategy: PromptCacheStrategy::Auto,
3932 gemini_cached_content: Some("cachedContents/demo-cache".to_string()),
3933 }),
3934 openrouter_routing: None,
3935 parallel_tool_calls: None,
3936 volatile_suffix_len: 0,
3937 };
3938
3939 let request_options = build_request_options(&config, "gemini").unwrap();
3940 assert_eq!(
3941 request_options
3942 .prompt_cache
3943 .and_then(|info| info.provider_mode),
3944 Some("cached_content".to_string())
3945 );
3946 assert_eq!(
3947 request_options.provider_options.get("gemini"),
3948 Some(&json!({ "cached_content": true }))
3949 );
3950 }
3951
3952 #[test]
3953 fn test_build_request_options_omits_gemini_cache_flag_when_disabled() {
3954 let config = LlmCallConfig {
3955 speed: None,
3956 verbosity: None,
3957 model: "gemini-2.5-pro".to_string(),
3958 temperature: None,
3959 max_tokens: None,
3960 tools: vec![],
3961 reasoning_effort: None,
3962 metadata: HashMap::new(),
3963 previous_response_id: None,
3964 tool_search: None,
3965 prompt_cache: Some(PromptCacheConfig {
3966 enabled: false,
3967 strategy: PromptCacheStrategy::Auto,
3968 gemini_cached_content: Some("cachedContents/demo-cache".to_string()),
3969 }),
3970 openrouter_routing: None,
3971 parallel_tool_calls: None,
3972 volatile_suffix_len: 0,
3973 };
3974
3975 assert!(build_request_options(&config, "gemini").is_none());
3976 }
3977
3978 #[test]
3979 fn system_keys_override_embedder_keys_in_metadata() {
3980 let embedder_metadata: HashMap<String, String> = [
3984 ("session_id".to_string(), "attacker_value".to_string()),
3985 ("custom_key".to_string(), "custom_value".to_string()),
3986 ]
3987 .into();
3988
3989 let mut metadata: HashMap<String, String> = HashMap::new();
3990
3991 for (k, v) in &embedder_metadata {
3993 metadata.insert(k.clone(), v.clone());
3994 }
3995
3996 metadata.insert("session_id".to_string(), "real_session_id".to_string());
3998
3999 assert_eq!(
4000 metadata.get("session_id").map(String::as_str),
4001 Some("real_session_id"),
4002 "system key must overwrite embedder key with same name"
4003 );
4004 assert_eq!(
4005 metadata.get("custom_key").map(String::as_str),
4006 Some("custom_value"),
4007 "non-colliding embedder key must be preserved"
4008 );
4009 }
4010
4011 use crate::traits::{NoopPartialStreamStore, PartialStreamState, PartialStreamStore};
4016
4017 struct MockPartialStore(Option<PartialStreamState>);
4018
4019 #[async_trait::async_trait]
4020 impl PartialStreamStore for MockPartialStore {
4021 async fn get_partial_stream(
4022 &self,
4023 _session_id: crate::typed_id::SessionId,
4024 _turn_id: &str,
4025 ) -> crate::error::Result<Option<PartialStreamState>> {
4026 Ok(self.0.clone())
4027 }
4028 }
4029
4030 #[tokio::test]
4031 async fn test_noop_partial_stream_store_returns_none() {
4032 let store = NoopPartialStreamStore;
4033 let result = store
4034 .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4035 .await
4036 .unwrap();
4037 assert!(result.is_none());
4038 }
4039
4040 #[tokio::test]
4041 async fn test_partial_stream_store_returns_accumulated_when_partial_exists() {
4042 let message_id = MessageId::new();
4043 let store = MockPartialStore(Some(PartialStreamState {
4044 message_id,
4045 accumulated: "partial text so far".to_string(),
4046 }));
4047 let result = store
4048 .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4049 .await
4050 .unwrap();
4051 let partial = result.unwrap();
4052 assert_eq!(partial.message_id, message_id);
4053 assert_eq!(partial.accumulated, "partial text so far");
4054 }
4055
4056 #[tokio::test]
4057 async fn test_partial_stream_store_returns_empty_when_started_no_delta() {
4058 let store = MockPartialStore(Some(PartialStreamState {
4059 message_id: MessageId::new(),
4060 accumulated: String::new(),
4061 }));
4062 let result = store
4063 .get_partial_stream(crate::typed_id::SessionId::new(), "turn_01")
4064 .await
4065 .unwrap();
4066 assert!(result.unwrap().accumulated.is_empty());
4067 }
4068
4069 mod tool_call_repair_integration {
4074 use super::*;
4075 use crate::capabilities::{CapabilityRegistry, TOOL_CALL_REPAIR_CAPABILITY_ID};
4076 use crate::events::{EventData, EventRequest};
4077 use crate::tool_types::{BuiltinTool, ToolDefinition, ToolPolicy};
4078 use crate::traits::EventEmitter;
4079 use crate::typed_id::{MessageId, SessionId, TurnId};
4080 use std::sync::Mutex;
4081
4082 #[derive(Default)]
4084 struct RecordingEmitter {
4085 events: Mutex<Vec<EventRequest>>,
4086 }
4087
4088 #[async_trait::async_trait]
4089 impl EventEmitter for RecordingEmitter {
4090 async fn emit(
4091 &self,
4092 request: EventRequest,
4093 ) -> crate::error::Result<crate::events::Event> {
4094 let event = request
4095 .clone()
4096 .into_event(crate::typed_id::EventId::new(), 0);
4097 self.events.lock().unwrap().push(request);
4098 Ok(event)
4099 }
4100 }
4101
4102 impl RecordingEmitter {
4103 fn repaired_events(&self) -> Vec<(String, String)> {
4104 self.events
4105 .lock()
4106 .unwrap()
4107 .iter()
4108 .filter_map(|r| match &r.data {
4109 EventData::ToolCallRepaired(d) => {
4110 Some((d.tool_call_id.clone(), d.outcome.clone()))
4111 }
4112 _ => None,
4113 })
4114 .collect()
4115 }
4116 }
4117
4118 fn ctx() -> AtomContext {
4119 AtomContext::new(SessionId::new(), TurnId::new(), MessageId::new())
4120 }
4121
4122 fn read_file_tool() -> ToolDefinition {
4123 ToolDefinition::Builtin(BuiltinTool {
4124 name: "read_file".to_string(),
4125 display_name: None,
4126 description: "read".to_string(),
4127 parameters: json!({
4128 "type": "object",
4129 "properties": { "path": { "type": "string" } },
4130 "required": ["path"]
4131 }),
4132 policy: ToolPolicy::Auto,
4133 category: None,
4134 deferrable: Default::default(),
4135 hints: Default::default(),
4136 full_parameters: None,
4137 })
4138 }
4139
4140 fn malformed_call() -> ToolCall {
4141 ToolCall {
4144 id: "call_1".to_string(),
4145 name: "read_file".to_string(),
4146 arguments: json!("here you go: {'path': '/foo'}"),
4147 }
4148 }
4149
4150 fn enabled_configs() -> Vec<crate::AgentCapabilityConfig> {
4151 vec![crate::AgentCapabilityConfig::new(
4152 TOOL_CALL_REPAIR_CAPABILITY_ID,
4153 )]
4154 }
4155
4156 async fn run(
4157 configs: &[crate::AgentCapabilityConfig],
4158 tools: &[ToolDefinition],
4159 calls: &mut [ToolCall],
4160 emitter: &RecordingEmitter,
4161 ) {
4162 run_at(configs, tools, calls, emitter, 1).await;
4163 }
4164
4165 async fn run_at(
4166 configs: &[crate::AgentCapabilityConfig],
4167 tools: &[ToolDefinition],
4168 calls: &mut [ToolCall],
4169 emitter: &RecordingEmitter,
4170 iteration: u32,
4171 ) {
4172 let registry = CapabilityRegistry::with_builtins();
4173 apply_tool_call_repair(
4174 ®istry,
4175 emitter,
4176 SessionId::new(),
4177 &ctx(),
4178 configs,
4179 tools,
4180 calls,
4181 iteration,
4182 )
4183 .await;
4184 }
4185
4186 #[tokio::test]
4187 async fn malformed_call_is_repaired_and_turn_proceeds() {
4188 let emitter = RecordingEmitter::default();
4189 let tools = vec![read_file_tool()];
4190 let mut calls = vec![malformed_call()];
4191
4192 run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4193
4194 assert_eq!(calls[0].arguments, json!({ "path": "/foo" }));
4196 assert_eq!(
4197 emitter.repaired_events(),
4198 vec![("call_1".to_string(), "local-salvage".to_string())]
4199 );
4200 }
4201
4202 #[tokio::test]
4203 async fn observability_event_fires_with_outcome_label() {
4204 let emitter = RecordingEmitter::default();
4205 let tools = vec![read_file_tool()];
4206 let mut calls = vec![malformed_call()];
4207
4208 run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4209
4210 let repaired = emitter.repaired_events();
4211 assert_eq!(repaired.len(), 1, "exactly one repair event");
4212 assert_eq!(repaired[0].1, "local-salvage");
4213 }
4214
4215 #[tokio::test]
4216 async fn attempt_cap_honored_and_falls_through_to_error_path() {
4217 let emitter = RecordingEmitter::default();
4219 let tools = vec![read_file_tool()];
4220 let original = json!("totally not json and no braces at all");
4221 let mut calls = vec![ToolCall {
4222 id: "call_2".to_string(),
4223 name: "read_file".to_string(),
4224 arguments: original.clone(),
4225 }];
4226 let configs = vec![crate::AgentCapabilityConfig::with_config(
4227 TOOL_CALL_REPAIR_CAPABILITY_ID,
4228 json!({ "max_reprompts": 0 }),
4229 )];
4230
4231 run(&configs, &tools, &mut calls, &emitter).await;
4232
4233 assert_eq!(calls[0].arguments, original);
4235 assert_eq!(
4236 emitter.repaired_events(),
4237 vec![("call_2".to_string(), "gave-up".to_string())]
4238 );
4239 }
4240
4241 #[tokio::test]
4242 async fn unsalvageable_with_remaining_attempts_labels_reprompt() {
4243 let emitter = RecordingEmitter::default();
4244 let tools = vec![read_file_tool()];
4245 let mut calls = vec![ToolCall {
4246 id: "call_3".to_string(),
4247 name: "read_file".to_string(),
4248 arguments: json!("no json here"),
4249 }];
4250 run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4252 assert_eq!(
4253 emitter.repaired_events(),
4254 vec![("call_3".to_string(), "re-prompt".to_string())]
4255 );
4256 }
4257
4258 #[tokio::test]
4259 async fn attempt_cap_transitions_to_gave_up_on_later_iteration() {
4260 let tools = vec![read_file_tool()];
4265 let garbage = json!("no json here");
4266
4267 let first = RecordingEmitter::default();
4268 let mut calls1 = vec![ToolCall {
4269 id: "call_a".to_string(),
4270 name: "read_file".to_string(),
4271 arguments: garbage.clone(),
4272 }];
4273 run_at(&enabled_configs(), &tools, &mut calls1, &first, 1).await;
4274 assert_eq!(
4275 first.repaired_events(),
4276 vec![("call_a".to_string(), "re-prompt".to_string())]
4277 );
4278
4279 let later = RecordingEmitter::default();
4280 let mut calls2 = vec![ToolCall {
4281 id: "call_a".to_string(),
4282 name: "read_file".to_string(),
4283 arguments: garbage,
4284 }];
4285 run_at(&enabled_configs(), &tools, &mut calls2, &later, 2).await;
4286 assert_eq!(
4287 later.repaired_events(),
4288 vec![("call_a".to_string(), "gave-up".to_string())]
4289 );
4290 }
4291
4292 #[tokio::test]
4293 async fn disabled_capability_produces_no_drift() {
4294 let emitter = RecordingEmitter::default();
4297 let tools = vec![read_file_tool()];
4298 let original = malformed_call().arguments;
4299 let mut calls = vec![malformed_call()];
4300
4301 run(&[], &tools, &mut calls, &emitter).await;
4302
4303 assert_eq!(calls[0].arguments, original);
4304 assert!(emitter.repaired_events().is_empty());
4305 }
4306
4307 #[tokio::test]
4308 async fn well_formed_call_is_untouched_and_silent() {
4309 let emitter = RecordingEmitter::default();
4310 let tools = vec![read_file_tool()];
4311 let mut calls = vec![ToolCall {
4312 id: "call_4".to_string(),
4313 name: "read_file".to_string(),
4314 arguments: json!({ "path": "/already/good" }),
4315 }];
4316
4317 run(&enabled_configs(), &tools, &mut calls, &emitter).await;
4318
4319 assert_eq!(calls[0].arguments, json!({ "path": "/already/good" }));
4320 assert!(emitter.repaired_events().is_empty());
4321 }
4322 }
4323}