1use chrono::Utc;
2use futures::future::BoxFuture;
3use futures::StreamExt;
4use serde_json::{json, Map, Number, Value};
5use std::collections::{hash_map::DefaultHasher, HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9use tandem_observability::{emit_event, ObservabilityEvent, ProcessKind};
10use tandem_providers::{ChatAttachment, ChatMessage, ProviderRegistry, StreamChunk, TokenUsage};
11use tandem_tools::{validate_tool_schemas, ToolRegistry};
12use tandem_types::{
13 ContextMode, EngineEvent, HostOs, HostRuntimeContext, Message, MessagePart, MessagePartInput,
14 MessageRole, ModelSpec, PathStyle, PrewriteCoverageMode, PrewriteRequirements,
15 SendMessageRequest, ShellFamily, ToolMode, ToolSchema,
16};
17use tandem_wire::WireMessagePart;
18use tokio_util::sync::CancellationToken;
19use tracing::Level;
20
21mod loop_guards;
22mod prewrite_gate;
23
24#[cfg(test)]
25use loop_guards::parse_budget_override;
26use loop_guards::{
27 duplicate_signature_limit_for, tool_budget_for, websearch_duplicate_signature_limit,
28};
29use prewrite_gate::{
30 describe_unmet_prewrite_requirements_for_prompt, evaluate_prewrite_gate, PrewriteProgress,
31};
32
33use crate::tool_router::{
34 classify_intent, default_mode_name, is_short_simple_prompt, max_tools_per_call_expanded,
35 select_tool_subset, should_escalate_auto_tools, tool_router_enabled, ToolIntent,
36 ToolRoutingDecision,
37};
38use crate::{
39 any_policy_matches, derive_session_title_from_prompt, title_needs_repair,
40 tool_name_matches_policy, AgentDefinition, AgentRegistry, CancellationRegistry, EventBus,
41 PermissionAction, PermissionManager, PluginRegistry, Storage,
42};
43use crate::{
44 build_tool_effect_ledger_record, finalize_mutation_checkpoint_record,
45 mutation_checkpoint_event, prepare_mutation_checkpoint, tool_effect_ledger_event,
46 MutationCheckpointOutcome, ToolEffectLedgerPhase, ToolEffectLedgerStatus,
47};
48use tokio::sync::RwLock;
49
50#[derive(Default)]
51struct StreamedToolCall {
52 name: String,
53 args: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum RawToolArgsState {
58 Present,
59 Empty,
60 Unparseable,
61}
62
63impl RawToolArgsState {
64 fn as_str(self) -> &'static str {
65 match self {
66 Self::Present => "present",
67 Self::Empty => "empty",
68 Self::Unparseable => "unparseable",
69 }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74enum WritePathRecoveryMode {
75 Heuristic,
76 OutputTargetOnly,
77}
78
79#[derive(Debug, Clone)]
80pub struct SpawnAgentToolContext {
81 pub session_id: String,
82 pub message_id: String,
83 pub tool_call_id: Option<String>,
84 pub args: Value,
85}
86
87#[derive(Debug, Clone)]
88pub struct SpawnAgentToolResult {
89 pub output: String,
90 pub metadata: Value,
91}
92
93#[derive(Debug, Clone)]
94pub struct ToolPolicyContext {
95 pub session_id: String,
96 pub message_id: String,
97 pub tool: String,
98 pub args: Value,
99}
100
101#[derive(Debug, Clone)]
102pub struct ToolPolicyDecision {
103 pub allowed: bool,
104 pub reason: Option<String>,
105}
106
107pub trait SpawnAgentHook: Send + Sync {
108 fn spawn_agent(
109 &self,
110 ctx: SpawnAgentToolContext,
111 ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
112}
113
114pub trait ToolPolicyHook: Send + Sync {
115 fn evaluate_tool(
116 &self,
117 ctx: ToolPolicyContext,
118 ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
119}
120
121#[derive(Debug, Clone)]
122pub struct PromptContextHookContext {
123 pub session_id: String,
124 pub message_id: String,
125 pub provider_id: String,
126 pub model_id: String,
127 pub iteration: usize,
128}
129
130pub trait PromptContextHook: Send + Sync {
131 fn augment_provider_messages(
132 &self,
133 ctx: PromptContextHookContext,
134 messages: Vec<ChatMessage>,
135 ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
136}
137
138#[derive(Clone)]
139pub struct EngineLoop {
140 storage: std::sync::Arc<Storage>,
141 event_bus: EventBus,
142 providers: ProviderRegistry,
143 plugins: PluginRegistry,
144 agents: AgentRegistry,
145 permissions: PermissionManager,
146 tools: ToolRegistry,
147 cancellations: CancellationRegistry,
148 host_runtime_context: HostRuntimeContext,
149 workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
150 session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
151 session_auto_approve_permissions: std::sync::Arc<RwLock<HashMap<String, bool>>>,
152 spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
153 tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
154 prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
155}
156
157impl EngineLoop {
158 #[allow(clippy::too_many_arguments)]
159 pub fn new(
160 storage: std::sync::Arc<Storage>,
161 event_bus: EventBus,
162 providers: ProviderRegistry,
163 plugins: PluginRegistry,
164 agents: AgentRegistry,
165 permissions: PermissionManager,
166 tools: ToolRegistry,
167 cancellations: CancellationRegistry,
168 host_runtime_context: HostRuntimeContext,
169 ) -> Self {
170 Self {
171 storage,
172 event_bus,
173 providers,
174 plugins,
175 agents,
176 permissions,
177 tools,
178 cancellations,
179 host_runtime_context,
180 workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
181 session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
182 session_auto_approve_permissions: std::sync::Arc::new(RwLock::new(HashMap::new())),
183 spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
184 tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
185 prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
186 }
187 }
188
189 pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
190 *self.spawn_agent_hook.write().await = Some(hook);
191 }
192
193 pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
194 *self.tool_policy_hook.write().await = Some(hook);
195 }
196
197 pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
198 *self.prompt_context_hook.write().await = Some(hook);
199 }
200
201 pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
202 let normalized = allowed_tools
203 .into_iter()
204 .map(|tool| normalize_tool_name(&tool))
205 .filter(|tool| !tool.trim().is_empty())
206 .collect::<Vec<_>>();
207 self.session_allowed_tools
208 .write()
209 .await
210 .insert(session_id.to_string(), normalized);
211 }
212
213 pub async fn clear_session_allowed_tools(&self, session_id: &str) {
214 self.session_allowed_tools.write().await.remove(session_id);
215 }
216
217 pub async fn set_session_auto_approve_permissions(&self, session_id: &str, enabled: bool) {
218 if enabled {
219 self.session_auto_approve_permissions
220 .write()
221 .await
222 .insert(session_id.to_string(), true);
223 } else {
224 self.session_auto_approve_permissions
225 .write()
226 .await
227 .remove(session_id);
228 }
229 }
230
231 pub async fn clear_session_auto_approve_permissions(&self, session_id: &str) {
232 self.session_auto_approve_permissions
233 .write()
234 .await
235 .remove(session_id);
236 }
237
238 pub async fn grant_workspace_override_for_session(
239 &self,
240 session_id: &str,
241 ttl_seconds: u64,
242 ) -> u64 {
243 let expires_at = chrono::Utc::now()
244 .timestamp_millis()
245 .max(0)
246 .saturating_add((ttl_seconds as i64).saturating_mul(1000))
247 as u64;
248 self.workspace_overrides
249 .write()
250 .await
251 .insert(session_id.to_string(), expires_at);
252 expires_at
253 }
254
255 pub async fn run_prompt_async(
256 &self,
257 session_id: String,
258 req: SendMessageRequest,
259 ) -> anyhow::Result<()> {
260 self.run_prompt_async_with_context(session_id, req, None)
261 .await
262 }
263
264 pub async fn run_prompt_async_with_context(
265 &self,
266 session_id: String,
267 req: SendMessageRequest,
268 correlation_id: Option<String>,
269 ) -> anyhow::Result<()> {
270 let session_model = self
271 .storage
272 .get_session(&session_id)
273 .await
274 .and_then(|s| s.model);
275 let (provider_id, model_id_value) =
276 resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
277 anyhow::anyhow!(
278 "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
279 )
280 })?;
281 let correlation_ref = correlation_id.as_deref();
282 let model_id = Some(model_id_value.as_str());
283 let cancel = self.cancellations.create(&session_id).await;
284 emit_event(
285 Level::INFO,
286 ProcessKind::Engine,
287 ObservabilityEvent {
288 event: "provider.call.start",
289 component: "engine.loop",
290 correlation_id: correlation_ref,
291 session_id: Some(&session_id),
292 run_id: None,
293 message_id: None,
294 provider_id: Some(provider_id.as_str()),
295 model_id,
296 status: Some("start"),
297 error_code: None,
298 detail: Some("run_prompt_async dispatch"),
299 },
300 );
301 self.event_bus.publish(EngineEvent::new(
302 "session.status",
303 json!({"sessionID": session_id, "status":"running"}),
304 ));
305 let request_parts = req.parts.clone();
306 let requested_tool_mode = req.tool_mode.clone().unwrap_or(ToolMode::Auto);
307 let requested_context_mode = req.context_mode.clone().unwrap_or(ContextMode::Auto);
308 let requested_write_required = req.write_required.unwrap_or(false);
309 let requested_prewrite_requirements = req.prewrite_requirements.clone().unwrap_or_default();
310 let request_tool_allowlist = req
311 .tool_allowlist
312 .clone()
313 .unwrap_or_default()
314 .into_iter()
315 .map(|tool| normalize_tool_name(&tool))
316 .filter(|tool| !tool.trim().is_empty())
317 .collect::<HashSet<_>>();
318 let text = req
319 .parts
320 .iter()
321 .map(|p| match p {
322 MessagePartInput::Text { text } => text.clone(),
323 MessagePartInput::File {
324 mime,
325 filename,
326 url,
327 } => format!(
328 "[file mime={} name={} url={}]",
329 mime,
330 filename.clone().unwrap_or_else(|| "unknown".to_string()),
331 url
332 ),
333 })
334 .collect::<Vec<_>>()
335 .join("\n");
336 let runtime_attachments = build_runtime_attachments(&provider_id, &request_parts).await;
337 self.auto_rename_session_from_user_text(&session_id, &text)
338 .await;
339 let active_agent = self.agents.get(req.agent.as_deref()).await;
340 let mut user_message_id = self
341 .find_recent_matching_user_message_id(&session_id, &text)
342 .await;
343 if user_message_id.is_none() {
344 let user_message = Message::new(
345 MessageRole::User,
346 vec![MessagePart::Text { text: text.clone() }],
347 );
348 let created_message_id = user_message.id.clone();
349 self.storage
350 .append_message(&session_id, user_message)
351 .await?;
352
353 let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
354 self.event_bus.publish(EngineEvent::new(
355 "message.part.updated",
356 json!({
357 "part": user_part,
358 "delta": text,
359 "agent": active_agent.name
360 }),
361 ));
362 user_message_id = Some(created_message_id);
363 }
364 let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
365
366 if cancel.is_cancelled() {
367 self.event_bus.publish(EngineEvent::new(
368 "session.status",
369 json!({"sessionID": session_id, "status":"cancelled"}),
370 ));
371 self.cancellations.remove(&session_id).await;
372 return Ok(());
373 }
374
375 let mut question_tool_used = false;
376 let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
377 if normalize_tool_name(&tool) == "question" {
378 question_tool_used = true;
379 }
380 if !agent_can_use_tool(&active_agent, &tool) {
381 format!(
382 "Tool `{tool}` is not enabled for agent `{}`.",
383 active_agent.name
384 )
385 } else {
386 self.execute_tool_with_permission(
387 &session_id,
388 &user_message_id,
389 tool.clone(),
390 args,
391 None,
392 active_agent.skills.as_deref(),
393 &text,
394 requested_write_required,
395 None,
396 cancel.clone(),
397 )
398 .await?
399 .unwrap_or_default()
400 }
401 } else {
402 let mut completion = String::new();
403 let mut max_iterations = max_tool_iterations();
404 let mut followup_context: Option<String> = None;
405 let mut last_tool_outputs: Vec<String> = Vec::new();
406 let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
407 let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
408 let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
409 let mut mutable_signature_counts: HashMap<String, usize> = HashMap::new();
410 let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
411 let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
412 let mut websearch_query_blocked = false;
413 let websearch_duplicate_signature_limit = websearch_duplicate_signature_limit();
414 let mut pack_builder_executed = false;
415 let mut auto_workspace_probe_attempted = false;
416 let mut productive_tool_calls_total = 0usize;
417 let mut productive_write_tool_calls_total = 0usize;
418 let mut productive_workspace_inspection_total = 0usize;
419 let mut productive_web_research_total = 0usize;
420 let mut productive_concrete_read_total = 0usize;
421 let mut successful_web_research_total = 0usize;
422 let mut required_tool_retry_count = 0usize;
423 let mut required_write_retry_count = 0usize;
424 let mut unmet_prewrite_repair_retry_count = 0usize;
425 let mut empty_completion_retry_count = 0usize;
426 let mut prewrite_gate_waived = false;
427 let mut invalid_tool_args_retry_count = 0usize;
428 let strict_write_retry_max_attempts = strict_write_retry_max_attempts();
429 let mut required_tool_unsatisfied_emitted = false;
430 let mut latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
431 let email_delivery_requested = requires_email_delivery_prompt(&text);
432 let web_research_requested = requires_web_research_prompt(&text);
433 let code_workflow_requested = infer_code_workflow_from_text(&text);
434 let mut email_action_executed = false;
435 let mut latest_email_action_note: Option<String> = None;
436 let intent = classify_intent(&text);
437 let router_enabled = tool_router_enabled();
438 let retrieval_enabled = semantic_tool_retrieval_enabled();
439 let retrieval_k = semantic_tool_retrieval_k();
440 let mcp_server_names = if mcp_catalog_in_system_prompt_enabled() {
441 self.tools.mcp_server_names().await
442 } else {
443 Vec::new()
444 };
445 let mut auto_tools_escalated = matches!(requested_tool_mode, ToolMode::Required);
446 let context_is_auto_compact = matches!(requested_context_mode, ContextMode::Auto)
447 && runtime_attachments.is_empty()
448 && is_short_simple_prompt(&text)
449 && matches!(intent, ToolIntent::Chitchat | ToolIntent::Knowledge);
450
451 while max_iterations > 0 && !cancel.is_cancelled() {
452 let iteration = 26usize.saturating_sub(max_iterations);
453 max_iterations -= 1;
454 let context_profile = if matches!(requested_context_mode, ContextMode::Full) {
455 ChatHistoryProfile::Full
456 } else if matches!(requested_context_mode, ContextMode::Compact)
457 || context_is_auto_compact
458 {
459 ChatHistoryProfile::Compact
460 } else {
461 ChatHistoryProfile::Standard
462 };
463 let mut messages =
464 load_chat_history(self.storage.clone(), &session_id, context_profile).await;
465 if iteration == 1 && !runtime_attachments.is_empty() {
466 attach_to_last_user_message(&mut messages, &runtime_attachments);
467 }
468 let history_char_count = messages.iter().map(|m| m.content.len()).sum::<usize>();
469 self.event_bus.publish(EngineEvent::new(
470 "context.profile.selected",
471 json!({
472 "sessionID": session_id,
473 "messageID": user_message_id,
474 "iteration": iteration,
475 "contextMode": format_context_mode(&requested_context_mode, context_is_auto_compact),
476 "historyMessageCount": messages.len(),
477 "historyCharCount": history_char_count,
478 "memoryInjected": false
479 }),
480 ));
481 let mut system_parts = vec![tandem_runtime_system_prompt(
482 &self.host_runtime_context,
483 &mcp_server_names,
484 )];
485 if let Some(system) = active_agent.system_prompt.as_ref() {
486 system_parts.push(system.clone());
487 }
488 messages.insert(
489 0,
490 ChatMessage {
491 role: "system".to_string(),
492 content: system_parts.join("\n\n"),
493 attachments: Vec::new(),
494 },
495 );
496 if let Some(extra) = followup_context.take() {
497 messages.push(ChatMessage {
498 role: "user".to_string(),
499 content: extra,
500 attachments: Vec::new(),
501 });
502 }
503 if let Some(hook) = self.prompt_context_hook.read().await.clone() {
504 let ctx = PromptContextHookContext {
505 session_id: session_id.clone(),
506 message_id: user_message_id.clone(),
507 provider_id: provider_id.clone(),
508 model_id: model_id_value.clone(),
509 iteration,
510 };
511 let hook_timeout =
512 Duration::from_millis(prompt_context_hook_timeout_ms() as u64);
513 match tokio::time::timeout(
514 hook_timeout,
515 hook.augment_provider_messages(ctx, messages.clone()),
516 )
517 .await
518 {
519 Ok(Ok(augmented)) => {
520 messages = augmented;
521 }
522 Ok(Err(err)) => {
523 self.event_bus.publish(EngineEvent::new(
524 "memory.context.error",
525 json!({
526 "sessionID": session_id,
527 "messageID": user_message_id,
528 "iteration": iteration,
529 "error": truncate_text(&err.to_string(), 500),
530 }),
531 ));
532 }
533 Err(_) => {
534 self.event_bus.publish(EngineEvent::new(
535 "memory.context.error",
536 json!({
537 "sessionID": session_id,
538 "messageID": user_message_id,
539 "iteration": iteration,
540 "error": format!(
541 "prompt context hook timeout after {} ms",
542 hook_timeout.as_millis()
543 ),
544 }),
545 ));
546 }
547 }
548 }
549 let all_tools = self.tools.list().await;
550 let mut retrieval_fallback_reason: Option<&'static str> = None;
551 let mut candidate_tools = if retrieval_enabled {
552 self.tools.retrieve(&text, retrieval_k).await
553 } else {
554 all_tools.clone()
555 };
556 if retrieval_enabled {
557 if candidate_tools.is_empty() && !all_tools.is_empty() {
558 candidate_tools = all_tools.clone();
559 retrieval_fallback_reason = Some("retrieval_empty_result");
560 } else if web_research_requested
561 && has_web_research_tools(&all_tools)
562 && !has_web_research_tools(&candidate_tools)
563 {
564 candidate_tools = all_tools.clone();
565 retrieval_fallback_reason = Some("missing_web_tools_for_research_prompt");
566 } else if email_delivery_requested
567 && has_email_action_tools(&all_tools)
568 && !has_email_action_tools(&candidate_tools)
569 {
570 candidate_tools = all_tools.clone();
571 retrieval_fallback_reason = Some("missing_email_tools_for_delivery_prompt");
572 }
573 }
574 let mut tool_schemas = if !router_enabled {
575 candidate_tools
576 } else {
577 match requested_tool_mode {
578 ToolMode::None => Vec::new(),
579 ToolMode::Required => select_tool_subset(
580 candidate_tools,
581 intent,
582 &request_tool_allowlist,
583 iteration > 1,
584 ),
585 ToolMode::Auto => {
586 if !auto_tools_escalated {
587 Vec::new()
588 } else {
589 select_tool_subset(
590 candidate_tools,
591 intent,
592 &request_tool_allowlist,
593 iteration > 1,
594 )
595 }
596 }
597 }
598 };
599 let mut policy_patterns =
600 request_tool_allowlist.iter().cloned().collect::<Vec<_>>();
601 if let Some(agent_tools) = active_agent.tools.as_ref() {
602 policy_patterns
603 .extend(agent_tools.iter().map(|tool| normalize_tool_name(tool)));
604 }
605 let session_allowed_tools = self
606 .session_allowed_tools
607 .read()
608 .await
609 .get(&session_id)
610 .cloned()
611 .unwrap_or_default();
612 policy_patterns.extend(session_allowed_tools.iter().cloned());
613 if !policy_patterns.is_empty() {
614 let mut included = tool_schemas
615 .iter()
616 .map(|schema| normalize_tool_name(&schema.name))
617 .collect::<HashSet<_>>();
618 for schema in &all_tools {
619 let normalized = normalize_tool_name(&schema.name);
620 if policy_patterns
621 .iter()
622 .any(|pattern| tool_name_matches_policy(pattern, &normalized))
623 && included.insert(normalized)
624 {
625 tool_schemas.push(schema.clone());
626 }
627 }
628 }
629 if !request_tool_allowlist.is_empty() {
630 tool_schemas.retain(|schema| {
631 let tool = normalize_tool_name(&schema.name);
632 request_tool_allowlist
633 .iter()
634 .any(|pattern| tool_name_matches_policy(pattern, &tool))
635 });
636 }
637 let prewrite_gate = evaluate_prewrite_gate(
638 requested_write_required,
639 &requested_prewrite_requirements,
640 PrewriteProgress {
641 productive_write_tool_calls_total,
642 productive_workspace_inspection_total,
643 productive_concrete_read_total,
644 productive_web_research_total,
645 successful_web_research_total,
646 required_write_retry_count,
647 unmet_prewrite_repair_retry_count,
648 prewrite_gate_waived,
649 },
650 );
651 let _prewrite_satisfied = prewrite_gate.prewrite_satisfied;
652 let prewrite_gate_write = prewrite_gate.gate_write;
653 let force_write_only_retry = prewrite_gate.force_write_only_retry;
654 let allow_repair_tools = prewrite_gate.allow_repair_tools;
655 if prewrite_gate_write {
656 tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
657 }
658 if requested_prewrite_requirements.repair_on_unmet_requirements
659 && productive_write_tool_calls_total >= 3
660 {
661 tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
662 }
663 if allow_repair_tools {
664 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
665 let repair_tools = tool_schemas
666 .iter()
667 .filter(|schema| {
668 tool_matches_unmet_prewrite_repair_requirement(
669 &schema.name,
670 &unmet_prewrite_codes,
671 )
672 })
673 .cloned()
674 .collect::<Vec<_>>();
675 if !repair_tools.is_empty() {
676 tool_schemas = repair_tools;
677 }
678 }
679 if force_write_only_retry && !allow_repair_tools {
680 tool_schemas.retain(|schema| is_workspace_write_tool(&schema.name));
681 }
682 if active_agent.tools.is_some() {
683 tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
684 }
685 tool_schemas.retain(|schema| {
686 let normalized = normalize_tool_name(&schema.name);
687 if let Some(server) = mcp_server_from_tool_name(&normalized) {
688 !blocked_mcp_servers.contains(server)
689 } else {
690 true
691 }
692 });
693 if let Some(allowed_tools) = self
694 .session_allowed_tools
695 .read()
696 .await
697 .get(&session_id)
698 .cloned()
699 {
700 if !allowed_tools.is_empty() {
701 tool_schemas.retain(|schema| {
702 let normalized = normalize_tool_name(&schema.name);
703 any_policy_matches(&allowed_tools, &normalized)
704 });
705 }
706 }
707 if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
708 let detail = validation_err.to_string();
709 emit_event(
710 Level::ERROR,
711 ProcessKind::Engine,
712 ObservabilityEvent {
713 event: "provider.call.error",
714 component: "engine.loop",
715 correlation_id: correlation_ref,
716 session_id: Some(&session_id),
717 run_id: None,
718 message_id: Some(&user_message_id),
719 provider_id: Some(provider_id.as_str()),
720 model_id,
721 status: Some("failed"),
722 error_code: Some("TOOL_SCHEMA_INVALID"),
723 detail: Some(&detail),
724 },
725 );
726 anyhow::bail!("{detail}");
727 }
728 let routing_decision = ToolRoutingDecision {
729 pass: if auto_tools_escalated { 2 } else { 1 },
730 mode: match requested_tool_mode {
731 ToolMode::Auto => default_mode_name(),
732 ToolMode::None => "none",
733 ToolMode::Required => "required",
734 },
735 intent,
736 selected_count: tool_schemas.len(),
737 total_available_count: all_tools.len(),
738 mcp_included: tool_schemas
739 .iter()
740 .any(|schema| normalize_tool_name(&schema.name).starts_with("mcp.")),
741 };
742 self.event_bus.publish(EngineEvent::new(
743 "tool.routing.decision",
744 json!({
745 "sessionID": session_id,
746 "messageID": user_message_id,
747 "iteration": iteration,
748 "pass": routing_decision.pass,
749 "mode": routing_decision.mode,
750 "intent": format!("{:?}", routing_decision.intent).to_ascii_lowercase(),
751 "selectedToolCount": routing_decision.selected_count,
752 "totalAvailableTools": routing_decision.total_available_count,
753 "mcpIncluded": routing_decision.mcp_included,
754 "retrievalEnabled": retrieval_enabled,
755 "retrievalK": retrieval_k,
756 "fallbackToFullTools": retrieval_fallback_reason.is_some(),
757 "fallbackReason": retrieval_fallback_reason
758 }),
759 ));
760 let allowed_tool_names = tool_schemas
761 .iter()
762 .map(|schema| normalize_tool_name(&schema.name))
763 .collect::<HashSet<_>>();
764 let offered_tool_preview = tool_schemas
765 .iter()
766 .take(8)
767 .map(|schema| normalize_tool_name(&schema.name))
768 .collect::<Vec<_>>()
769 .join(", ");
770 self.event_bus.publish(EngineEvent::new(
771 "provider.call.iteration.start",
772 json!({
773 "sessionID": session_id,
774 "messageID": user_message_id,
775 "iteration": iteration,
776 "selectedToolCount": allowed_tool_names.len(),
777 }),
778 ));
779 let provider_connect_timeout =
780 Duration::from_millis(provider_stream_connect_timeout_ms() as u64);
781 let stream_result = tokio::time::timeout(
782 provider_connect_timeout,
783 self.providers.stream_for_provider(
784 Some(provider_id.as_str()),
785 Some(model_id_value.as_str()),
786 messages,
787 requested_tool_mode.clone(),
788 Some(tool_schemas),
789 cancel.clone(),
790 ),
791 )
792 .await
793 .map_err(|_| {
794 anyhow::anyhow!(
795 "provider stream connect timeout after {} ms",
796 provider_connect_timeout.as_millis()
797 )
798 })
799 .and_then(|result| result);
800 let stream = match stream_result {
801 Ok(stream) => stream,
802 Err(err) => {
803 let error_text = err.to_string();
804 let error_code = provider_error_code(&error_text);
805 let detail = truncate_text(&error_text, 500);
806 emit_event(
807 Level::ERROR,
808 ProcessKind::Engine,
809 ObservabilityEvent {
810 event: "provider.call.error",
811 component: "engine.loop",
812 correlation_id: correlation_ref,
813 session_id: Some(&session_id),
814 run_id: None,
815 message_id: Some(&user_message_id),
816 provider_id: Some(provider_id.as_str()),
817 model_id,
818 status: Some("failed"),
819 error_code: Some(error_code),
820 detail: Some(&detail),
821 },
822 );
823 self.event_bus.publish(EngineEvent::new(
824 "provider.call.iteration.error",
825 json!({
826 "sessionID": session_id,
827 "messageID": user_message_id,
828 "iteration": iteration,
829 "error": detail,
830 }),
831 ));
832 return Err(err);
833 }
834 };
835 tokio::pin!(stream);
836 completion.clear();
837 let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
838 let mut provider_usage: Option<TokenUsage> = None;
839 let mut accepted_tool_calls_in_cycle = 0usize;
840 let provider_idle_timeout =
841 Duration::from_millis(provider_stream_idle_timeout_ms() as u64);
842 loop {
843 let next_chunk_result =
844 tokio::time::timeout(provider_idle_timeout, stream.next())
845 .await
846 .map_err(|_| {
847 anyhow::anyhow!(
848 "provider stream idle timeout after {} ms",
849 provider_idle_timeout.as_millis()
850 )
851 });
852 let next_chunk = match next_chunk_result {
853 Ok(next_chunk) => next_chunk,
854 Err(err) => {
855 self.event_bus.publish(EngineEvent::new(
856 "provider.call.iteration.error",
857 json!({
858 "sessionID": session_id,
859 "messageID": user_message_id,
860 "iteration": iteration,
861 "error": truncate_text(&err.to_string(), 500),
862 }),
863 ));
864 return Err(err);
865 }
866 };
867 let Some(chunk) = next_chunk else {
868 break;
869 };
870 let chunk = match chunk {
871 Ok(chunk) => chunk,
872 Err(err) => {
873 let error_text = err.to_string();
874 let error_code = provider_error_code(&error_text);
875 let detail = truncate_text(&error_text, 500);
876 emit_event(
877 Level::ERROR,
878 ProcessKind::Engine,
879 ObservabilityEvent {
880 event: "provider.call.error",
881 component: "engine.loop",
882 correlation_id: correlation_ref,
883 session_id: Some(&session_id),
884 run_id: None,
885 message_id: Some(&user_message_id),
886 provider_id: Some(provider_id.as_str()),
887 model_id,
888 status: Some("failed"),
889 error_code: Some(error_code),
890 detail: Some(&detail),
891 },
892 );
893 self.event_bus.publish(EngineEvent::new(
894 "provider.call.iteration.error",
895 json!({
896 "sessionID": session_id,
897 "messageID": user_message_id,
898 "iteration": iteration,
899 "error": detail,
900 }),
901 ));
902 return Err(anyhow::anyhow!(
903 "provider stream chunk error: {error_text}"
904 ));
905 }
906 };
907 match chunk {
908 StreamChunk::TextDelta(delta) => {
909 let delta = strip_model_control_markers(&delta);
910 if delta.trim().is_empty() {
911 continue;
912 }
913 if completion.is_empty() {
914 emit_event(
915 Level::INFO,
916 ProcessKind::Engine,
917 ObservabilityEvent {
918 event: "provider.call.first_byte",
919 component: "engine.loop",
920 correlation_id: correlation_ref,
921 session_id: Some(&session_id),
922 run_id: None,
923 message_id: Some(&user_message_id),
924 provider_id: Some(provider_id.as_str()),
925 model_id,
926 status: Some("streaming"),
927 error_code: None,
928 detail: Some("first text delta"),
929 },
930 );
931 }
932 completion.push_str(&delta);
933 let delta = truncate_text(&delta, 4_000);
934 let delta_part =
935 WireMessagePart::text(&session_id, &user_message_id, delta.clone());
936 self.event_bus.publish(EngineEvent::new(
937 "message.part.updated",
938 json!({"part": delta_part, "delta": delta}),
939 ));
940 }
941 StreamChunk::ReasoningDelta(_reasoning) => {}
942 StreamChunk::Done {
943 finish_reason: _,
944 usage,
945 } => {
946 if usage.is_some() {
947 provider_usage = usage;
948 }
949 break;
950 }
951 StreamChunk::ToolCallStart { id, name } => {
952 let entry = streamed_tool_calls.entry(id).or_default();
953 if entry.name.is_empty() {
954 entry.name = name;
955 }
956 }
957 StreamChunk::ToolCallDelta { id, args_delta } => {
958 let entry = streamed_tool_calls.entry(id.clone()).or_default();
959 entry.args.push_str(&args_delta);
960 let tool_name = if entry.name.trim().is_empty() {
961 "tool".to_string()
962 } else {
963 normalize_tool_name(&entry.name)
964 };
965 let parsed_preview = if entry.name.trim().is_empty() {
966 Value::String(truncate_text(&entry.args, 1_000))
967 } else {
968 parse_streamed_tool_args(&tool_name, &entry.args)
969 };
970 let mut tool_part = WireMessagePart::tool_invocation(
971 &session_id,
972 &user_message_id,
973 tool_name.clone(),
974 parsed_preview.clone(),
975 );
976 tool_part.id = Some(id.clone());
977 if tool_name == "write" {
978 tracing::info!(
979 session_id = %session_id,
980 message_id = %user_message_id,
981 tool_call_id = %id,
982 args_delta_len = args_delta.len(),
983 accumulated_args_len = entry.args.len(),
984 parsed_preview_empty = parsed_preview.is_null()
985 || parsed_preview.as_object().is_some_and(|value| value.is_empty())
986 || parsed_preview
987 .as_str()
988 .map(|value| value.trim().is_empty())
989 .unwrap_or(false),
990 "streamed write tool args delta received"
991 );
992 }
993 self.event_bus.publish(EngineEvent::new(
994 "message.part.updated",
995 json!({
996 "part": tool_part,
997 "toolCallDelta": {
998 "id": id,
999 "tool": tool_name,
1000 "argsDelta": truncate_text(&args_delta, 1_000),
1001 "rawArgsPreview": truncate_text(&entry.args, 2_000),
1002 "parsedArgsPreview": parsed_preview
1003 }
1004 }),
1005 ));
1006 }
1007 StreamChunk::ToolCallEnd { id: _ } => {}
1008 }
1009 if cancel.is_cancelled() {
1010 break;
1011 }
1012 }
1013
1014 let streamed_tool_call_count = streamed_tool_calls.len();
1015 let streamed_tool_call_parse_failed = streamed_tool_calls
1016 .values()
1017 .any(|call| !call.args.trim().is_empty() && call.name.trim().is_empty());
1018 let mut tool_calls = streamed_tool_calls
1019 .into_iter()
1020 .filter_map(|(call_id, call)| {
1021 if call.name.trim().is_empty() {
1022 return None;
1023 }
1024 let tool_name = normalize_tool_name(&call.name);
1025 let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
1026 Some(ParsedToolCall {
1027 tool: tool_name,
1028 args: parsed_args,
1029 call_id: Some(call_id),
1030 })
1031 })
1032 .collect::<Vec<_>>();
1033 if tool_calls.is_empty() {
1034 tool_calls = parse_tool_invocations_from_response(&completion)
1035 .into_iter()
1036 .map(|(tool, args)| ParsedToolCall {
1037 tool,
1038 args,
1039 call_id: None,
1040 })
1041 .collect::<Vec<_>>();
1042 }
1043 let provider_tool_parse_failed = tool_calls.is_empty()
1044 && (streamed_tool_call_parse_failed
1045 || (streamed_tool_call_count > 0
1046 && looks_like_unparsed_tool_payload(&completion))
1047 || looks_like_unparsed_tool_payload(&completion));
1048 if provider_tool_parse_failed {
1049 latest_required_tool_failure_kind =
1050 RequiredToolFailureKind::ToolCallParseFailed;
1051 } else if tool_calls.is_empty() {
1052 latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
1053 }
1054 if router_enabled
1055 && matches!(requested_tool_mode, ToolMode::Auto)
1056 && !auto_tools_escalated
1057 && iteration == 1
1058 && should_escalate_auto_tools(intent, &text, &completion)
1059 {
1060 auto_tools_escalated = true;
1061 followup_context = Some(
1062 "Tool access is now enabled for this request. Use only necessary tools and then answer concisely."
1063 .to_string(),
1064 );
1065 self.event_bus.publish(EngineEvent::new(
1066 "provider.call.iteration.finish",
1067 json!({
1068 "sessionID": session_id,
1069 "messageID": user_message_id,
1070 "iteration": iteration,
1071 "finishReason": "auto_escalate",
1072 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1073 "rejectedToolCalls": 0,
1074 }),
1075 ));
1076 continue;
1077 }
1078 if tool_calls.is_empty()
1079 && !auto_workspace_probe_attempted
1080 && should_force_workspace_probe(&text, &completion)
1081 && allowed_tool_names.contains("glob")
1082 {
1083 auto_workspace_probe_attempted = true;
1084 tool_calls = vec![ParsedToolCall {
1085 tool: "glob".to_string(),
1086 args: json!({ "pattern": "*" }),
1087 call_id: None,
1088 }];
1089 }
1090 if !tool_calls.is_empty() {
1091 let saw_tool_call_candidate = true;
1092 let mut outputs = Vec::new();
1093 let mut executed_productive_tool = false;
1094 let mut write_tool_attempted_in_cycle = false;
1095 let mut auth_required_hit_in_cycle = false;
1096 let mut guard_budget_hit_in_cycle = false;
1097 let mut duplicate_signature_hit_in_cycle = false;
1098 let mut rejected_tool_call_in_cycle = false;
1099 for ParsedToolCall {
1100 tool,
1101 args,
1102 call_id,
1103 } in tool_calls
1104 {
1105 if !agent_can_use_tool(&active_agent, &tool) {
1106 rejected_tool_call_in_cycle = true;
1107 continue;
1108 }
1109 let tool_key = normalize_tool_name(&tool);
1110 if is_workspace_write_tool(&tool_key) {
1111 write_tool_attempted_in_cycle = true;
1112 }
1113 if !allowed_tool_names.contains(&tool_key) {
1114 rejected_tool_call_in_cycle = true;
1115 let note = if offered_tool_preview.is_empty() {
1116 format!(
1117 "Tool `{}` call skipped: it is not available in this turn.",
1118 tool_key
1119 )
1120 } else {
1121 format!(
1122 "Tool `{}` call skipped: it is not available in this turn. Available tools: {}.",
1123 tool_key, offered_tool_preview
1124 )
1125 };
1126 self.event_bus.publish(EngineEvent::new(
1127 "tool.call.rejected_unoffered",
1128 json!({
1129 "sessionID": session_id,
1130 "messageID": user_message_id,
1131 "iteration": iteration,
1132 "tool": tool_key,
1133 "offeredToolCount": allowed_tool_names.len()
1134 }),
1135 ));
1136 if tool_name_looks_like_email_action(&tool_key) {
1137 latest_email_action_note = Some(note.clone());
1138 }
1139 outputs.push(note);
1140 continue;
1141 }
1142 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1143 if blocked_mcp_servers.contains(server) {
1144 rejected_tool_call_in_cycle = true;
1145 outputs.push(format!(
1146 "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
1147 tool_key, server
1148 ));
1149 continue;
1150 }
1151 }
1152 if tool_key == "question" {
1153 question_tool_used = true;
1154 }
1155 if tool_key == "pack_builder" && pack_builder_executed {
1156 rejected_tool_call_in_cycle = true;
1157 outputs.push(
1158 "Tool `pack_builder` call skipped: already executed in this run. Provide a final response or ask any required follow-up question."
1159 .to_string(),
1160 );
1161 continue;
1162 }
1163 if websearch_query_blocked && tool_key == "websearch" {
1164 rejected_tool_call_in_cycle = true;
1165 outputs.push(
1166 "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
1167 .to_string(),
1168 );
1169 continue;
1170 }
1171 let mut effective_args = args.clone();
1172 if tool_key == "todo_write" {
1173 effective_args = normalize_todo_write_args(effective_args, &completion);
1174 if is_empty_todo_write_args(&effective_args) {
1175 rejected_tool_call_in_cycle = true;
1176 outputs.push(
1177 "Tool `todo_write` call skipped: empty todo payload."
1178 .to_string(),
1179 );
1180 continue;
1181 }
1182 }
1183 let signature = if tool_key == "batch" {
1184 batch_tool_signature(&args)
1185 .unwrap_or_else(|| tool_signature(&tool_key, &args))
1186 } else {
1187 tool_signature(&tool_key, &args)
1188 };
1189 if is_shell_tool_name(&tool_key)
1190 && shell_mismatch_signatures.contains(&signature)
1191 {
1192 rejected_tool_call_in_cycle = true;
1193 outputs.push(
1194 "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
1195 .to_string(),
1196 );
1197 continue;
1198 }
1199 let mut signature_count = 1usize;
1200 if is_read_only_tool(&tool_key)
1201 || (tool_key == "batch" && is_read_only_batch_call(&args))
1202 {
1203 let count = readonly_signature_counts
1204 .entry(signature.clone())
1205 .and_modify(|v| *v = v.saturating_add(1))
1206 .or_insert(1);
1207 signature_count = *count;
1208 if tool_key == "websearch" {
1209 if let Some(limit) = websearch_duplicate_signature_limit {
1210 if *count > limit {
1211 rejected_tool_call_in_cycle = true;
1212 self.event_bus.publish(EngineEvent::new(
1213 "tool.loop_guard.triggered",
1214 json!({
1215 "sessionID": session_id,
1216 "messageID": user_message_id,
1217 "tool": tool_key,
1218 "reason": "duplicate_signature_retry_exhausted",
1219 "duplicateLimit": limit,
1220 "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
1221 "loop_guard_triggered": true
1222 }),
1223 ));
1224 outputs.push(
1225 "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
1226 .to_string(),
1227 );
1228 continue;
1229 }
1230 }
1231 }
1232 if tool_key != "websearch" && *count > 1 {
1233 rejected_tool_call_in_cycle = true;
1234 if let Some(cached) = readonly_tool_cache.get(&signature) {
1235 outputs.push(cached.clone());
1236 } else {
1237 outputs.push(format!(
1238 "Tool `{}` call skipped: duplicate call signature detected.",
1239 tool_key
1240 ));
1241 }
1242 continue;
1243 }
1244 }
1245 let is_read_only_signature = is_read_only_tool(&tool_key)
1246 || (tool_key == "batch" && is_read_only_batch_call(&args));
1247 if !is_read_only_signature {
1248 let duplicate_limit = duplicate_signature_limit_for(&tool_key);
1249 let seen = mutable_signature_counts
1250 .entry(signature.clone())
1251 .and_modify(|v| *v = v.saturating_add(1))
1252 .or_insert(1);
1253 if *seen > duplicate_limit {
1254 rejected_tool_call_in_cycle = true;
1255 self.event_bus.publish(EngineEvent::new(
1256 "tool.loop_guard.triggered",
1257 json!({
1258 "sessionID": session_id,
1259 "messageID": user_message_id,
1260 "tool": tool_key,
1261 "reason": "duplicate_signature_retry_exhausted",
1262 "signatureHash": stable_hash(&signature),
1263 "duplicateLimit": duplicate_limit,
1264 "loop_guard_triggered": true
1265 }),
1266 ));
1267 outputs.push(format!(
1268 "Tool `{}` call skipped: duplicate call signature retry limit reached ({}).",
1269 tool_key, duplicate_limit
1270 ));
1271 duplicate_signature_hit_in_cycle = true;
1272 continue;
1273 }
1274 }
1275 let budget = tool_budget_for(&tool_key);
1276 let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
1277 if *entry >= budget {
1278 rejected_tool_call_in_cycle = true;
1279 outputs.push(format!(
1280 "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
1281 tool_key, budget
1282 ));
1283 guard_budget_hit_in_cycle = true;
1284 continue;
1285 }
1286 let mut finalized_part = WireMessagePart::tool_invocation(
1287 &session_id,
1288 &user_message_id,
1289 tool.clone(),
1290 effective_args.clone(),
1291 );
1292 if let Some(call_id) = call_id.clone() {
1293 finalized_part.id = Some(call_id);
1294 }
1295 finalized_part.state = Some("pending".to_string());
1296 self.event_bus.publish(EngineEvent::new(
1297 "message.part.updated",
1298 json!({"part": finalized_part}),
1299 ));
1300 *entry += 1;
1301 accepted_tool_calls_in_cycle =
1302 accepted_tool_calls_in_cycle.saturating_add(1);
1303 if let Some(output) = self
1304 .execute_tool_with_permission(
1305 &session_id,
1306 &user_message_id,
1307 tool,
1308 effective_args,
1309 call_id,
1310 active_agent.skills.as_deref(),
1311 &text,
1312 requested_write_required,
1313 Some(&completion),
1314 cancel.clone(),
1315 )
1316 .await?
1317 {
1318 let productive = is_productive_tool_output(&tool_key, &output);
1319 if output.contains("WEBSEARCH_QUERY_MISSING") {
1320 websearch_query_blocked = true;
1321 }
1322 if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
1323 {
1324 shell_mismatch_signatures.insert(signature.clone());
1325 }
1326 if is_read_only_tool(&tool_key)
1327 && tool_key != "websearch"
1328 && signature_count == 1
1329 {
1330 readonly_tool_cache.insert(signature, output.clone());
1331 }
1332 if productive {
1333 productive_tool_calls_total =
1334 productive_tool_calls_total.saturating_add(1);
1335 if is_workspace_write_tool(&tool_key) {
1336 productive_write_tool_calls_total =
1337 productive_write_tool_calls_total.saturating_add(1);
1338 }
1339 if is_workspace_inspection_tool(&tool_key) {
1340 productive_workspace_inspection_total =
1341 productive_workspace_inspection_total.saturating_add(1);
1342 }
1343 if tool_key == "read" {
1344 productive_concrete_read_total =
1345 productive_concrete_read_total.saturating_add(1);
1346 }
1347 if is_web_research_tool(&tool_key) {
1348 productive_web_research_total =
1349 productive_web_research_total.saturating_add(1);
1350 if is_successful_web_research_output(&tool_key, &output) {
1351 successful_web_research_total =
1352 successful_web_research_total.saturating_add(1);
1353 }
1354 }
1355 executed_productive_tool = true;
1356 if tool_key == "pack_builder" {
1357 pack_builder_executed = true;
1358 }
1359 }
1360 if tool_name_looks_like_email_action(&tool_key) {
1361 if productive {
1362 email_action_executed = true;
1363 } else {
1364 latest_email_action_note =
1365 Some(truncate_text(&output, 280).replace('\n', " "));
1366 }
1367 }
1368 if is_auth_required_tool_output(&output) {
1369 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1370 blocked_mcp_servers.insert(server.to_string());
1371 }
1372 auth_required_hit_in_cycle = true;
1373 }
1374 outputs.push(output);
1375 if auth_required_hit_in_cycle {
1376 break;
1377 }
1378 if guard_budget_hit_in_cycle {
1379 break;
1380 }
1381 }
1382 }
1383 if !outputs.is_empty() {
1384 last_tool_outputs = outputs.clone();
1385 if matches!(requested_tool_mode, ToolMode::Required)
1386 && productive_tool_calls_total == 0
1387 {
1388 latest_required_tool_failure_kind = classify_required_tool_failure(
1389 &outputs,
1390 saw_tool_call_candidate,
1391 accepted_tool_calls_in_cycle,
1392 provider_tool_parse_failed,
1393 rejected_tool_call_in_cycle,
1394 );
1395 if requested_write_required
1396 && write_tool_attempted_in_cycle
1397 && productive_write_tool_calls_total == 0
1398 && is_write_invalid_args_failure_kind(
1399 latest_required_tool_failure_kind,
1400 )
1401 {
1402 if required_write_retry_count + 1 < strict_write_retry_max_attempts
1403 {
1404 required_write_retry_count += 1;
1405 required_tool_retry_count += 1;
1406 followup_context = Some(build_write_required_retry_context(
1407 &offered_tool_preview,
1408 latest_required_tool_failure_kind,
1409 &text,
1410 &requested_prewrite_requirements,
1411 productive_workspace_inspection_total > 0,
1412 productive_concrete_read_total > 0,
1413 productive_web_research_total > 0,
1414 successful_web_research_total > 0,
1415 ));
1416 self.event_bus.publish(EngineEvent::new(
1417 "provider.call.iteration.finish",
1418 json!({
1419 "sessionID": session_id,
1420 "messageID": user_message_id,
1421 "iteration": iteration,
1422 "finishReason": "required_write_invalid_retry",
1423 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1424 "rejectedToolCalls": 0,
1425 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1426 }),
1427 ));
1428 continue;
1429 }
1430 }
1431 if !requested_write_required && required_tool_retry_count == 0 {
1432 required_tool_retry_count += 1;
1433 followup_context =
1434 Some(build_required_tool_retry_context_for_task(
1435 &offered_tool_preview,
1436 latest_required_tool_failure_kind,
1437 &text,
1438 ));
1439 self.event_bus.publish(EngineEvent::new(
1440 "provider.call.iteration.finish",
1441 json!({
1442 "sessionID": session_id,
1443 "messageID": user_message_id,
1444 "iteration": iteration,
1445 "finishReason": "required_tool_retry",
1446 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1447 "rejectedToolCalls": 0,
1448 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1449 }),
1450 ));
1451 continue;
1452 }
1453 completion = required_tool_mode_unsatisfied_completion(
1454 latest_required_tool_failure_kind,
1455 );
1456 if !required_tool_unsatisfied_emitted {
1457 required_tool_unsatisfied_emitted = true;
1458 self.event_bus.publish(EngineEvent::new(
1459 "tool.mode.required.unsatisfied",
1460 json!({
1461 "sessionID": session_id,
1462 "messageID": user_message_id,
1463 "iteration": iteration,
1464 "selectedToolCount": allowed_tool_names.len(),
1465 "offeredToolsPreview": offered_tool_preview,
1466 "reason": latest_required_tool_failure_kind.code(),
1467 }),
1468 ));
1469 }
1470 self.event_bus.publish(EngineEvent::new(
1471 "provider.call.iteration.finish",
1472 json!({
1473 "sessionID": session_id,
1474 "messageID": user_message_id,
1475 "iteration": iteration,
1476 "finishReason": "required_tool_unsatisfied",
1477 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1478 "rejectedToolCalls": 0,
1479 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1480 }),
1481 ));
1482 break;
1483 }
1484 let prewrite_gate = evaluate_prewrite_gate(
1485 requested_write_required,
1486 &requested_prewrite_requirements,
1487 PrewriteProgress {
1488 productive_write_tool_calls_total,
1489 productive_workspace_inspection_total,
1490 productive_concrete_read_total,
1491 productive_web_research_total,
1492 successful_web_research_total,
1493 required_write_retry_count,
1494 unmet_prewrite_repair_retry_count,
1495 prewrite_gate_waived,
1496 },
1497 );
1498 let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1499 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1500 if requested_write_required
1501 && productive_tool_calls_total > 0
1502 && productive_write_tool_calls_total == 0
1503 {
1504 if should_start_prewrite_repair_before_first_write(
1505 requested_prewrite_requirements.repair_on_unmet_requirements,
1506 productive_write_tool_calls_total,
1507 prewrite_satisfied,
1508 code_workflow_requested,
1509 ) {
1510 if unmet_prewrite_repair_retry_count
1511 < prewrite_repair_retry_max_attempts()
1512 {
1513 unmet_prewrite_repair_retry_count += 1;
1514 let repair_attempt = unmet_prewrite_repair_retry_count;
1515 let repair_attempts_remaining =
1516 prewrite_repair_retry_max_attempts()
1517 .saturating_sub(repair_attempt);
1518 followup_context = Some(build_prewrite_repair_retry_context(
1519 &offered_tool_preview,
1520 latest_required_tool_failure_kind,
1521 &text,
1522 &requested_prewrite_requirements,
1523 productive_workspace_inspection_total > 0,
1524 productive_concrete_read_total > 0,
1525 productive_web_research_total > 0,
1526 successful_web_research_total > 0,
1527 ));
1528 self.event_bus.publish(EngineEvent::new(
1529 "provider.call.iteration.finish",
1530 json!({
1531 "sessionID": session_id,
1532 "messageID": user_message_id,
1533 "iteration": iteration,
1534 "finishReason": "prewrite_repair_retry",
1535 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1536 "rejectedToolCalls": 0,
1537 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1538 "repair": prewrite_repair_event_payload(
1539 repair_attempt,
1540 repair_attempts_remaining,
1541 &unmet_prewrite_codes,
1542 false,
1543 ),
1544 }),
1545 ));
1546 continue;
1547 }
1548 if !prewrite_gate_waived {
1549 prewrite_gate_waived = true;
1550 let repair_attempt = unmet_prewrite_repair_retry_count;
1551 let repair_attempts_remaining =
1552 prewrite_repair_retry_max_attempts()
1553 .saturating_sub(repair_attempt);
1554 followup_context = Some(build_prewrite_waived_write_context(
1555 &text,
1556 &unmet_prewrite_codes,
1557 ));
1558 self.event_bus.publish(EngineEvent::new(
1559 "provider.call.iteration.finish",
1560 json!({
1561 "sessionID": session_id,
1562 "messageID": user_message_id,
1563 "iteration": iteration,
1564 "finishReason": "prewrite_gate_waived",
1565 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1566 "rejectedToolCalls": 0,
1567 "prewriteGateWaived": true,
1568 "repair": prewrite_repair_event_payload(
1569 repair_attempt,
1570 repair_attempts_remaining,
1571 &unmet_prewrite_codes,
1572 true,
1573 ),
1574 }),
1575 ));
1576 continue;
1577 }
1578 }
1579 latest_required_tool_failure_kind =
1580 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1581 if required_write_retry_count + 1 < strict_write_retry_max_attempts {
1582 required_write_retry_count += 1;
1583 followup_context = Some(build_write_required_retry_context(
1584 &offered_tool_preview,
1585 latest_required_tool_failure_kind,
1586 &text,
1587 &requested_prewrite_requirements,
1588 productive_workspace_inspection_total > 0,
1589 productive_concrete_read_total > 0,
1590 productive_web_research_total > 0,
1591 successful_web_research_total > 0,
1592 ));
1593 self.event_bus.publish(EngineEvent::new(
1594 "provider.call.iteration.finish",
1595 json!({
1596 "sessionID": session_id,
1597 "messageID": user_message_id,
1598 "iteration": iteration,
1599 "finishReason": "required_write_retry",
1600 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1601 "rejectedToolCalls": 0,
1602 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1603 }),
1604 ));
1605 continue;
1606 }
1607 completion = required_tool_mode_unsatisfied_completion(
1608 latest_required_tool_failure_kind,
1609 );
1610 if !required_tool_unsatisfied_emitted {
1611 required_tool_unsatisfied_emitted = true;
1612 self.event_bus.publish(EngineEvent::new(
1613 "tool.mode.required.unsatisfied",
1614 json!({
1615 "sessionID": session_id,
1616 "messageID": user_message_id,
1617 "iteration": iteration,
1618 "selectedToolCount": allowed_tool_names.len(),
1619 "offeredToolsPreview": offered_tool_preview,
1620 "reason": latest_required_tool_failure_kind.code(),
1621 }),
1622 ));
1623 }
1624 self.event_bus.publish(EngineEvent::new(
1625 "provider.call.iteration.finish",
1626 json!({
1627 "sessionID": session_id,
1628 "messageID": user_message_id,
1629 "iteration": iteration,
1630 "finishReason": "required_write_unsatisfied",
1631 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1632 "rejectedToolCalls": 0,
1633 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1634 }),
1635 ));
1636 break;
1637 }
1638 if invalid_tool_args_retry_count < invalid_tool_args_retry_max_attempts() {
1639 if let Some(retry_context) =
1640 build_invalid_tool_args_retry_context_from_outputs(
1641 &outputs,
1642 invalid_tool_args_retry_count,
1643 )
1644 {
1645 invalid_tool_args_retry_count += 1;
1646 followup_context = Some(format!(
1647 "Previous tool call arguments were invalid. {}",
1648 retry_context
1649 ));
1650 self.event_bus.publish(EngineEvent::new(
1651 "provider.call.iteration.finish",
1652 json!({
1653 "sessionID": session_id,
1654 "messageID": user_message_id,
1655 "iteration": iteration,
1656 "finishReason": "invalid_tool_args_retry",
1657 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1658 "rejectedToolCalls": 0,
1659 }),
1660 ));
1661 continue;
1662 }
1663 }
1664 let guard_budget_hit =
1665 outputs.iter().any(|o| is_guard_budget_tool_output(o));
1666 if executed_productive_tool {
1667 let prewrite_gate = evaluate_prewrite_gate(
1668 requested_write_required,
1669 &requested_prewrite_requirements,
1670 PrewriteProgress {
1671 productive_write_tool_calls_total,
1672 productive_workspace_inspection_total,
1673 productive_concrete_read_total,
1674 productive_web_research_total,
1675 successful_web_research_total,
1676 required_write_retry_count,
1677 unmet_prewrite_repair_retry_count,
1678 prewrite_gate_waived,
1679 },
1680 );
1681 let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1682 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1683 if requested_write_required
1684 && productive_write_tool_calls_total > 0
1685 && requested_prewrite_requirements.repair_on_unmet_requirements
1686 && unmet_prewrite_repair_retry_count
1687 < prewrite_repair_retry_max_attempts()
1688 && !prewrite_satisfied
1689 {
1690 unmet_prewrite_repair_retry_count += 1;
1691 let repair_attempt = unmet_prewrite_repair_retry_count;
1692 let repair_attempts_remaining =
1693 prewrite_repair_retry_max_attempts()
1694 .saturating_sub(repair_attempt);
1695 followup_context = Some(build_prewrite_repair_retry_context(
1696 &offered_tool_preview,
1697 latest_required_tool_failure_kind,
1698 &text,
1699 &requested_prewrite_requirements,
1700 productive_workspace_inspection_total > 0,
1701 productive_concrete_read_total > 0,
1702 productive_web_research_total > 0,
1703 successful_web_research_total > 0,
1704 ));
1705 self.event_bus.publish(EngineEvent::new(
1706 "provider.call.iteration.finish",
1707 json!({
1708 "sessionID": session_id,
1709 "messageID": user_message_id,
1710 "iteration": iteration,
1711 "finishReason": "prewrite_repair_retry",
1712 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1713 "rejectedToolCalls": 0,
1714 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1715 "repair": prewrite_repair_event_payload(
1716 repair_attempt,
1717 repair_attempts_remaining,
1718 &unmet_prewrite_codes,
1719 false,
1720 ),
1721 }),
1722 ));
1723 continue;
1724 }
1725 followup_context = Some(format!(
1726 "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
1727 summarize_tool_outputs(&outputs)
1728 ));
1729 self.event_bus.publish(EngineEvent::new(
1730 "provider.call.iteration.finish",
1731 json!({
1732 "sessionID": session_id,
1733 "messageID": user_message_id,
1734 "iteration": iteration,
1735 "finishReason": "tool_followup",
1736 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1737 "rejectedToolCalls": 0,
1738 }),
1739 ));
1740 continue;
1741 }
1742 if guard_budget_hit {
1743 completion = summarize_guard_budget_outputs(&outputs)
1744 .unwrap_or_else(|| {
1745 "This run hit the per-run tool guard budget, so tool execution was paused to avoid retries. Send a new message to start a fresh run.".to_string()
1746 });
1747 } else if duplicate_signature_hit_in_cycle {
1748 completion = summarize_duplicate_signature_outputs(&outputs)
1749 .unwrap_or_else(|| {
1750 "This run paused because the same tool call kept repeating. Rephrase the request or provide a different command target and retry.".to_string()
1751 });
1752 } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
1753 completion = summary;
1754 } else {
1755 completion.clear();
1756 }
1757 self.event_bus.publish(EngineEvent::new(
1758 "provider.call.iteration.finish",
1759 json!({
1760 "sessionID": session_id,
1761 "messageID": user_message_id,
1762 "iteration": iteration,
1763 "finishReason": "tool_summary",
1764 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1765 "rejectedToolCalls": 0,
1766 }),
1767 ));
1768 break;
1769 } else if matches!(requested_tool_mode, ToolMode::Required) {
1770 latest_required_tool_failure_kind = classify_required_tool_failure(
1771 &outputs,
1772 saw_tool_call_candidate,
1773 accepted_tool_calls_in_cycle,
1774 provider_tool_parse_failed,
1775 rejected_tool_call_in_cycle,
1776 );
1777 }
1778 }
1779
1780 if let Some(usage) = provider_usage {
1781 self.event_bus.publish(EngineEvent::new(
1782 "provider.usage",
1783 json!({
1784 "sessionID": session_id,
1785 "messageID": user_message_id,
1786 "promptTokens": usage.prompt_tokens,
1787 "completionTokens": usage.completion_tokens,
1788 "totalTokens": usage.total_tokens,
1789 }),
1790 ));
1791 }
1792
1793 if matches!(requested_tool_mode, ToolMode::Required)
1794 && productive_tool_calls_total == 0
1795 {
1796 if requested_write_required
1797 && required_write_retry_count > 0
1798 && productive_write_tool_calls_total == 0
1799 && !is_write_invalid_args_failure_kind(latest_required_tool_failure_kind)
1800 {
1801 latest_required_tool_failure_kind =
1802 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1803 }
1804 if requested_write_required
1805 && required_write_retry_count + 1 < strict_write_retry_max_attempts
1806 {
1807 required_write_retry_count += 1;
1808 followup_context = Some(build_write_required_retry_context(
1809 &offered_tool_preview,
1810 latest_required_tool_failure_kind,
1811 &text,
1812 &requested_prewrite_requirements,
1813 productive_workspace_inspection_total > 0,
1814 productive_concrete_read_total > 0,
1815 productive_web_research_total > 0,
1816 successful_web_research_total > 0,
1817 ));
1818 continue;
1819 }
1820 if !requested_write_required && required_tool_retry_count == 0 {
1821 required_tool_retry_count += 1;
1822 followup_context = Some(build_required_tool_retry_context_for_task(
1823 &offered_tool_preview,
1824 latest_required_tool_failure_kind,
1825 &text,
1826 ));
1827 continue;
1828 }
1829 completion = required_tool_mode_unsatisfied_completion(
1830 latest_required_tool_failure_kind,
1831 );
1832 if !required_tool_unsatisfied_emitted {
1833 required_tool_unsatisfied_emitted = true;
1834 self.event_bus.publish(EngineEvent::new(
1835 "tool.mode.required.unsatisfied",
1836 json!({
1837 "sessionID": session_id,
1838 "messageID": user_message_id,
1839 "iteration": iteration,
1840 "selectedToolCount": allowed_tool_names.len(),
1841 "offeredToolsPreview": offered_tool_preview,
1842 "reason": latest_required_tool_failure_kind.code(),
1843 }),
1844 ));
1845 }
1846 self.event_bus.publish(EngineEvent::new(
1847 "provider.call.iteration.finish",
1848 json!({
1849 "sessionID": session_id,
1850 "messageID": user_message_id,
1851 "iteration": iteration,
1852 "finishReason": "required_tool_unsatisfied",
1853 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1854 "rejectedToolCalls": 0,
1855 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1856 }),
1857 ));
1858 } else {
1859 if completion.trim().is_empty()
1860 && !last_tool_outputs.is_empty()
1861 && requested_write_required
1862 && empty_completion_retry_count == 0
1863 {
1864 empty_completion_retry_count += 1;
1865 followup_context = Some(build_empty_completion_retry_context(
1866 &offered_tool_preview,
1867 &text,
1868 &requested_prewrite_requirements,
1869 productive_workspace_inspection_total > 0,
1870 productive_concrete_read_total > 0,
1871 productive_web_research_total > 0,
1872 successful_web_research_total > 0,
1873 ));
1874 self.event_bus.publish(EngineEvent::new(
1875 "provider.call.iteration.finish",
1876 json!({
1877 "sessionID": session_id,
1878 "messageID": user_message_id,
1879 "iteration": iteration,
1880 "finishReason": "empty_completion_retry",
1881 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1882 "rejectedToolCalls": 0,
1883 }),
1884 ));
1885 continue;
1886 }
1887 let prewrite_gate = evaluate_prewrite_gate(
1888 requested_write_required,
1889 &requested_prewrite_requirements,
1890 PrewriteProgress {
1891 productive_write_tool_calls_total,
1892 productive_workspace_inspection_total,
1893 productive_concrete_read_total,
1894 productive_web_research_total,
1895 successful_web_research_total,
1896 required_write_retry_count,
1897 unmet_prewrite_repair_retry_count,
1898 prewrite_gate_waived,
1899 },
1900 );
1901 if should_start_prewrite_repair_before_first_write(
1902 requested_prewrite_requirements.repair_on_unmet_requirements,
1903 productive_write_tool_calls_total,
1904 prewrite_gate.prewrite_satisfied,
1905 code_workflow_requested,
1906 ) && !prewrite_gate_waived
1907 {
1908 let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1909 if unmet_prewrite_repair_retry_count < prewrite_repair_retry_max_attempts()
1910 {
1911 unmet_prewrite_repair_retry_count += 1;
1912 let repair_attempt = unmet_prewrite_repair_retry_count;
1913 let repair_attempts_remaining =
1914 prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
1915 followup_context = Some(build_prewrite_repair_retry_context(
1916 &offered_tool_preview,
1917 latest_required_tool_failure_kind,
1918 &text,
1919 &requested_prewrite_requirements,
1920 productive_workspace_inspection_total > 0,
1921 productive_concrete_read_total > 0,
1922 productive_web_research_total > 0,
1923 successful_web_research_total > 0,
1924 ));
1925 self.event_bus.publish(EngineEvent::new(
1926 "provider.call.iteration.finish",
1927 json!({
1928 "sessionID": session_id,
1929 "messageID": user_message_id,
1930 "iteration": iteration,
1931 "finishReason": "prewrite_repair_retry",
1932 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1933 "rejectedToolCalls": 0,
1934 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1935 "repair": prewrite_repair_event_payload(
1936 repair_attempt,
1937 repair_attempts_remaining,
1938 &unmet_prewrite_codes,
1939 false,
1940 ),
1941 }),
1942 ));
1943 continue;
1944 }
1945 prewrite_gate_waived = true;
1946 let repair_attempt = unmet_prewrite_repair_retry_count;
1947 let repair_attempts_remaining =
1948 prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
1949 followup_context = Some(build_prewrite_waived_write_context(
1950 &text,
1951 &unmet_prewrite_codes,
1952 ));
1953 self.event_bus.publish(EngineEvent::new(
1954 "provider.call.iteration.finish",
1955 json!({
1956 "sessionID": session_id,
1957 "messageID": user_message_id,
1958 "iteration": iteration,
1959 "finishReason": "prewrite_gate_waived",
1960 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1961 "rejectedToolCalls": 0,
1962 "prewriteGateWaived": true,
1963 "repair": prewrite_repair_event_payload(
1964 repair_attempt,
1965 repair_attempts_remaining,
1966 &unmet_prewrite_codes,
1967 true,
1968 ),
1969 }),
1970 ));
1971 continue;
1972 }
1973 if prewrite_gate_waived
1974 && requested_write_required
1975 && productive_write_tool_calls_total == 0
1976 && required_write_retry_count + 1 < strict_write_retry_max_attempts
1977 {
1978 required_write_retry_count += 1;
1979 followup_context = Some(build_write_required_retry_context(
1980 &offered_tool_preview,
1981 latest_required_tool_failure_kind,
1982 &text,
1983 &requested_prewrite_requirements,
1984 productive_workspace_inspection_total > 0,
1985 productive_concrete_read_total > 0,
1986 productive_web_research_total > 0,
1987 successful_web_research_total > 0,
1988 ));
1989 self.event_bus.publish(EngineEvent::new(
1990 "provider.call.iteration.finish",
1991 json!({
1992 "sessionID": session_id,
1993 "messageID": user_message_id,
1994 "iteration": iteration,
1995 "finishReason": "waived_write_retry",
1996 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1997 "rejectedToolCalls": 0,
1998 }),
1999 ));
2000 continue;
2001 }
2002 self.event_bus.publish(EngineEvent::new(
2003 "provider.call.iteration.finish",
2004 json!({
2005 "sessionID": session_id,
2006 "messageID": user_message_id,
2007 "iteration": iteration,
2008 "finishReason": "provider_completion",
2009 "acceptedToolCalls": accepted_tool_calls_in_cycle,
2010 "rejectedToolCalls": 0,
2011 }),
2012 ));
2013 }
2014 break;
2015 }
2016 if matches!(requested_tool_mode, ToolMode::Required) && productive_tool_calls_total == 0
2017 {
2018 completion =
2019 required_tool_mode_unsatisfied_completion(latest_required_tool_failure_kind);
2020 if !required_tool_unsatisfied_emitted {
2021 self.event_bus.publish(EngineEvent::new(
2022 "tool.mode.required.unsatisfied",
2023 json!({
2024 "sessionID": session_id,
2025 "messageID": user_message_id,
2026 "selectedToolCount": tool_call_counts.len(),
2027 "reason": latest_required_tool_failure_kind.code(),
2028 }),
2029 ));
2030 }
2031 }
2032 if completion.trim().is_empty()
2033 && !last_tool_outputs.is_empty()
2034 && requested_write_required
2035 && productive_write_tool_calls_total > 0
2036 {
2037 let final_prewrite_satisfied = evaluate_prewrite_gate(
2038 requested_write_required,
2039 &requested_prewrite_requirements,
2040 PrewriteProgress {
2041 productive_write_tool_calls_total,
2042 productive_workspace_inspection_total,
2043 productive_concrete_read_total,
2044 productive_web_research_total,
2045 successful_web_research_total,
2046 required_write_retry_count,
2047 unmet_prewrite_repair_retry_count,
2048 prewrite_gate_waived,
2049 },
2050 )
2051 .prewrite_satisfied;
2052 completion = synthesize_artifact_write_completion_from_tool_state(
2053 &text,
2054 final_prewrite_satisfied,
2055 prewrite_gate_waived,
2056 );
2057 }
2058 if completion.trim().is_empty()
2059 && !last_tool_outputs.is_empty()
2060 && should_generate_post_tool_final_narrative(
2061 requested_tool_mode,
2062 productive_tool_calls_total,
2063 )
2064 {
2065 if let Some(narrative) = self
2066 .generate_final_narrative_without_tools(
2067 &session_id,
2068 &active_agent,
2069 Some(provider_id.as_str()),
2070 Some(model_id_value.as_str()),
2071 cancel.clone(),
2072 &last_tool_outputs,
2073 )
2074 .await
2075 {
2076 completion = narrative;
2077 }
2078 }
2079 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
2080 let preview = last_tool_outputs
2081 .iter()
2082 .take(3)
2083 .map(|o| truncate_text(o, 240))
2084 .collect::<Vec<_>>()
2085 .join("\n");
2086 completion = format!(
2087 "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
2088 preview
2089 );
2090 }
2091 if completion.trim().is_empty() {
2092 completion =
2093 "I couldn't produce a final response for that run. Please retry your request."
2094 .to_string();
2095 }
2096 if email_delivery_requested
2097 && !email_action_executed
2098 && completion_claims_email_sent(&completion)
2099 {
2100 let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
2101 .to_string();
2102 if let Some(note) = latest_email_action_note.as_ref() {
2103 fallback.push_str("\n\nLast email tool status: ");
2104 fallback.push_str(note);
2105 }
2106 fallback.push_str(
2107 "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
2108 );
2109 completion = fallback;
2110 }
2111 completion = strip_model_control_markers(&completion);
2112 truncate_text(&completion, 16_000)
2113 };
2114 emit_event(
2115 Level::INFO,
2116 ProcessKind::Engine,
2117 ObservabilityEvent {
2118 event: "provider.call.finish",
2119 component: "engine.loop",
2120 correlation_id: correlation_ref,
2121 session_id: Some(&session_id),
2122 run_id: None,
2123 message_id: Some(&user_message_id),
2124 provider_id: Some(provider_id.as_str()),
2125 model_id,
2126 status: Some("ok"),
2127 error_code: None,
2128 detail: Some("provider stream complete"),
2129 },
2130 );
2131 if active_agent.name.eq_ignore_ascii_case("plan") {
2132 emit_plan_todo_fallback(
2133 self.storage.clone(),
2134 &self.event_bus,
2135 &session_id,
2136 &user_message_id,
2137 &completion,
2138 )
2139 .await;
2140 let todos_after_fallback = self.storage.get_todos(&session_id).await;
2141 if todos_after_fallback.is_empty() && !question_tool_used {
2142 emit_plan_question_fallback(
2143 self.storage.clone(),
2144 &self.event_bus,
2145 &session_id,
2146 &user_message_id,
2147 &completion,
2148 )
2149 .await;
2150 }
2151 }
2152 if cancel.is_cancelled() {
2153 self.event_bus.publish(EngineEvent::new(
2154 "session.status",
2155 json!({"sessionID": session_id, "status":"cancelled"}),
2156 ));
2157 self.cancellations.remove(&session_id).await;
2158 return Ok(());
2159 }
2160 let assistant = Message::new(
2161 MessageRole::Assistant,
2162 vec![MessagePart::Text {
2163 text: completion.clone(),
2164 }],
2165 );
2166 let assistant_message_id = assistant.id.clone();
2167 self.storage.append_message(&session_id, assistant).await?;
2168 let final_part = WireMessagePart::text(
2169 &session_id,
2170 &assistant_message_id,
2171 truncate_text(&completion, 16_000),
2172 );
2173 self.event_bus.publish(EngineEvent::new(
2174 "message.part.updated",
2175 json!({"part": final_part}),
2176 ));
2177 self.event_bus.publish(EngineEvent::new(
2178 "session.updated",
2179 json!({"sessionID": session_id, "status":"idle"}),
2180 ));
2181 self.event_bus.publish(EngineEvent::new(
2182 "session.status",
2183 json!({"sessionID": session_id, "status":"idle"}),
2184 ));
2185 self.cancellations.remove(&session_id).await;
2186 Ok(())
2187 }
2188
2189 pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
2190 self.providers.default_complete(&prompt).await
2191 }
2192
2193 pub async fn run_oneshot_for_provider(
2194 &self,
2195 prompt: String,
2196 provider_id: Option<&str>,
2197 ) -> anyhow::Result<String> {
2198 self.providers
2199 .complete_for_provider(provider_id, &prompt, None)
2200 .await
2201 }
2202
2203 #[allow(clippy::too_many_arguments)]
2204 async fn execute_tool_with_permission(
2205 &self,
2206 session_id: &str,
2207 message_id: &str,
2208 tool: String,
2209 args: Value,
2210 initial_tool_call_id: Option<String>,
2211 equipped_skills: Option<&[String]>,
2212 latest_user_text: &str,
2213 write_required: bool,
2214 latest_assistant_context: Option<&str>,
2215 cancel: CancellationToken,
2216 ) -> anyhow::Result<Option<String>> {
2217 let tool = normalize_tool_name(&tool);
2218 let raw_args = args.clone();
2219 let publish_tool_effect = |tool_call_id: Option<&str>,
2220 phase: ToolEffectLedgerPhase,
2221 status: ToolEffectLedgerStatus,
2222 args: &Value,
2223 metadata: Option<&Value>,
2224 output: Option<&str>,
2225 error: Option<&str>| {
2226 self.event_bus
2227 .publish(tool_effect_ledger_event(build_tool_effect_ledger_record(
2228 session_id,
2229 message_id,
2230 tool_call_id,
2231 &tool,
2232 phase,
2233 status,
2234 args,
2235 metadata,
2236 output,
2237 error,
2238 )));
2239 };
2240 let normalized = normalize_tool_args_with_mode(
2241 &tool,
2242 args,
2243 latest_user_text,
2244 latest_assistant_context.unwrap_or_default(),
2245 if write_required {
2246 WritePathRecoveryMode::OutputTargetOnly
2247 } else {
2248 WritePathRecoveryMode::Heuristic
2249 },
2250 );
2251 let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
2252 let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
2253 self.event_bus.publish(EngineEvent::new(
2254 "tool.args.normalized",
2255 json!({
2256 "sessionID": session_id,
2257 "messageID": message_id,
2258 "tool": tool,
2259 "argsSource": normalized.args_source,
2260 "argsIntegrity": normalized.args_integrity,
2261 "rawArgsState": normalized.raw_args_state.as_str(),
2262 "rawArgsPreview": raw_args_preview,
2263 "normalizedArgsPreview": normalized_args_preview,
2264 "query": normalized.query,
2265 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2266 "requestID": Value::Null
2267 }),
2268 ));
2269 if normalized.args_integrity == "recovered" {
2270 self.event_bus.publish(EngineEvent::new(
2271 "tool.args.recovered",
2272 json!({
2273 "sessionID": session_id,
2274 "messageID": message_id,
2275 "tool": tool,
2276 "argsSource": normalized.args_source,
2277 "rawArgsPreview": raw_args_preview,
2278 "normalizedArgsPreview": normalized_args_preview,
2279 "query": normalized.query,
2280 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2281 "requestID": Value::Null
2282 }),
2283 ));
2284 }
2285 if normalized.missing_terminal {
2286 let missing_reason = normalized
2287 .missing_terminal_reason
2288 .clone()
2289 .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
2290 let latest_user_preview = truncate_text(latest_user_text, 500);
2291 let latest_assistant_preview =
2292 truncate_text(latest_assistant_context.unwrap_or_default(), 500);
2293 self.event_bus.publish(EngineEvent::new(
2294 "tool.args.missing_terminal",
2295 json!({
2296 "sessionID": session_id,
2297 "messageID": message_id,
2298 "tool": tool,
2299 "argsSource": normalized.args_source,
2300 "argsIntegrity": normalized.args_integrity,
2301 "rawArgsState": normalized.raw_args_state.as_str(),
2302 "requestID": Value::Null,
2303 "error": missing_reason,
2304 "rawArgsPreview": raw_args_preview,
2305 "normalizedArgsPreview": normalized_args_preview,
2306 "latestUserPreview": latest_user_preview,
2307 "latestAssistantPreview": latest_assistant_preview,
2308 }),
2309 ));
2310 if tool == "write" {
2311 tracing::warn!(
2312 session_id = %session_id,
2313 message_id = %message_id,
2314 tool = %tool,
2315 reason = %missing_reason,
2316 args_source = %normalized.args_source,
2317 args_integrity = %normalized.args_integrity,
2318 raw_args_state = %normalized.raw_args_state.as_str(),
2319 raw_args = %raw_args_preview,
2320 normalized_args = %normalized_args_preview,
2321 latest_user = %latest_user_preview,
2322 latest_assistant = %latest_assistant_preview,
2323 "write tool arguments missing terminal field"
2324 );
2325 }
2326 let best_effort_args = persisted_failed_tool_args(&raw_args, &normalized.args);
2327 let mut failed_part = WireMessagePart::tool_result(
2328 session_id,
2329 message_id,
2330 tool.clone(),
2331 Some(best_effort_args),
2332 json!(null),
2333 );
2334 failed_part.state = Some("failed".to_string());
2335 let surfaced_reason =
2336 provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
2337 .unwrap_or_else(|| missing_reason.clone());
2338 failed_part.error = Some(surfaced_reason.clone());
2339 self.event_bus.publish(EngineEvent::new(
2340 "message.part.updated",
2341 json!({"part": failed_part}),
2342 ));
2343 publish_tool_effect(
2344 None,
2345 ToolEffectLedgerPhase::Outcome,
2346 ToolEffectLedgerStatus::Blocked,
2347 &normalized.args,
2348 None,
2349 None,
2350 Some(&surfaced_reason),
2351 );
2352 return Ok(Some(surfaced_reason));
2353 }
2354
2355 let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
2356 Ok(args) => args,
2357 Err(message) => {
2358 publish_tool_effect(
2359 None,
2360 ToolEffectLedgerPhase::Outcome,
2361 ToolEffectLedgerStatus::Blocked,
2362 &raw_args,
2363 None,
2364 None,
2365 Some(&message),
2366 );
2367 return Ok(Some(message));
2368 }
2369 };
2370 if let Some(allowed_tools) = self
2371 .session_allowed_tools
2372 .read()
2373 .await
2374 .get(session_id)
2375 .cloned()
2376 {
2377 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
2378 let reason = format!("Tool `{tool}` is not allowed for this run.");
2379 publish_tool_effect(
2380 None,
2381 ToolEffectLedgerPhase::Outcome,
2382 ToolEffectLedgerStatus::Blocked,
2383 &args,
2384 None,
2385 None,
2386 Some(&reason),
2387 );
2388 return Ok(Some(reason));
2389 }
2390 }
2391 if let Some(hook) = self.tool_policy_hook.read().await.clone() {
2392 let decision = hook
2393 .evaluate_tool(ToolPolicyContext {
2394 session_id: session_id.to_string(),
2395 message_id: message_id.to_string(),
2396 tool: tool.clone(),
2397 args: args.clone(),
2398 })
2399 .await?;
2400 if !decision.allowed {
2401 let reason = decision
2402 .reason
2403 .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
2404 let mut blocked_part = WireMessagePart::tool_result(
2405 session_id,
2406 message_id,
2407 tool.clone(),
2408 Some(args.clone()),
2409 json!(null),
2410 );
2411 blocked_part.state = Some("failed".to_string());
2412 blocked_part.error = Some(reason.clone());
2413 self.event_bus.publish(EngineEvent::new(
2414 "message.part.updated",
2415 json!({"part": blocked_part}),
2416 ));
2417 publish_tool_effect(
2418 None,
2419 ToolEffectLedgerPhase::Outcome,
2420 ToolEffectLedgerStatus::Blocked,
2421 &args,
2422 None,
2423 None,
2424 Some(&reason),
2425 );
2426 return Ok(Some(reason));
2427 }
2428 }
2429 let mut tool_call_id: Option<String> = initial_tool_call_id;
2430 if let Some(violation) = self
2431 .workspace_sandbox_violation(session_id, &tool, &args)
2432 .await
2433 {
2434 let mut blocked_part = WireMessagePart::tool_result(
2435 session_id,
2436 message_id,
2437 tool.clone(),
2438 Some(args.clone()),
2439 json!(null),
2440 );
2441 blocked_part.state = Some("failed".to_string());
2442 blocked_part.error = Some(violation.clone());
2443 self.event_bus.publish(EngineEvent::new(
2444 "message.part.updated",
2445 json!({"part": blocked_part}),
2446 ));
2447 publish_tool_effect(
2448 tool_call_id.as_deref(),
2449 ToolEffectLedgerPhase::Outcome,
2450 ToolEffectLedgerStatus::Blocked,
2451 &args,
2452 None,
2453 None,
2454 Some(&violation),
2455 );
2456 return Ok(Some(violation));
2457 }
2458 let rule = self
2459 .plugins
2460 .permission_override(&tool)
2461 .await
2462 .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
2463 if matches!(rule, PermissionAction::Deny) {
2464 let reason = format!("Permission denied for tool `{tool}` by policy.");
2465 publish_tool_effect(
2466 tool_call_id.as_deref(),
2467 ToolEffectLedgerPhase::Outcome,
2468 ToolEffectLedgerStatus::Blocked,
2469 &args,
2470 None,
2471 None,
2472 Some(&reason),
2473 );
2474 return Ok(Some(reason));
2475 }
2476
2477 let mut effective_args = args.clone();
2478 if matches!(rule, PermissionAction::Ask) {
2479 let auto_approve_permissions = self
2480 .session_auto_approve_permissions
2481 .read()
2482 .await
2483 .get(session_id)
2484 .copied()
2485 .unwrap_or(false);
2486 if auto_approve_permissions {
2487 self.event_bus.publish(EngineEvent::new(
2488 "permission.auto_approved",
2489 json!({
2490 "sessionID": session_id,
2491 "messageID": message_id,
2492 "tool": tool,
2493 }),
2494 ));
2495 effective_args = args;
2496 } else {
2497 let pending = self
2498 .permissions
2499 .ask_for_session_with_context(
2500 Some(session_id),
2501 &tool,
2502 args.clone(),
2503 Some(crate::PermissionArgsContext {
2504 args_source: normalized.args_source.clone(),
2505 args_integrity: normalized.args_integrity.clone(),
2506 query: normalized.query.clone(),
2507 }),
2508 )
2509 .await;
2510 let mut pending_part = WireMessagePart::tool_invocation(
2511 session_id,
2512 message_id,
2513 tool.clone(),
2514 args.clone(),
2515 );
2516 pending_part.id = Some(pending.id.clone());
2517 tool_call_id = Some(pending.id.clone());
2518 pending_part.state = Some("pending".to_string());
2519 self.event_bus.publish(EngineEvent::new(
2520 "message.part.updated",
2521 json!({"part": pending_part}),
2522 ));
2523 let reply = self
2524 .permissions
2525 .wait_for_reply_with_timeout(
2526 &pending.id,
2527 cancel.clone(),
2528 Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2529 )
2530 .await;
2531 let (reply, timed_out) = reply;
2532 if cancel.is_cancelled() {
2533 return Ok(None);
2534 }
2535 if timed_out {
2536 let timeout_ms = permission_wait_timeout_ms();
2537 self.event_bus.publish(EngineEvent::new(
2538 "permission.wait.timeout",
2539 json!({
2540 "sessionID": session_id,
2541 "messageID": message_id,
2542 "tool": tool,
2543 "requestID": pending.id,
2544 "timeoutMs": timeout_ms,
2545 }),
2546 ));
2547 let mut timeout_part = WireMessagePart::tool_result(
2548 session_id,
2549 message_id,
2550 tool.clone(),
2551 Some(args.clone()),
2552 json!(null),
2553 );
2554 timeout_part.id = Some(pending.id);
2555 timeout_part.state = Some("failed".to_string());
2556 timeout_part.error = Some(format!(
2557 "Permission request timed out after {} ms",
2558 timeout_ms
2559 ));
2560 self.event_bus.publish(EngineEvent::new(
2561 "message.part.updated",
2562 json!({"part": timeout_part}),
2563 ));
2564 let timeout_reason = format!(
2565 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2566 );
2567 publish_tool_effect(
2568 tool_call_id.as_deref(),
2569 ToolEffectLedgerPhase::Outcome,
2570 ToolEffectLedgerStatus::Blocked,
2571 &args,
2572 None,
2573 None,
2574 Some(&timeout_reason),
2575 );
2576 return Ok(Some(format!(
2577 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2578 )));
2579 }
2580 let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2581 if !approved {
2582 let mut denied_part = WireMessagePart::tool_result(
2583 session_id,
2584 message_id,
2585 tool.clone(),
2586 Some(args.clone()),
2587 json!(null),
2588 );
2589 denied_part.id = Some(pending.id);
2590 denied_part.state = Some("denied".to_string());
2591 denied_part.error = Some("Permission denied by user".to_string());
2592 self.event_bus.publish(EngineEvent::new(
2593 "message.part.updated",
2594 json!({"part": denied_part}),
2595 ));
2596 let denied_reason = format!("Permission denied for tool `{tool}` by user.");
2597 publish_tool_effect(
2598 tool_call_id.as_deref(),
2599 ToolEffectLedgerPhase::Outcome,
2600 ToolEffectLedgerStatus::Blocked,
2601 &args,
2602 None,
2603 None,
2604 Some(&denied_reason),
2605 );
2606 return Ok(Some(format!(
2607 "Permission denied for tool `{tool}` by user."
2608 )));
2609 }
2610 effective_args = args;
2611 }
2612 }
2613
2614 let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2615 let session = self.storage.get_session(session_id).await;
2616 if let (Some(obj), Some(session)) = (args.as_object_mut(), session.as_ref()) {
2617 obj.insert(
2618 "__session_id".to_string(),
2619 Value::String(session_id.to_string()),
2620 );
2621 if let Some(project_id) = session.project_id.clone() {
2622 obj.insert(
2623 "__project_id".to_string(),
2624 Value::String(project_id.clone()),
2625 );
2626 if project_id.starts_with("channel-public::") {
2627 obj.insert(
2628 "__memory_max_visible_scope".to_string(),
2629 Value::String("project".to_string()),
2630 );
2631 }
2632 }
2633 }
2634 let tool_context = self.resolve_tool_execution_context(session_id).await;
2635 if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2636 args = rewrite_workspace_alias_tool_args(&tool, args, workspace_root);
2637 if let Some(obj) = args.as_object_mut() {
2638 obj.insert(
2639 "__workspace_root".to_string(),
2640 Value::String(workspace_root.clone()),
2641 );
2642 obj.insert(
2643 "__effective_cwd".to_string(),
2644 Value::String(effective_cwd.clone()),
2645 );
2646 obj.insert(
2647 "__session_id".to_string(),
2648 Value::String(session_id.to_string()),
2649 );
2650 if let Some(project_id) = project_id.clone() {
2651 obj.insert("__project_id".to_string(), Value::String(project_id));
2652 }
2653 }
2654 tracing::info!(
2655 "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2656 session_id,
2657 tool,
2658 workspace_root,
2659 effective_cwd,
2660 project_id.clone().unwrap_or_default()
2661 );
2662 }
2663 let mut invoke_part =
2664 WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2665 if let Some(call_id) = tool_call_id.clone() {
2666 invoke_part.id = Some(call_id);
2667 }
2668 let invoke_part_id = invoke_part.id.clone();
2669 self.event_bus.publish(EngineEvent::new(
2670 "message.part.updated",
2671 json!({"part": invoke_part}),
2672 ));
2673 let args_for_side_events = args.clone();
2674 let mutation_checkpoint = prepare_mutation_checkpoint(&tool, &args_for_side_events);
2675 publish_tool_effect(
2676 invoke_part_id.as_deref(),
2677 ToolEffectLedgerPhase::Invocation,
2678 ToolEffectLedgerStatus::Started,
2679 &args_for_side_events,
2680 None,
2681 None,
2682 None,
2683 );
2684 let publish_mutation_checkpoint =
2685 |tool_call_id: Option<&str>, outcome: MutationCheckpointOutcome| {
2686 if let Some(baseline) = mutation_checkpoint.as_ref() {
2687 self.event_bus.publish(mutation_checkpoint_event(
2688 finalize_mutation_checkpoint_record(
2689 session_id,
2690 message_id,
2691 tool_call_id,
2692 baseline,
2693 outcome,
2694 ),
2695 ));
2696 }
2697 };
2698 if tool == "spawn_agent" {
2699 let hook = self.spawn_agent_hook.read().await.clone();
2700 if let Some(hook) = hook {
2701 let spawned = hook
2702 .spawn_agent(SpawnAgentToolContext {
2703 session_id: session_id.to_string(),
2704 message_id: message_id.to_string(),
2705 tool_call_id: invoke_part_id.clone(),
2706 args: args_for_side_events.clone(),
2707 })
2708 .await?;
2709 let output = self.plugins.transform_tool_output(spawned.output).await;
2710 let output = truncate_text(&output, 16_000);
2711 emit_tool_side_events(
2712 self.storage.clone(),
2713 &self.event_bus,
2714 ToolSideEventContext {
2715 session_id,
2716 message_id,
2717 tool: &tool,
2718 args: &args_for_side_events,
2719 metadata: &spawned.metadata,
2720 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2721 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2722 },
2723 )
2724 .await;
2725 let mut result_part = WireMessagePart::tool_result(
2726 session_id,
2727 message_id,
2728 tool.clone(),
2729 Some(args_for_side_events.clone()),
2730 json!(output.clone()),
2731 );
2732 result_part.id = invoke_part_id.clone();
2733 self.event_bus.publish(EngineEvent::new(
2734 "message.part.updated",
2735 json!({"part": result_part}),
2736 ));
2737 publish_tool_effect(
2738 invoke_part_id.as_deref(),
2739 ToolEffectLedgerPhase::Outcome,
2740 ToolEffectLedgerStatus::Succeeded,
2741 &args_for_side_events,
2742 Some(&spawned.metadata),
2743 Some(&output),
2744 None,
2745 );
2746 publish_mutation_checkpoint(
2747 invoke_part_id.as_deref(),
2748 MutationCheckpointOutcome::Succeeded,
2749 );
2750 return Ok(Some(truncate_text(
2751 &format!("Tool `{tool}` result:\n{output}"),
2752 16_000,
2753 )));
2754 }
2755 let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2756 let mut failed_part = WireMessagePart::tool_result(
2757 session_id,
2758 message_id,
2759 tool.clone(),
2760 Some(args_for_side_events.clone()),
2761 json!(null),
2762 );
2763 failed_part.id = invoke_part_id.clone();
2764 failed_part.state = Some("failed".to_string());
2765 failed_part.error = Some(output.to_string());
2766 self.event_bus.publish(EngineEvent::new(
2767 "message.part.updated",
2768 json!({"part": failed_part}),
2769 ));
2770 publish_tool_effect(
2771 invoke_part_id.as_deref(),
2772 ToolEffectLedgerPhase::Outcome,
2773 ToolEffectLedgerStatus::Failed,
2774 &args_for_side_events,
2775 None,
2776 None,
2777 Some(output),
2778 );
2779 publish_mutation_checkpoint(
2780 invoke_part_id.as_deref(),
2781 MutationCheckpointOutcome::Failed,
2782 );
2783 return Ok(Some(output.to_string()));
2784 }
2785 let result = match self
2786 .execute_tool_with_timeout(&tool, args, cancel.clone())
2787 .await
2788 {
2789 Ok(result) => result,
2790 Err(err) => {
2791 let err_text = err.to_string();
2792 if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
2793 let timeout_ms = tool_exec_timeout_ms();
2794 let timeout_output = format!(
2795 "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
2796 );
2797 let mut failed_part = WireMessagePart::tool_result(
2798 session_id,
2799 message_id,
2800 tool.clone(),
2801 Some(args_for_side_events.clone()),
2802 json!(null),
2803 );
2804 failed_part.id = invoke_part_id.clone();
2805 failed_part.state = Some("failed".to_string());
2806 failed_part.error = Some(timeout_output.clone());
2807 self.event_bus.publish(EngineEvent::new(
2808 "message.part.updated",
2809 json!({"part": failed_part}),
2810 ));
2811 publish_tool_effect(
2812 invoke_part_id.as_deref(),
2813 ToolEffectLedgerPhase::Outcome,
2814 ToolEffectLedgerStatus::Failed,
2815 &args_for_side_events,
2816 None,
2817 None,
2818 Some(&timeout_output),
2819 );
2820 publish_mutation_checkpoint(
2821 invoke_part_id.as_deref(),
2822 MutationCheckpointOutcome::Failed,
2823 );
2824 return Ok(Some(timeout_output));
2825 }
2826 if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
2827 self.event_bus.publish(EngineEvent::new(
2828 "mcp.auth.required",
2829 json!({
2830 "sessionID": session_id,
2831 "messageID": message_id,
2832 "tool": tool.clone(),
2833 "server": auth.server,
2834 "authorizationUrl": auth.authorization_url,
2835 "message": auth.message,
2836 "challengeId": auth.challenge_id
2837 }),
2838 ));
2839 let auth_output = format!(
2840 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
2841 tool, auth.message, auth.authorization_url
2842 );
2843 let mut result_part = WireMessagePart::tool_result(
2844 session_id,
2845 message_id,
2846 tool.clone(),
2847 Some(args_for_side_events.clone()),
2848 json!(auth_output.clone()),
2849 );
2850 result_part.id = invoke_part_id.clone();
2851 self.event_bus.publish(EngineEvent::new(
2852 "message.part.updated",
2853 json!({"part": result_part}),
2854 ));
2855 publish_tool_effect(
2856 invoke_part_id.as_deref(),
2857 ToolEffectLedgerPhase::Outcome,
2858 ToolEffectLedgerStatus::Blocked,
2859 &args_for_side_events,
2860 None,
2861 Some(&auth_output),
2862 Some(&auth.message),
2863 );
2864 publish_mutation_checkpoint(
2865 invoke_part_id.as_deref(),
2866 MutationCheckpointOutcome::Blocked,
2867 );
2868 return Ok(Some(truncate_text(
2869 &format!("Tool `{tool}` result:\n{auth_output}"),
2870 16_000,
2871 )));
2872 }
2873 let mut failed_part = WireMessagePart::tool_result(
2874 session_id,
2875 message_id,
2876 tool.clone(),
2877 Some(args_for_side_events.clone()),
2878 json!(null),
2879 );
2880 failed_part.id = invoke_part_id.clone();
2881 failed_part.state = Some("failed".to_string());
2882 failed_part.error = Some(err_text.clone());
2883 self.event_bus.publish(EngineEvent::new(
2884 "message.part.updated",
2885 json!({"part": failed_part}),
2886 ));
2887 publish_tool_effect(
2888 invoke_part_id.as_deref(),
2889 ToolEffectLedgerPhase::Outcome,
2890 ToolEffectLedgerStatus::Failed,
2891 &args_for_side_events,
2892 None,
2893 None,
2894 Some(&err_text),
2895 );
2896 publish_mutation_checkpoint(
2897 invoke_part_id.as_deref(),
2898 MutationCheckpointOutcome::Failed,
2899 );
2900 return Err(err);
2901 }
2902 };
2903 if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
2904 let event_name = if auth.pending && auth.blocked {
2905 "mcp.auth.pending"
2906 } else {
2907 "mcp.auth.required"
2908 };
2909 self.event_bus.publish(EngineEvent::new(
2910 event_name,
2911 json!({
2912 "sessionID": session_id,
2913 "messageID": message_id,
2914 "tool": tool.clone(),
2915 "server": auth.server,
2916 "authorizationUrl": auth.authorization_url,
2917 "message": auth.message,
2918 "challengeId": auth.challenge_id,
2919 "pending": auth.pending,
2920 "blocked": auth.blocked,
2921 "retryAfterMs": auth.retry_after_ms
2922 }),
2923 ));
2924 }
2925 emit_tool_side_events(
2926 self.storage.clone(),
2927 &self.event_bus,
2928 ToolSideEventContext {
2929 session_id,
2930 message_id,
2931 tool: &tool,
2932 args: &args_for_side_events,
2933 metadata: &result.metadata,
2934 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2935 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2936 },
2937 )
2938 .await;
2939 let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
2940 if auth.pending && auth.blocked {
2941 let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
2942 format!(
2943 "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
2944 tool, auth.message, auth.authorization_url, retry_after_secs
2945 )
2946 } else {
2947 format!(
2948 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
2949 tool, auth.message, auth.authorization_url
2950 )
2951 }
2952 } else {
2953 self.plugins.transform_tool_output(result.output).await
2954 };
2955 let output = truncate_text(&output, 16_000);
2956 let mut result_part = WireMessagePart::tool_result(
2957 session_id,
2958 message_id,
2959 tool.clone(),
2960 Some(args_for_side_events.clone()),
2961 json!(output.clone()),
2962 );
2963 result_part.id = invoke_part_id.clone();
2964 self.event_bus.publish(EngineEvent::new(
2965 "message.part.updated",
2966 json!({"part": result_part}),
2967 ));
2968 publish_tool_effect(
2969 invoke_part_id.as_deref(),
2970 ToolEffectLedgerPhase::Outcome,
2971 ToolEffectLedgerStatus::Succeeded,
2972 &args_for_side_events,
2973 Some(&result.metadata),
2974 Some(&output),
2975 None,
2976 );
2977 publish_mutation_checkpoint(
2978 invoke_part_id.as_deref(),
2979 MutationCheckpointOutcome::Succeeded,
2980 );
2981 Ok(Some(truncate_text(
2982 &format!("Tool `{tool}` result:\n{output}"),
2983 16_000,
2984 )))
2985 }
2986
2987 async fn execute_tool_with_timeout(
2988 &self,
2989 tool: &str,
2990 args: Value,
2991 cancel: CancellationToken,
2992 ) -> anyhow::Result<tandem_types::ToolResult> {
2993 let timeout_ms = tool_exec_timeout_ms() as u64;
2994 match tokio::time::timeout(
2995 Duration::from_millis(timeout_ms),
2996 self.tools.execute_with_cancel(tool, args, cancel),
2997 )
2998 .await
2999 {
3000 Ok(result) => result,
3001 Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
3002 }
3003 }
3004
3005 async fn find_recent_matching_user_message_id(
3006 &self,
3007 session_id: &str,
3008 text: &str,
3009 ) -> Option<String> {
3010 let session = self.storage.get_session(session_id).await?;
3011 let last = session.messages.last()?;
3012 if !matches!(last.role, MessageRole::User) {
3013 return None;
3014 }
3015 let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
3016 if age_ms > 10_000 {
3017 return None;
3018 }
3019 let last_text = last
3020 .parts
3021 .iter()
3022 .filter_map(|part| match part {
3023 MessagePart::Text { text } => Some(text.clone()),
3024 _ => None,
3025 })
3026 .collect::<Vec<_>>()
3027 .join("\n");
3028 if last_text == text {
3029 return Some(last.id.clone());
3030 }
3031 None
3032 }
3033
3034 async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
3035 let Some(mut session) = self.storage.get_session(session_id).await else {
3036 return;
3037 };
3038 if !title_needs_repair(&session.title) {
3039 return;
3040 }
3041
3042 let first_user_text = session.messages.iter().find_map(|message| {
3043 if !matches!(message.role, MessageRole::User) {
3044 return None;
3045 }
3046 message.parts.iter().find_map(|part| match part {
3047 MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
3048 _ => None,
3049 })
3050 });
3051
3052 let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
3053 let Some(title) = derive_session_title_from_prompt(&source, 60) else {
3054 return;
3055 };
3056
3057 session.title = title;
3058 session.time.updated = Utc::now();
3059 let _ = self.storage.save_session(session).await;
3060 }
3061
3062 async fn workspace_sandbox_violation(
3063 &self,
3064 session_id: &str,
3065 tool: &str,
3066 args: &Value,
3067 ) -> Option<String> {
3068 if self.workspace_override_active(session_id).await {
3069 return None;
3070 }
3071 let session = self.storage.get_session(session_id).await?;
3072 let workspace = session
3073 .workspace_root
3074 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3075 let workspace_path = PathBuf::from(&workspace);
3076 let candidate_paths = extract_tool_candidate_paths(tool, args);
3077 if candidate_paths.is_empty() {
3078 if is_shell_tool_name(tool) {
3079 if let Some(command) = extract_shell_command(args) {
3080 if shell_command_targets_sensitive_path(&command) {
3081 return Some(format!(
3082 "Sandbox blocked `{tool}` command targeting sensitive paths."
3083 ));
3084 }
3085 }
3086 }
3087 return None;
3088 }
3089 if let Some(sensitive) = candidate_paths.iter().find(|path| {
3090 let raw = Path::new(path);
3091 let resolved = if raw.is_absolute() {
3092 raw.to_path_buf()
3093 } else {
3094 workspace_path.join(raw)
3095 };
3096 is_sensitive_path_candidate(&resolved)
3097 }) {
3098 return Some(format!(
3099 "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
3100 ));
3101 }
3102
3103 let outside = candidate_paths.iter().find(|path| {
3104 let raw = Path::new(path);
3105 let resolved = if raw.is_absolute() {
3106 raw.to_path_buf()
3107 } else {
3108 workspace_path.join(raw)
3109 };
3110 !crate::is_within_workspace_root(&resolved, &workspace_path)
3111 })?;
3112 Some(format!(
3113 "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3114 ))
3115 }
3116
3117 async fn resolve_tool_execution_context(
3118 &self,
3119 session_id: &str,
3120 ) -> Option<(String, String, Option<String>)> {
3121 let session = self.storage.get_session(session_id).await?;
3122 let workspace_root = session
3123 .workspace_root
3124 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3125 let effective_cwd = if session.directory.trim().is_empty()
3126 || session.directory.trim() == "."
3127 {
3128 workspace_root.clone()
3129 } else {
3130 crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
3131 };
3132 let project_id = session
3133 .project_id
3134 .clone()
3135 .or_else(|| crate::workspace_project_id(&workspace_root));
3136 Some((workspace_root, effective_cwd, project_id))
3137 }
3138
3139 async fn workspace_override_active(&self, session_id: &str) -> bool {
3140 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
3141 let mut overrides = self.workspace_overrides.write().await;
3142 overrides.retain(|_, expires_at| *expires_at > now);
3143 overrides
3144 .get(session_id)
3145 .map(|expires_at| *expires_at > now)
3146 .unwrap_or(false)
3147 }
3148
3149 async fn generate_final_narrative_without_tools(
3150 &self,
3151 session_id: &str,
3152 active_agent: &AgentDefinition,
3153 provider_hint: Option<&str>,
3154 model_id: Option<&str>,
3155 cancel: CancellationToken,
3156 tool_outputs: &[String],
3157 ) -> Option<String> {
3158 if cancel.is_cancelled() {
3159 return None;
3160 }
3161 let mut messages = load_chat_history(
3162 self.storage.clone(),
3163 session_id,
3164 ChatHistoryProfile::Standard,
3165 )
3166 .await;
3167 let mut system_parts = vec![tandem_runtime_system_prompt(
3168 &self.host_runtime_context,
3169 &[],
3170 )];
3171 if let Some(system) = active_agent.system_prompt.as_ref() {
3172 system_parts.push(system.clone());
3173 }
3174 messages.insert(
3175 0,
3176 ChatMessage {
3177 role: "system".to_string(),
3178 content: system_parts.join("\n\n"),
3179 attachments: Vec::new(),
3180 },
3181 );
3182 messages.push(ChatMessage {
3183 role: "user".to_string(),
3184 content: build_post_tool_final_narrative_prompt(tool_outputs),
3185 attachments: Vec::new(),
3186 });
3187 let stream = self
3188 .providers
3189 .stream_for_provider(
3190 provider_hint,
3191 model_id,
3192 messages,
3193 ToolMode::None,
3194 None,
3195 cancel.clone(),
3196 )
3197 .await
3198 .ok()?;
3199 tokio::pin!(stream);
3200 let mut completion = String::new();
3201 while let Some(chunk) = stream.next().await {
3202 if cancel.is_cancelled() {
3203 return None;
3204 }
3205 match chunk {
3206 Ok(StreamChunk::TextDelta(delta)) => {
3207 let delta = strip_model_control_markers(&delta);
3208 if !delta.trim().is_empty() {
3209 completion.push_str(&delta);
3210 }
3211 }
3212 Ok(StreamChunk::Done { .. }) => break,
3213 Ok(_) => {}
3214 Err(_) => return None,
3215 }
3216 }
3217 let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
3218 if completion.trim().is_empty() {
3219 None
3220 } else {
3221 Some(completion)
3222 }
3223 }
3224}
3225
3226fn resolve_model_route(
3227 request_model: Option<&ModelSpec>,
3228 session_model: Option<&ModelSpec>,
3229) -> Option<(String, String)> {
3230 fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
3231 let provider_id = spec.provider_id.trim();
3232 let model_id = spec.model_id.trim();
3233 if provider_id.is_empty() || model_id.is_empty() {
3234 return None;
3235 }
3236 Some((provider_id.to_string(), model_id.to_string()))
3237 }
3238
3239 request_model
3240 .and_then(normalize)
3241 .or_else(|| session_model.and_then(normalize))
3242}
3243
3244fn strip_model_control_markers(input: &str) -> String {
3245 let mut cleaned = input.to_string();
3246 for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
3247 if cleaned.contains(marker) {
3248 cleaned = cleaned.replace(marker, "");
3249 }
3250 }
3251 cleaned
3252}
3253
3254fn truncate_text(input: &str, max_len: usize) -> String {
3255 if input.len() <= max_len {
3256 return input.to_string();
3257 }
3258 let mut out = input[..max_len].to_string();
3259 out.push_str("...<truncated>");
3260 out
3261}
3262
3263fn build_post_tool_final_narrative_prompt(tool_outputs: &[String]) -> String {
3264 format!(
3265 "Tool observations:\n{}\n\nUsing the tool observations and the existing conversation instructions, provide the required final answer now. Preserve any requested output contract, required JSON structure, required handoff fields, and required final status object from the original task. Do not call tools. Do not stop at a tool summary if the task requires a structured final response.",
3266 summarize_tool_outputs(tool_outputs)
3267 )
3268}
3269
3270fn provider_error_code(error_text: &str) -> &'static str {
3271 let lower = error_text.to_lowercase();
3272 if lower.contains("invalid_function_parameters")
3273 || lower.contains("array schema missing items")
3274 || lower.contains("tool schema")
3275 {
3276 return "TOOL_SCHEMA_INVALID";
3277 }
3278 if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
3279 {
3280 return "RATE_LIMIT_EXCEEDED";
3281 }
3282 if lower.contains("context length")
3283 || lower.contains("max tokens")
3284 || lower.contains("token limit")
3285 {
3286 return "CONTEXT_LENGTH_EXCEEDED";
3287 }
3288 if lower.contains("unauthorized")
3289 || lower.contains("authentication")
3290 || lower.contains("401")
3291 || lower.contains("403")
3292 {
3293 return "AUTHENTICATION_ERROR";
3294 }
3295 if lower.contains("timeout") || lower.contains("timed out") {
3296 return "TIMEOUT";
3297 }
3298 if lower.contains("server error")
3299 || lower.contains("500")
3300 || lower.contains("502")
3301 || lower.contains("503")
3302 || lower.contains("504")
3303 {
3304 return "PROVIDER_SERVER_ERROR";
3305 }
3306 "PROVIDER_REQUEST_FAILED"
3307}
3308
3309fn normalize_tool_name(name: &str) -> String {
3310 let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
3311 for prefix in [
3312 "default_api:",
3313 "default_api.",
3314 "functions.",
3315 "function.",
3316 "tools.",
3317 "tool.",
3318 "builtin:",
3319 "builtin.",
3320 ] {
3321 if let Some(rest) = normalized.strip_prefix(prefix) {
3322 let trimmed = rest.trim();
3323 if !trimmed.is_empty() {
3324 normalized = trimmed.to_string();
3325 break;
3326 }
3327 }
3328 }
3329 match normalized.as_str() {
3330 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
3331 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
3332 other => other.to_string(),
3333 }
3334}
3335
3336fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
3337 let mut parts = tool_name.split('.');
3338 let prefix = parts.next()?;
3339 if prefix != "mcp" {
3340 return None;
3341 }
3342 parts.next().filter(|server| !server.is_empty())
3343}
3344
3345fn requires_web_research_prompt(input: &str) -> bool {
3346 let lower = input.to_ascii_lowercase();
3347 [
3348 "research",
3349 "top news",
3350 "today's news",
3351 "todays news",
3352 "with links",
3353 "latest headlines",
3354 "current events",
3355 ]
3356 .iter()
3357 .any(|needle| lower.contains(needle))
3358}
3359
3360fn requires_email_delivery_prompt(input: &str) -> bool {
3361 let lower = input.to_ascii_lowercase();
3362 (lower.contains("send") && lower.contains("email"))
3363 || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
3364 || lower.contains("email to")
3365}
3366
3367fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
3368 schemas.iter().any(|schema| {
3369 let name = normalize_tool_name(&schema.name);
3370 name == "websearch" || name == "webfetch" || name == "webfetch_html"
3371 })
3372}
3373
3374fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
3375 schemas
3376 .iter()
3377 .map(|schema| normalize_tool_name(&schema.name))
3378 .any(|name| tool_name_looks_like_email_action(&name))
3379}
3380
3381fn tool_name_looks_like_email_action(name: &str) -> bool {
3382 let normalized = normalize_tool_name(name);
3383 if normalized.starts_with("mcp.") {
3384 return normalized.contains("gmail")
3385 || normalized.contains("mail")
3386 || normalized.contains("email");
3387 }
3388 normalized.contains("mail") || normalized.contains("email")
3389}
3390
3391fn completion_claims_email_sent(text: &str) -> bool {
3392 let lower = text.to_ascii_lowercase();
3393 let has_email_marker = lower.contains("email status")
3394 || lower.contains("emailed")
3395 || lower.contains("email sent")
3396 || lower.contains("sent to");
3397 has_email_marker
3398 && (lower.contains("sent")
3399 || lower.contains("delivered")
3400 || lower.contains("has been sent"))
3401}
3402
3403fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
3404 let Some(obj) = args.as_object() else {
3405 return Vec::new();
3406 };
3407 let keys: &[&str] = match tool {
3408 "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
3409 "glob" => &["pattern"],
3410 "lsp" => &["filePath", "path"],
3411 "bash" => &["cwd"],
3412 "apply_patch" => &[],
3413 _ => &["path", "cwd"],
3414 };
3415 keys.iter()
3416 .filter_map(|key| obj.get(*key))
3417 .filter_map(|value| value.as_str())
3418 .filter(|s| !s.trim().is_empty())
3419 .map(ToString::to_string)
3420 .collect()
3421}
3422
3423fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
3424 let target = normalize_tool_name(tool_name);
3425 match agent.tools.as_ref() {
3426 None => true,
3427 Some(list) => {
3428 let normalized = list
3429 .iter()
3430 .map(|t| normalize_tool_name(t))
3431 .collect::<Vec<_>>();
3432 any_policy_matches(&normalized, &target)
3433 }
3434 }
3435}
3436
3437fn enforce_skill_scope(
3438 tool_name: &str,
3439 args: Value,
3440 equipped_skills: Option<&[String]>,
3441) -> Result<Value, String> {
3442 if normalize_tool_name(tool_name) != "skill" {
3443 return Ok(args);
3444 }
3445 let Some(configured) = equipped_skills else {
3446 return Ok(args);
3447 };
3448
3449 let mut allowed = configured
3450 .iter()
3451 .map(|s| s.trim().to_string())
3452 .filter(|s| !s.is_empty())
3453 .collect::<Vec<_>>();
3454 if allowed
3455 .iter()
3456 .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
3457 {
3458 return Ok(args);
3459 }
3460 allowed.sort();
3461 allowed.dedup();
3462 if allowed.is_empty() {
3463 return Err("No skills are equipped for this agent.".to_string());
3464 }
3465
3466 let requested = args
3467 .get("name")
3468 .and_then(|v| v.as_str())
3469 .map(|v| v.trim().to_string())
3470 .unwrap_or_default();
3471 if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
3472 return Err(format!(
3473 "Skill '{}' is not equipped for this agent. Equipped skills: {}",
3474 requested,
3475 allowed.join(", ")
3476 ));
3477 }
3478
3479 let mut out = if let Some(obj) = args.as_object() {
3480 Value::Object(obj.clone())
3481 } else {
3482 json!({})
3483 };
3484 if let Some(obj) = out.as_object_mut() {
3485 obj.insert("allowed_skills".to_string(), json!(allowed));
3486 }
3487 Ok(out)
3488}
3489
3490fn is_read_only_tool(tool_name: &str) -> bool {
3491 matches!(
3492 normalize_tool_name(tool_name).as_str(),
3493 "glob"
3494 | "read"
3495 | "grep"
3496 | "search"
3497 | "codesearch"
3498 | "list"
3499 | "ls"
3500 | "lsp"
3501 | "websearch"
3502 | "webfetch"
3503 | "webfetch_html"
3504 )
3505}
3506
3507fn is_workspace_write_tool(tool_name: &str) -> bool {
3508 matches!(
3509 normalize_tool_name(tool_name).as_str(),
3510 "write" | "edit" | "apply_patch"
3511 )
3512}
3513
3514fn should_start_prewrite_repair_before_first_write(
3515 repair_on_unmet_requirements: bool,
3516 productive_write_tool_calls_total: usize,
3517 prewrite_satisfied: bool,
3518 code_workflow_requested: bool,
3519) -> bool {
3520 (repair_on_unmet_requirements || code_workflow_requested)
3521 && productive_write_tool_calls_total == 0
3522 && !prewrite_satisfied
3523}
3524
3525fn is_batch_wrapper_tool_name(name: &str) -> bool {
3526 matches!(
3527 normalize_tool_name(name).as_str(),
3528 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
3529 )
3530}
3531
3532fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
3533 obj.get(key)
3534 .and_then(|v| v.as_str())
3535 .map(str::trim)
3536 .filter(|s| !s.is_empty())
3537}
3538
3539fn nested_non_empty_string_at<'a>(
3540 obj: &'a Map<String, Value>,
3541 parent: &str,
3542 key: &str,
3543) -> Option<&'a str> {
3544 obj.get(parent)
3545 .and_then(|v| v.as_object())
3546 .and_then(|nested| nested.get(key))
3547 .and_then(|v| v.as_str())
3548 .map(str::trim)
3549 .filter(|s| !s.is_empty())
3550}
3551
3552fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
3553 let calls = args
3554 .get("tool_calls")
3555 .and_then(|v| v.as_array())
3556 .cloned()
3557 .unwrap_or_default();
3558 calls
3559 .into_iter()
3560 .filter_map(|call| {
3561 let obj = call.as_object()?;
3562 let tool_raw = non_empty_string_at(obj, "tool")
3563 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3564 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
3565 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
3566 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
3567 let name_raw = non_empty_string_at(obj, "name")
3568 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3569 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
3570 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
3571 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
3572 let effective = match (tool_raw, name_raw) {
3573 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
3574 (Some(t), _) => t,
3575 (None, Some(n)) => n,
3576 (None, None) => return None,
3577 };
3578 let normalized = normalize_tool_name(effective);
3579 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3580 Some((normalized, call_args))
3581 })
3582 .collect()
3583}
3584
3585fn is_read_only_batch_call(args: &Value) -> bool {
3586 let calls = extract_batch_calls(args);
3587 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
3588}
3589
3590fn batch_tool_signature(args: &Value) -> Option<String> {
3591 let calls = extract_batch_calls(args);
3592 if calls.is_empty() {
3593 return None;
3594 }
3595 let parts = calls
3596 .into_iter()
3597 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
3598 .collect::<Vec<_>>();
3599 Some(format!("batch:{}", parts.join("|")))
3600}
3601
3602fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
3603 let normalized_tool = normalize_tool_name(tool_name);
3604 if normalized_tool == "batch" && is_non_productive_batch_output(output) {
3605 return false;
3606 }
3607 if is_auth_required_tool_output(output) {
3608 return false;
3609 }
3610 let Some(result_body) = extract_tool_result_body(output) else {
3611 return false;
3612 };
3613 !is_non_productive_tool_result_body(result_body)
3614}
3615
3616fn is_successful_web_research_output(tool_name: &str, output: &str) -> bool {
3617 if !is_web_research_tool(tool_name) {
3618 return false;
3619 }
3620 let Some(result_body) = extract_tool_result_body(output) else {
3621 return false;
3622 };
3623 if is_non_productive_tool_result_body(result_body) {
3624 return false;
3625 }
3626 let lower = result_body.to_ascii_lowercase();
3627 !(lower.contains("search timed out")
3628 || lower.contains("timed out")
3629 || lower.contains("no results received")
3630 || lower.contains("no search results")
3631 || lower.contains("no relevant results"))
3632}
3633
3634fn extract_tool_result_body(output: &str) -> Option<&str> {
3635 let trimmed = output.trim();
3636 let rest = trimmed.strip_prefix("Tool `")?;
3637 let (_, result_body) = rest.split_once("` result:")?;
3638 Some(result_body.trim())
3639}
3640
3641fn is_non_productive_tool_result_body(output: &str) -> bool {
3642 let trimmed = output.trim();
3643 if trimmed.is_empty() {
3644 return true;
3645 }
3646 let lower = trimmed.to_ascii_lowercase();
3647 lower.starts_with("unknown tool:")
3648 || lower.contains("call skipped")
3649 || lower.contains("guard budget exceeded")
3650 || lower.contains("invalid_function_parameters")
3651 || is_terminal_tool_error_reason(trimmed)
3652}
3653
3654fn is_terminal_tool_error_reason(output: &str) -> bool {
3655 let first_line = output.lines().next().unwrap_or_default().trim();
3656 if first_line.is_empty() {
3657 return false;
3658 }
3659 let normalized = first_line.to_ascii_uppercase();
3660 matches!(
3661 normalized.as_str(),
3662 "TOOL_ARGUMENTS_MISSING"
3663 | "WEBSEARCH_QUERY_MISSING"
3664 | "BASH_COMMAND_MISSING"
3665 | "FILE_PATH_MISSING"
3666 | "WRITE_CONTENT_MISSING"
3667 | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
3668 | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
3669 | "WEBFETCH_URL_MISSING"
3670 | "PACK_BUILDER_PLAN_ID_MISSING"
3671 | "PACK_BUILDER_GOAL_MISSING"
3672 | "PROVIDER_REQUEST_FAILED"
3673 | "AUTHENTICATION_ERROR"
3674 | "CONTEXT_LENGTH_EXCEEDED"
3675 | "RATE_LIMIT_EXCEEDED"
3676 ) || normalized.ends_with("_MISSING")
3677 || normalized.ends_with("_ERROR")
3678}
3679
3680fn is_non_productive_batch_output(output: &str) -> bool {
3681 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
3682 return false;
3683 };
3684 let Some(items) = value.as_array() else {
3685 return false;
3686 };
3687 if items.is_empty() {
3688 return true;
3689 }
3690 items.iter().all(|item| {
3691 let text = item
3692 .get("output")
3693 .and_then(|v| v.as_str())
3694 .map(str::trim)
3695 .unwrap_or_default()
3696 .to_ascii_lowercase();
3697 text.is_empty()
3698 || text.starts_with("unknown tool:")
3699 || text.contains("call skipped")
3700 || text.contains("guard budget exceeded")
3701 })
3702}
3703
3704fn is_auth_required_tool_output(output: &str) -> bool {
3705 let lower = output.to_ascii_lowercase();
3706 (lower.contains("authorization required")
3707 || lower.contains("requires authorization")
3708 || lower.contains("authorization pending"))
3709 && (lower.contains("authorize here") || lower.contains("http"))
3710}
3711
3712#[derive(Debug, Clone)]
3713struct McpAuthRequiredMetadata {
3714 challenge_id: String,
3715 authorization_url: String,
3716 message: String,
3717 server: Option<String>,
3718 pending: bool,
3719 blocked: bool,
3720 retry_after_ms: Option<u64>,
3721}
3722
3723fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
3724 let auth = metadata.get("mcpAuth")?;
3725 if !auth
3726 .get("required")
3727 .and_then(|v| v.as_bool())
3728 .unwrap_or(false)
3729 {
3730 return None;
3731 }
3732 let authorization_url = auth
3733 .get("authorizationUrl")
3734 .and_then(|v| v.as_str())
3735 .map(str::trim)
3736 .filter(|v| !v.is_empty())?
3737 .to_string();
3738 let message = auth
3739 .get("message")
3740 .and_then(|v| v.as_str())
3741 .map(str::trim)
3742 .filter(|v| !v.is_empty())
3743 .unwrap_or("This tool requires authorization before it can run.")
3744 .to_string();
3745 let challenge_id = auth
3746 .get("challengeId")
3747 .and_then(|v| v.as_str())
3748 .map(str::trim)
3749 .filter(|v| !v.is_empty())
3750 .unwrap_or("unknown")
3751 .to_string();
3752 let server = metadata
3753 .get("server")
3754 .and_then(|v| v.as_str())
3755 .map(str::trim)
3756 .filter(|v| !v.is_empty())
3757 .map(ToString::to_string);
3758 let pending = auth
3759 .get("pending")
3760 .and_then(|v| v.as_bool())
3761 .unwrap_or(false);
3762 let blocked = auth
3763 .get("blocked")
3764 .and_then(|v| v.as_bool())
3765 .unwrap_or(false);
3766 let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
3767 Some(McpAuthRequiredMetadata {
3768 challenge_id,
3769 authorization_url,
3770 message,
3771 server,
3772 pending,
3773 blocked,
3774 retry_after_ms,
3775 })
3776}
3777
3778fn extract_mcp_auth_required_from_error_text(
3779 tool_name: &str,
3780 error_text: &str,
3781) -> Option<McpAuthRequiredMetadata> {
3782 let lower = error_text.to_ascii_lowercase();
3783 let auth_hint = lower.contains("authorization")
3784 || lower.contains("oauth")
3785 || lower.contains("invalid oauth token")
3786 || lower.contains("requires authorization");
3787 if !auth_hint {
3788 return None;
3789 }
3790 let authorization_url = find_first_url(error_text)?;
3791 let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
3792 let server = tool_name
3793 .strip_prefix("mcp.")
3794 .and_then(|rest| rest.split('.').next())
3795 .filter(|s| !s.is_empty())
3796 .map(ToString::to_string);
3797 Some(McpAuthRequiredMetadata {
3798 challenge_id,
3799 authorization_url,
3800 message: "This integration requires authorization before this action can run.".to_string(),
3801 server,
3802 pending: false,
3803 blocked: false,
3804 retry_after_ms: None,
3805 })
3806}
3807
3808fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
3809 if outputs.is_empty()
3810 || !outputs
3811 .iter()
3812 .all(|output| is_auth_required_tool_output(output))
3813 {
3814 return None;
3815 }
3816 let mut auth_lines = outputs
3817 .iter()
3818 .filter_map(|output| {
3819 let trimmed = output.trim();
3820 if trimmed.is_empty() {
3821 None
3822 } else {
3823 Some(trimmed.to_string())
3824 }
3825 })
3826 .collect::<Vec<_>>();
3827 auth_lines.sort();
3828 auth_lines.dedup();
3829 if auth_lines.is_empty() {
3830 return None;
3831 }
3832 Some(format!(
3833 "Authorization is required before I can continue with this action.\n\n{}",
3834 auth_lines.join("\n\n")
3835 ))
3836}
3837
3838fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
3839 if outputs.is_empty()
3840 || !outputs
3841 .iter()
3842 .all(|output| is_guard_budget_tool_output(output))
3843 {
3844 return None;
3845 }
3846 let mut lines = outputs
3847 .iter()
3848 .filter_map(|output| {
3849 let trimmed = output.trim();
3850 if trimmed.is_empty() {
3851 None
3852 } else {
3853 Some(trimmed.to_string())
3854 }
3855 })
3856 .collect::<Vec<_>>();
3857 lines.sort();
3858 lines.dedup();
3859 if lines.is_empty() {
3860 return None;
3861 }
3862 Some(format!(
3863 "This run hit the per-run tool guard budget, so I paused tool execution to avoid runaway retries.\n\n{}\n\nSend a new message to start a fresh run.",
3864 lines.join("\n")
3865 ))
3866}
3867
3868fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
3869 if outputs.is_empty()
3870 || !outputs
3871 .iter()
3872 .all(|output| is_duplicate_signature_limit_output(output))
3873 {
3874 return None;
3875 }
3876 let mut lines = outputs
3877 .iter()
3878 .filter_map(|output| {
3879 let trimmed = output.trim();
3880 if trimmed.is_empty() {
3881 None
3882 } else {
3883 Some(trimmed.to_string())
3884 }
3885 })
3886 .collect::<Vec<_>>();
3887 lines.sort();
3888 lines.dedup();
3889 if lines.is_empty() {
3890 return None;
3891 }
3892 Some(format!(
3893 "This run paused because the same tool call kept repeating.\n\n{}\n\nRephrase the request or start a new message with a clearer command target.",
3894 lines.join("\n")
3895 ))
3896}
3897
3898const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
3899
3900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3901enum RequiredToolFailureKind {
3902 NoToolCallEmitted,
3903 ToolCallParseFailed,
3904 ToolCallInvalidArgs,
3905 WriteArgsEmptyFromProvider,
3906 WriteArgsUnparseableFromProvider,
3907 ToolCallRejectedByPolicy,
3908 ToolCallExecutedNonProductive,
3909 WriteRequiredNotSatisfied,
3910 PrewriteRequirementsExhausted,
3911}
3912
3913impl RequiredToolFailureKind {
3914 fn code(self) -> &'static str {
3915 match self {
3916 Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
3917 Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
3918 Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
3919 Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
3920 Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
3921 Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
3922 Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
3923 Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
3924 Self::PrewriteRequirementsExhausted => "PREWRITE_REQUIREMENTS_EXHAUSTED",
3925 }
3926 }
3927}
3928
3929fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
3930 format!(
3931 "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
3932 reason.code()
3933 )
3934}
3935
3936#[allow(dead_code)]
3937fn prewrite_requirements_exhausted_completion(
3938 unmet_codes: &[&'static str],
3939 repair_attempt: usize,
3940 repair_attempts_remaining: usize,
3941) -> String {
3942 let unmet = if unmet_codes.is_empty() {
3943 "none".to_string()
3944 } else {
3945 unmet_codes.join(", ")
3946 };
3947 format!(
3948 "TOOL_MODE_REQUIRED_NOT_SATISFIED: PREWRITE_REQUIREMENTS_EXHAUSTED: unmet prewrite requirements: {unmet}\n\n{{\"status\":\"blocked\",\"reason\":\"prewrite requirements exhausted before final artifact validation\",\"failureCode\":\"PREWRITE_REQUIREMENTS_EXHAUSTED\",\"repairAttempt\":{},\"repairAttemptsRemaining\":{},\"repairExhausted\":true,\"unmetRequirements\":{:?}}}",
3949 repair_attempt,
3950 repair_attempts_remaining,
3951 unmet_codes,
3952 )
3953}
3954
3955fn prewrite_repair_event_payload(
3956 repair_attempt: usize,
3957 repair_attempts_remaining: usize,
3958 unmet_codes: &[&'static str],
3959 repair_exhausted: bool,
3960) -> Value {
3961 json!({
3962 "repairAttempt": repair_attempt,
3963 "repairAttemptsRemaining": repair_attempts_remaining,
3964 "unmetRequirements": unmet_codes,
3965 "repairActive": repair_attempt > 0 && !repair_exhausted,
3966 "repairExhausted": repair_exhausted,
3967 })
3968}
3969
3970fn build_required_tool_retry_context(
3971 offered_tool_preview: &str,
3972 previous_reason: RequiredToolFailureKind,
3973) -> String {
3974 let offered = offered_tool_preview.trim();
3975 let available_tools = if offered.is_empty() {
3976 "Use one of the tools offered in this turn before you produce final text.".to_string()
3977 } else {
3978 format!("Use one of these offered tools before you produce final text: {offered}.")
3979 };
3980 let execution_instruction = if previous_reason
3981 == RequiredToolFailureKind::WriteRequiredNotSatisfied
3982 {
3983 "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
3984 } else if is_write_invalid_args_failure_kind(previous_reason) {
3985 "Previous tool call arguments were invalid. If you use write, include both `path` and the full `content`. If inspection is already complete, use write, edit, or apply_patch now.".to_string()
3986 } else {
3987 available_tools
3988 };
3989 format!(
3990 "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
3991 previous_reason.code(),
3992 execution_instruction
3993 )
3994}
3995
3996fn looks_like_code_target_path(path: &str) -> bool {
3997 let trimmed = path.trim();
3998 if trimmed.is_empty() {
3999 return false;
4000 }
4001 let normalized = trimmed.replace('\\', "/");
4002 let file_name = normalized
4003 .rsplit('/')
4004 .next()
4005 .unwrap_or(normalized.as_str())
4006 .to_ascii_lowercase();
4007 if matches!(
4008 file_name.as_str(),
4009 "cargo.toml"
4010 | "cargo.lock"
4011 | "package.json"
4012 | "pnpm-lock.yaml"
4013 | "package-lock.json"
4014 | "yarn.lock"
4015 | "makefile"
4016 | "dockerfile"
4017 | ".gitignore"
4018 | ".editorconfig"
4019 | "tsconfig.json"
4020 | "pyproject.toml"
4021 | "requirements.txt"
4022 ) {
4023 return true;
4024 }
4025 let extension = file_name.rsplit('.').next().unwrap_or_default();
4026 matches!(
4027 extension,
4028 "rs" | "ts"
4029 | "tsx"
4030 | "js"
4031 | "jsx"
4032 | "py"
4033 | "go"
4034 | "java"
4035 | "kt"
4036 | "kts"
4037 | "c"
4038 | "cc"
4039 | "cpp"
4040 | "h"
4041 | "hpp"
4042 | "cs"
4043 | "rb"
4044 | "php"
4045 | "swift"
4046 | "scala"
4047 | "sh"
4048 | "bash"
4049 | "zsh"
4050 | "toml"
4051 | "yaml"
4052 | "yml"
4053 | "json"
4054 )
4055}
4056
4057fn infer_code_workflow_from_text(text: &str) -> bool {
4058 let lowered = text.to_ascii_lowercase();
4059 if lowered.contains("code agent contract")
4060 || lowered.contains("inspect -> patch -> apply -> test -> repair")
4061 || lowered.contains("task kind: `code_change`")
4062 || lowered.contains("task kind: code_change")
4063 || lowered.contains("output contract kind: code_patch")
4064 || lowered.contains("verification expectation:")
4065 || lowered.contains("verification command:")
4066 {
4067 return true;
4068 }
4069 infer_required_output_target_path_from_text(text)
4070 .is_some_and(|path| looks_like_code_target_path(&path))
4071}
4072
4073fn infer_verification_command_from_text(text: &str) -> Option<String> {
4074 for marker in ["Verification expectation:", "verification expectation:"] {
4075 let Some(start) = text.find(marker) else {
4076 continue;
4077 };
4078 let remainder = text[start + marker.len()..].trim_start();
4079 let line = remainder.lines().next().unwrap_or_default().trim();
4080 if line.is_empty() {
4081 continue;
4082 }
4083 let cleaned = line
4084 .trim_matches('`')
4085 .trim_end_matches('.')
4086 .trim()
4087 .to_string();
4088 if !cleaned.is_empty() {
4089 return Some(cleaned);
4090 }
4091 }
4092 None
4093}
4094
4095fn build_required_tool_retry_context_for_task(
4096 offered_tool_preview: &str,
4097 previous_reason: RequiredToolFailureKind,
4098 latest_user_text: &str,
4099) -> String {
4100 let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
4101 if !infer_code_workflow_from_text(latest_user_text) {
4102 return prompt;
4103 }
4104 let output_target = infer_required_output_target_path_from_text(latest_user_text)
4105 .unwrap_or_else(|| "the declared source target".to_string());
4106 let verification = infer_verification_command_from_text(latest_user_text)
4107 .unwrap_or_else(|| "run the declared verification command with `bash`".to_string());
4108 prompt.push(' ');
4109 prompt.push_str(
4110 "This is a code workflow: follow inspect -> patch -> apply -> test -> repair before finalizing.",
4111 );
4112 prompt.push(' ');
4113 prompt.push_str(&format!(
4114 "Patch `{output_target}` using `apply_patch` (or `edit` for local edits); use `write` only when creating a brand-new file."
4115 ));
4116 prompt.push(' ');
4117 prompt.push_str(&format!(
4118 "After patching, run verification with `bash` (`{verification}`). If verification fails, repair the smallest root cause and re-run verification."
4119 ));
4120 prompt
4121}
4122
4123fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
4124 matches!(
4125 reason,
4126 RequiredToolFailureKind::ToolCallInvalidArgs
4127 | RequiredToolFailureKind::WriteArgsEmptyFromProvider
4128 | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
4129 )
4130}
4131
4132fn build_write_required_retry_context(
4133 offered_tool_preview: &str,
4134 previous_reason: RequiredToolFailureKind,
4135 latest_user_text: &str,
4136 prewrite_requirements: &PrewriteRequirements,
4137 workspace_inspection_satisfied: bool,
4138 concrete_read_satisfied: bool,
4139 web_research_satisfied: bool,
4140 successful_web_research_satisfied: bool,
4141) -> String {
4142 let mut prompt = build_required_tool_retry_context_for_task(
4143 offered_tool_preview,
4144 previous_reason,
4145 latest_user_text,
4146 );
4147 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4148 prewrite_requirements,
4149 workspace_inspection_satisfied,
4150 concrete_read_satisfied,
4151 web_research_satisfied,
4152 successful_web_research_satisfied,
4153 );
4154 if !unmet.is_empty() {
4155 prompt.push(' ');
4156 prompt.push_str(&format!(
4157 "Before the final write, you still need to {}.",
4158 unmet.join(" and ")
4159 ));
4160 }
4161 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4162 prompt.push(' ');
4163 prompt.push_str(&format!(
4164 "The required output target for this task is `{path}`. Write or update that file now."
4165 ));
4166 prompt.push(' ');
4167 prompt.push_str(
4168 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4169 );
4170 prompt.push(' ');
4171 prompt.push_str(
4172 "You have already gathered research in this session. Now write the output file using the information from your previous tool calls. You may re-read a specific file if needed for accuracy.",
4173 );
4174 }
4175 prompt
4176}
4177
4178fn build_prewrite_repair_retry_context(
4179 offered_tool_preview: &str,
4180 previous_reason: RequiredToolFailureKind,
4181 latest_user_text: &str,
4182 prewrite_requirements: &PrewriteRequirements,
4183 workspace_inspection_satisfied: bool,
4184 concrete_read_satisfied: bool,
4185 web_research_satisfied: bool,
4186 successful_web_research_satisfied: bool,
4187) -> String {
4188 let mut prompt = build_required_tool_retry_context_for_task(
4189 offered_tool_preview,
4190 previous_reason,
4191 latest_user_text,
4192 );
4193 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4194 prewrite_requirements,
4195 workspace_inspection_satisfied,
4196 concrete_read_satisfied,
4197 web_research_satisfied,
4198 successful_web_research_satisfied,
4199 );
4200 if !unmet.is_empty() {
4201 prompt.push(' ');
4202 prompt.push_str(&format!(
4203 "Before the final write, you still need to {}.",
4204 unmet.join(" and ")
4205 ));
4206 }
4207 let mut repair_notes = Vec::new();
4208 if prewrite_requirements.concrete_read_required && !concrete_read_satisfied {
4209 repair_notes.push(
4210 "This task requires concrete `read` calls on relevant workspace files before you can write the output. Call `read` now on the files you discovered.",
4211 );
4212 }
4213 if prewrite_requirements.successful_web_research_required && !successful_web_research_satisfied
4214 {
4215 repair_notes.push(
4216 "Timed out or empty websearch attempts do not satisfy external-research requirements; call `websearch` with a concrete query now.",
4217 );
4218 }
4219 if !matches!(
4220 prewrite_requirements.coverage_mode,
4221 PrewriteCoverageMode::None
4222 ) {
4223 repair_notes.push(
4224 "Every path listed under `Files reviewed` must have been actually read in this run, and any relevant discovered file you did not read must appear under `Files not reviewed` with a reason.",
4225 );
4226 }
4227 if !repair_notes.is_empty() {
4228 prompt.push(' ');
4229 prompt.push_str("Do not skip this step. ");
4230 prompt.push_str(&repair_notes.join(" "));
4231 }
4232 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4233 if infer_code_workflow_from_text(latest_user_text) {
4234 prompt.push(' ');
4235 prompt.push_str(&format!(
4236 "Use `read` to confirm the concrete code context, then patch `{path}` with `apply_patch` or `edit` and run verification before finalizing."
4237 ));
4238 prompt.push(' ');
4239 prompt.push_str(
4240 "Do not return a prose-only completion before patch + verification steps run.",
4241 );
4242 } else {
4243 prompt.push(' ');
4244 prompt.push_str(&format!(
4245 "Use `read` and `websearch` now to gather evidence, then write the artifact to `{path}`."
4246 ));
4247 prompt.push(' ');
4248 prompt.push_str(&format!(
4249 "Do not declare the output blocked while `read` and `websearch` remain available. Call them now."
4250 ));
4251 }
4252 }
4253 prompt
4254}
4255
4256fn build_prewrite_waived_write_context(
4257 latest_user_text: &str,
4258 unmet_codes: &[&'static str],
4259) -> String {
4260 let mut prompt = String::from(
4261 "Research prerequisites could not be fully satisfied after multiple repair attempts. \
4262 You must still write the output file using whatever information you have gathered so far. \
4263 Do not write a blocked or placeholder file. Write the best possible output with the evidence available.",
4264 );
4265 if !unmet_codes.is_empty() {
4266 prompt.push_str(&format!(
4267 " (Unmet prerequisites waived: {}.)",
4268 unmet_codes.join(", ")
4269 ));
4270 }
4271 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4272 prompt.push_str(&format!(
4273 " The required output file is `{path}`. Call the `write` tool now to create it."
4274 ));
4275 }
4276 prompt
4277}
4278
4279fn build_empty_completion_retry_context(
4280 offered_tool_preview: &str,
4281 latest_user_text: &str,
4282 prewrite_requirements: &PrewriteRequirements,
4283 workspace_inspection_satisfied: bool,
4284 concrete_read_satisfied: bool,
4285 web_research_satisfied: bool,
4286 successful_web_research_satisfied: bool,
4287) -> String {
4288 let mut prompt = String::from(
4289 "You already used tools in this session, but returned no final output. Do not stop now.",
4290 );
4291 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4292 prewrite_requirements,
4293 workspace_inspection_satisfied,
4294 concrete_read_satisfied,
4295 web_research_satisfied,
4296 successful_web_research_satisfied,
4297 );
4298 if !unmet.is_empty() {
4299 prompt.push(' ');
4300 prompt.push_str(&format!(
4301 "You still need to {} before the final write.",
4302 unmet.join(" and ")
4303 ));
4304 prompt.push(' ');
4305 prompt.push_str(&build_required_tool_retry_context_for_task(
4306 offered_tool_preview,
4307 RequiredToolFailureKind::WriteRequiredNotSatisfied,
4308 latest_user_text,
4309 ));
4310 }
4311 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4312 prompt.push(' ');
4313 prompt.push_str(&format!("The required output target is `{path}`."));
4314 if unmet.is_empty() {
4315 prompt.push(' ');
4316 prompt.push_str(
4317 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4318 );
4319 } else {
4320 prompt.push(' ');
4321 prompt.push_str(
4322 "After completing the missing requirement, immediately write that file instead of ending with prose.",
4323 );
4324 }
4325 }
4326 prompt
4327}
4328
4329fn synthesize_artifact_write_completion_from_tool_state(
4330 latest_user_text: &str,
4331 prewrite_satisfied: bool,
4332 prewrite_gate_waived: bool,
4333) -> String {
4334 let target = infer_required_output_target_path_from_text(latest_user_text)
4335 .unwrap_or_else(|| "the declared output artifact".to_string());
4336 let mut completion = format!("Completed the requested tool actions and wrote `{target}`.");
4337 if prewrite_gate_waived && !prewrite_satisfied {
4338 completion.push_str(
4339 "\n\nRuntime validation will decide whether the artifact can be accepted because some evidence requirements were waived in-run."
4340 );
4341 } else {
4342 completion
4343 .push_str("\n\nRuntime validation will verify the artifact and finalize node status.");
4344 }
4345 completion.push_str("\n\n{\"status\":\"completed\"}");
4346 completion
4347}
4348
4349fn should_generate_post_tool_final_narrative(
4350 requested_tool_mode: ToolMode,
4351 productive_tool_calls_total: usize,
4352) -> bool {
4353 !matches!(requested_tool_mode, ToolMode::Required) || productive_tool_calls_total > 0
4354}
4355
4356fn is_workspace_inspection_tool(tool_name: &str) -> bool {
4357 matches!(
4358 normalize_tool_name(tool_name).as_str(),
4359 "glob" | "read" | "grep" | "search" | "codesearch" | "ls" | "list"
4360 )
4361}
4362
4363fn is_web_research_tool(tool_name: &str) -> bool {
4364 matches!(
4365 normalize_tool_name(tool_name).as_str(),
4366 "websearch" | "webfetch" | "webfetch_html"
4367 )
4368}
4369
4370fn tool_matches_unmet_prewrite_repair_requirement(tool_name: &str, unmet_codes: &[&str]) -> bool {
4371 if is_workspace_write_tool(tool_name) {
4372 return false;
4373 }
4374 let normalized = normalize_tool_name(tool_name);
4375 let needs_workspace_inspection = unmet_codes.contains(&"workspace_inspection_required");
4376 let needs_concrete_read =
4377 unmet_codes.contains(&"concrete_read_required") || unmet_codes.contains(&"coverage_mode");
4378 let needs_web_research = unmet_codes.iter().any(|code| {
4379 matches!(
4380 *code,
4381 "web_research_required" | "successful_web_research_required"
4382 )
4383 });
4384 (needs_concrete_read && (normalized == "read" || normalized == "glob"))
4385 || (needs_workspace_inspection && is_workspace_inspection_tool(&normalized))
4386 || (needs_web_research && is_web_research_tool(&normalized))
4387}
4388
4389fn invalid_tool_args_retry_max_attempts() -> usize {
4390 2
4391}
4392
4393pub fn prewrite_repair_retry_max_attempts() -> usize {
4394 5
4395}
4396
4397fn build_invalid_tool_args_retry_context_from_outputs(
4398 outputs: &[String],
4399 previous_attempts: usize,
4400) -> Option<String> {
4401 if outputs
4402 .iter()
4403 .any(|output| output.contains("BASH_COMMAND_MISSING"))
4404 {
4405 let emphasis = if previous_attempts > 0 {
4406 "You already tried `bash` without a valid command. Do not repeat an empty bash call."
4407 } else {
4408 "If you use `bash`, include a full non-empty command string."
4409 };
4410 return Some(format!(
4411 "Previous bash tool call was invalid because it did not include the required `command` field. {emphasis} Good examples: `pwd`, `ls -la`, `find docs -maxdepth 2 -type f`, or `rg -n \"workflow\" docs src`. Prefer `ls`, `glob`, `search`, and `read` for repository inspection when they are sufficient."
4412 ));
4413 }
4414 if outputs
4415 .iter()
4416 .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
4417 {
4418 return Some(
4419 "Previous websearch tool call was invalid because it did not include a query. If you use `websearch`, include a specific non-empty search query.".to_string(),
4420 );
4421 }
4422 if outputs
4423 .iter()
4424 .any(|output| output.contains("WEBFETCH_URL_MISSING"))
4425 {
4426 return Some(
4427 "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
4428 );
4429 }
4430 if outputs
4431 .iter()
4432 .any(|output| output.contains("FILE_PATH_MISSING"))
4433 {
4434 return Some(
4435 "Previous file tool call was invalid because it did not include a `path`. If you use `read`, `write`, or `edit`, include the exact workspace-relative file path.".to_string(),
4436 );
4437 }
4438 if outputs
4439 .iter()
4440 .any(|output| output.contains("WRITE_CONTENT_MISSING"))
4441 {
4442 return Some(
4443 "Previous write tool call was invalid because it did not include `content`. If you use `write`, include both `path` and the full `content`.".to_string(),
4444 );
4445 }
4446 None
4447}
4448
4449fn looks_like_unparsed_tool_payload(output: &str) -> bool {
4450 let trimmed = output.trim();
4451 if trimmed.is_empty() {
4452 return false;
4453 }
4454 let lower = trimmed.to_ascii_lowercase();
4455 lower.contains("\"tool_calls\"")
4456 || lower.contains("\"function_call\"")
4457 || lower.contains("\"function\":{")
4458 || lower.contains("\"type\":\"tool_call\"")
4459 || lower.contains("\"type\":\"function_call\"")
4460 || lower.contains("\"type\":\"tool_use\"")
4461}
4462
4463fn is_policy_rejection_output(output: &str) -> bool {
4464 let lower = output.trim().to_ascii_lowercase();
4465 lower.contains("call skipped")
4466 || lower.contains("authorization required")
4467 || lower.contains("not allowed")
4468 || lower.contains("permission denied")
4469}
4470
4471fn classify_required_tool_failure(
4472 outputs: &[String],
4473 saw_tool_call_candidate: bool,
4474 accepted_tool_calls: usize,
4475 parse_failed: bool,
4476 rejected_by_policy: bool,
4477) -> RequiredToolFailureKind {
4478 if parse_failed {
4479 return RequiredToolFailureKind::ToolCallParseFailed;
4480 }
4481 if !saw_tool_call_candidate {
4482 return RequiredToolFailureKind::NoToolCallEmitted;
4483 }
4484 if accepted_tool_calls == 0 || rejected_by_policy {
4485 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4486 }
4487 if outputs
4488 .iter()
4489 .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
4490 {
4491 return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
4492 }
4493 if outputs
4494 .iter()
4495 .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
4496 {
4497 return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
4498 }
4499 if outputs
4500 .iter()
4501 .any(|output| is_terminal_tool_error_reason(output))
4502 {
4503 return RequiredToolFailureKind::ToolCallInvalidArgs;
4504 }
4505 if outputs
4506 .iter()
4507 .any(|output| is_policy_rejection_output(output))
4508 {
4509 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4510 }
4511 RequiredToolFailureKind::ToolCallExecutedNonProductive
4512}
4513
4514fn find_first_url(text: &str) -> Option<String> {
4515 text.split_whitespace().find_map(|token| {
4516 if token.starts_with("https://") || token.starts_with("http://") {
4517 let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
4518 if cleaned.len() > "https://".len() {
4519 return Some(cleaned.to_string());
4520 }
4521 }
4522 None
4523 })
4524}
4525
4526fn max_tool_iterations() -> usize {
4527 let default_iterations = 25usize;
4528 std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
4529 .ok()
4530 .and_then(|raw| raw.trim().parse::<usize>().ok())
4531 .filter(|value| *value > 0)
4532 .unwrap_or(default_iterations)
4533}
4534
4535fn strict_write_retry_max_attempts() -> usize {
4536 std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
4537 .ok()
4538 .and_then(|raw| raw.trim().parse::<usize>().ok())
4539 .filter(|value| *value > 0)
4540 .unwrap_or(3)
4541}
4542
4543fn provider_stream_connect_timeout_ms() -> usize {
4544 std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
4545 .ok()
4546 .and_then(|raw| raw.trim().parse::<usize>().ok())
4547 .filter(|value| *value > 0)
4548 .unwrap_or(90_000)
4549}
4550
4551fn provider_stream_idle_timeout_ms() -> usize {
4552 std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
4553 .ok()
4554 .and_then(|raw| raw.trim().parse::<usize>().ok())
4555 .filter(|value| *value > 0)
4556 .unwrap_or(90_000)
4557}
4558
4559fn prompt_context_hook_timeout_ms() -> usize {
4560 std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
4561 .ok()
4562 .and_then(|raw| raw.trim().parse::<usize>().ok())
4563 .filter(|value| *value > 0)
4564 .unwrap_or(5_000)
4565}
4566
4567fn permission_wait_timeout_ms() -> usize {
4568 std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
4569 .ok()
4570 .and_then(|raw| raw.trim().parse::<usize>().ok())
4571 .filter(|value| *value > 0)
4572 .unwrap_or(15_000)
4573}
4574
4575fn tool_exec_timeout_ms() -> usize {
4576 std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
4577 .ok()
4578 .and_then(|raw| raw.trim().parse::<usize>().ok())
4579 .filter(|value| *value > 0)
4580 .unwrap_or(45_000)
4581}
4582
4583fn is_guard_budget_tool_output(output: &str) -> bool {
4584 output
4585 .to_ascii_lowercase()
4586 .contains("per-run guard budget exceeded")
4587}
4588
4589fn is_duplicate_signature_limit_output(output: &str) -> bool {
4590 output
4591 .to_ascii_lowercase()
4592 .contains("duplicate call signature retry limit reached")
4593}
4594
4595fn is_sensitive_path_candidate(path: &Path) -> bool {
4596 let lowered = path.to_string_lossy().to_ascii_lowercase();
4597 if lowered.contains("/.ssh/")
4598 || lowered.ends_with("/.ssh")
4599 || lowered.contains("/.gnupg/")
4600 || lowered.ends_with("/.gnupg")
4601 {
4602 return true;
4603 }
4604 if lowered.contains("/.aws/credentials")
4605 || lowered.ends_with("/.npmrc")
4606 || lowered.ends_with("/.netrc")
4607 || lowered.ends_with("/.pypirc")
4608 {
4609 return true;
4610 }
4611 if lowered.contains("id_rsa")
4612 || lowered.contains("id_ed25519")
4613 || lowered.contains("id_ecdsa")
4614 || lowered.contains(".pem")
4615 || lowered.contains(".p12")
4616 || lowered.contains(".pfx")
4617 || lowered.contains(".key")
4618 {
4619 return true;
4620 }
4621 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
4622 let n = name.to_ascii_lowercase();
4623 if n == ".env" || n.starts_with(".env.") {
4624 return true;
4625 }
4626 }
4627 false
4628}
4629
4630fn shell_command_targets_sensitive_path(command: &str) -> bool {
4631 let lower = command.to_ascii_lowercase();
4632 let patterns = [
4633 ".env",
4634 ".ssh",
4635 ".gnupg",
4636 ".aws/credentials",
4637 "id_rsa",
4638 "id_ed25519",
4639 ".pem",
4640 ".p12",
4641 ".pfx",
4642 ".key",
4643 ];
4644 patterns.iter().any(|p| lower.contains(p))
4645}
4646
4647#[derive(Debug, Clone)]
4648struct NormalizedToolArgs {
4649 args: Value,
4650 args_source: String,
4651 args_integrity: String,
4652 raw_args_state: RawToolArgsState,
4653 query: Option<String>,
4654 missing_terminal: bool,
4655 missing_terminal_reason: Option<String>,
4656}
4657
4658#[derive(Debug, Clone)]
4659struct ParsedToolCall {
4660 tool: String,
4661 args: Value,
4662 call_id: Option<String>,
4663}
4664
4665#[cfg(test)]
4666fn normalize_tool_args(
4667 tool_name: &str,
4668 raw_args: Value,
4669 latest_user_text: &str,
4670 latest_assistant_context: &str,
4671) -> NormalizedToolArgs {
4672 normalize_tool_args_with_mode(
4673 tool_name,
4674 raw_args,
4675 latest_user_text,
4676 latest_assistant_context,
4677 WritePathRecoveryMode::Heuristic,
4678 )
4679}
4680
4681fn normalize_tool_args_with_mode(
4682 tool_name: &str,
4683 raw_args: Value,
4684 latest_user_text: &str,
4685 latest_assistant_context: &str,
4686 write_path_recovery_mode: WritePathRecoveryMode,
4687) -> NormalizedToolArgs {
4688 let normalized_tool = normalize_tool_name(tool_name);
4689 let original_args = raw_args.clone();
4690 let mut args = raw_args;
4691 let mut args_source = if args.is_string() {
4692 "provider_string".to_string()
4693 } else {
4694 "provider_json".to_string()
4695 };
4696 let mut args_integrity = "ok".to_string();
4697 let raw_args_state = classify_raw_tool_args_state(&args);
4698 let mut query = None;
4699 let mut missing_terminal = false;
4700 let mut missing_terminal_reason = None;
4701
4702 if normalized_tool == "websearch" {
4703 if let Some(found) = extract_websearch_query(&args) {
4704 query = Some(found);
4705 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
4706 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
4707 args_source = "inferred_from_user".to_string();
4708 args_integrity = "recovered".to_string();
4709 query = Some(inferred);
4710 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
4711 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
4712 args_source = "recovered_from_context".to_string();
4713 args_integrity = "recovered".to_string();
4714 query = Some(recovered);
4715 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
4716 } else {
4717 args_source = "missing".to_string();
4718 args_integrity = "empty".to_string();
4719 missing_terminal = true;
4720 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
4721 }
4722 } else if is_shell_tool_name(&normalized_tool) {
4723 if let Some(command) = extract_shell_command(&args) {
4724 args = set_shell_command(args, command);
4725 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
4726 args_source = "inferred_from_context".to_string();
4727 args_integrity = "recovered".to_string();
4728 args = set_shell_command(args, inferred);
4729 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
4730 args_source = "inferred_from_user".to_string();
4731 args_integrity = "recovered".to_string();
4732 args = set_shell_command(args, inferred);
4733 } else {
4734 args_source = "missing".to_string();
4735 args_integrity = "empty".to_string();
4736 missing_terminal = true;
4737 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
4738 }
4739 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
4740 if let Some(path) = extract_file_path_arg(&args) {
4741 args = set_file_path_arg(args, path);
4742 } else if normalized_tool == "write" || normalized_tool == "edit" {
4743 if let Some(inferred) = infer_required_output_target_path_from_text(latest_user_text)
4744 .or_else(|| infer_required_output_target_path_from_text(latest_assistant_context))
4745 {
4746 args_source = "recovered_from_context".to_string();
4747 args_integrity = "recovered".to_string();
4748 args = set_file_path_arg(args, inferred);
4749 } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
4750 if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
4751 args_source = "inferred_from_user".to_string();
4752 args_integrity = "recovered".to_string();
4753 args = set_file_path_arg(args, inferred);
4754 } else {
4755 args_source = "missing".to_string();
4756 args_integrity = "empty".to_string();
4757 missing_terminal = true;
4758 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
4759 }
4760 } else {
4761 args_source = "missing".to_string();
4762 args_integrity = "empty".to_string();
4763 missing_terminal = true;
4764 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
4765 }
4766 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
4767 args_source = "inferred_from_user".to_string();
4768 args_integrity = "recovered".to_string();
4769 args = set_file_path_arg(args, inferred);
4770 } else {
4771 args_source = "missing".to_string();
4772 args_integrity = "empty".to_string();
4773 missing_terminal = true;
4774 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
4775 }
4776
4777 if !missing_terminal && normalized_tool == "write" {
4778 if let Some(content) = extract_write_content_arg(&args) {
4779 args = set_write_content_arg(args, content);
4780 } else if let Some(recovered) =
4781 infer_write_content_from_assistant_context(latest_assistant_context)
4782 {
4783 args_source = "recovered_from_context".to_string();
4784 args_integrity = "recovered".to_string();
4785 args = set_write_content_arg(args, recovered);
4786 } else {
4787 args_source = "missing".to_string();
4788 args_integrity = "empty".to_string();
4789 missing_terminal = true;
4790 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
4791 }
4792 }
4793 } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
4794 if let Some(url) = extract_webfetch_url_arg(&args) {
4795 args = set_webfetch_url_arg(args, url);
4796 } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
4797 args_source = "inferred_from_context".to_string();
4798 args_integrity = "recovered".to_string();
4799 args = set_webfetch_url_arg(args, inferred);
4800 } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
4801 args_source = "inferred_from_user".to_string();
4802 args_integrity = "recovered".to_string();
4803 args = set_webfetch_url_arg(args, inferred);
4804 } else {
4805 args_source = "missing".to_string();
4806 args_integrity = "empty".to_string();
4807 missing_terminal = true;
4808 missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
4809 }
4810 } else if normalized_tool == "pack_builder" {
4811 let mode = extract_pack_builder_mode_arg(&args);
4812 let plan_id = extract_pack_builder_plan_id_arg(&args);
4813 if mode.as_deref() == Some("apply") && plan_id.is_none() {
4814 if let Some(inferred_plan) =
4815 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
4816 {
4817 args_source = "recovered_from_context".to_string();
4818 args_integrity = "recovered".to_string();
4819 args = set_pack_builder_apply_args(args, inferred_plan);
4820 } else {
4821 args_source = "missing".to_string();
4822 args_integrity = "empty".to_string();
4823 missing_terminal = true;
4824 missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
4825 }
4826 } else if mode.as_deref() == Some("apply") {
4827 args = ensure_pack_builder_default_mode(args);
4828 } else if let Some(inferred_plan) =
4829 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
4830 {
4831 args_source = "recovered_from_context".to_string();
4832 args_integrity = "recovered".to_string();
4833 args = set_pack_builder_apply_args(args, inferred_plan);
4834 } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
4835 args = set_pack_builder_goal_arg(args, goal);
4836 } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
4837 args_source = "inferred_from_user".to_string();
4838 args_integrity = "recovered".to_string();
4839 args = set_pack_builder_goal_arg(args, inferred);
4840 } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
4841 {
4842 args_source = "recovered_from_context".to_string();
4843 args_integrity = "recovered".to_string();
4844 args = set_pack_builder_goal_arg(args, recovered);
4845 } else {
4846 args_source = "missing".to_string();
4847 args_integrity = "empty".to_string();
4848 missing_terminal = true;
4849 missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
4850 }
4851 args = ensure_pack_builder_default_mode(args);
4852 } else if is_email_delivery_tool_name(&normalized_tool) {
4853 let sanitized = sanitize_email_attachment_args(args);
4854 if sanitized != original_args {
4855 args_source = "sanitized_attachment".to_string();
4856 args_integrity = "recovered".to_string();
4857 }
4858 args = sanitized;
4859 }
4860
4861 NormalizedToolArgs {
4862 args,
4863 args_source,
4864 args_integrity,
4865 raw_args_state,
4866 query,
4867 missing_terminal,
4868 missing_terminal_reason,
4869 }
4870}
4871
4872fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
4873 match raw_args {
4874 Value::Null => RawToolArgsState::Empty,
4875 Value::Object(obj) => {
4876 if obj.is_empty() {
4877 RawToolArgsState::Empty
4878 } else {
4879 RawToolArgsState::Present
4880 }
4881 }
4882 Value::Array(items) => {
4883 if items.is_empty() {
4884 RawToolArgsState::Empty
4885 } else {
4886 RawToolArgsState::Present
4887 }
4888 }
4889 Value::String(raw) => {
4890 let trimmed = raw.trim();
4891 if trimmed.is_empty() {
4892 return RawToolArgsState::Empty;
4893 }
4894 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
4895 return classify_raw_tool_args_state(&parsed);
4896 }
4897 if parse_function_style_args(trimmed).is_empty() {
4898 return RawToolArgsState::Unparseable;
4899 }
4900 RawToolArgsState::Present
4901 }
4902 _ => RawToolArgsState::Present,
4903 }
4904}
4905
4906fn args_missing_or_empty(args: &Value) -> bool {
4907 match args {
4908 Value::Null => true,
4909 Value::Object(obj) => obj.is_empty(),
4910 Value::Array(items) => items.is_empty(),
4911 Value::String(raw) => raw.trim().is_empty(),
4912 _ => false,
4913 }
4914}
4915
4916fn persisted_failed_tool_args(raw_args: &Value, normalized_args: &Value) -> Value {
4917 if args_missing_or_empty(raw_args) && !args_missing_or_empty(normalized_args) {
4918 normalized_args.clone()
4919 } else {
4920 raw_args.clone()
4921 }
4922}
4923
4924fn provider_specific_write_reason(
4925 tool: &str,
4926 missing_reason: &str,
4927 raw_args_state: RawToolArgsState,
4928) -> Option<String> {
4929 if tool != "write"
4930 || !matches!(
4931 missing_reason,
4932 "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
4933 )
4934 {
4935 return None;
4936 }
4937 match raw_args_state {
4938 RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
4939 RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
4940 RawToolArgsState::Present => None,
4941 }
4942}
4943
4944fn is_shell_tool_name(tool_name: &str) -> bool {
4945 matches!(
4946 tool_name.trim().to_ascii_lowercase().as_str(),
4947 "bash" | "shell" | "powershell" | "cmd"
4948 )
4949}
4950
4951fn email_tool_name_tokens(tool_name: &str) -> Vec<String> {
4952 tool_name
4953 .trim()
4954 .to_ascii_lowercase()
4955 .chars()
4956 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
4957 .collect::<String>()
4958 .split_whitespace()
4959 .map(str::to_string)
4960 .collect::<Vec<_>>()
4961}
4962
4963fn email_tool_name_compact(tool_name: &str) -> String {
4964 tool_name
4965 .trim()
4966 .to_ascii_lowercase()
4967 .chars()
4968 .filter(|ch| ch.is_ascii_alphanumeric())
4969 .collect::<String>()
4970}
4971
4972fn is_email_delivery_tool_name(tool_name: &str) -> bool {
4973 let tokens = email_tool_name_tokens(tool_name);
4974 let compact = email_tool_name_compact(tool_name);
4975 let looks_like_email_provider = tokens.iter().any(|token| {
4976 matches!(
4977 token.as_str(),
4978 "email"
4979 | "mail"
4980 | "gmail"
4981 | "outlook"
4982 | "smtp"
4983 | "imap"
4984 | "inbox"
4985 | "mailbox"
4986 | "mailer"
4987 | "exchange"
4988 | "sendgrid"
4989 | "mailgun"
4990 | "postmark"
4991 | "resend"
4992 | "ses"
4993 )
4994 });
4995 if !looks_like_email_provider {
4996 return false;
4997 }
4998 tokens.iter().any(|token| {
4999 matches!(
5000 token.as_str(),
5001 "send" | "deliver" | "reply" | "draft" | "compose" | "create"
5002 )
5003 }) || compact.contains("sendemail")
5004 || compact.contains("emailsend")
5005 || compact.contains("replyemail")
5006 || compact.contains("emailreply")
5007 || compact.contains("draftemail")
5008 || compact.contains("emaildraft")
5009 || compact.contains("composeemail")
5010 || compact.contains("emailcompose")
5011 || compact.contains("createemaildraft")
5012 || compact.contains("emailcreatedraft")
5013}
5014
5015fn sanitize_email_attachment_args(args: Value) -> Value {
5016 let mut obj = match args {
5017 Value::Object(map) => map,
5018 other => return other,
5019 };
5020 if let Some(Value::Object(attachment)) = obj.get("attachment") {
5021 let s3key = attachment
5022 .get("s3key")
5023 .and_then(Value::as_str)
5024 .map(str::trim)
5025 .unwrap_or("");
5026 if s3key.is_empty() {
5027 obj.remove("attachment");
5028 }
5029 } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
5030 obj.remove("attachment");
5031 }
5032 if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
5033 attachments.retain(|entry| {
5034 entry
5035 .get("s3key")
5036 .and_then(Value::as_str)
5037 .map(str::trim)
5038 .map(|value| !value.is_empty())
5039 .unwrap_or(false)
5040 });
5041 if attachments.is_empty() {
5042 obj.remove("attachments");
5043 }
5044 }
5045 Value::Object(obj)
5046}
5047
5048fn set_file_path_arg(args: Value, path: String) -> Value {
5049 let mut obj = args.as_object().cloned().unwrap_or_default();
5050 obj.insert("path".to_string(), Value::String(path));
5051 Value::Object(obj)
5052}
5053
5054fn normalize_workspace_alias_path(path: &str, workspace_root: &str) -> Option<String> {
5055 let trimmed = path.trim();
5056 if trimmed.is_empty() {
5057 return None;
5058 }
5059 let normalized = trimmed.replace('\\', "/");
5060 if normalized == "/workspace" {
5061 return Some(workspace_root.to_string());
5062 }
5063 if let Some(rest) = normalized.strip_prefix("/workspace/") {
5064 if rest.trim().is_empty() {
5065 return Some(workspace_root.to_string());
5066 }
5067 return Some(rest.trim().to_string());
5068 }
5069 None
5070}
5071
5072fn rewrite_workspace_alias_tool_args(tool: &str, args: Value, workspace_root: &str) -> Value {
5073 let normalized_tool = normalize_tool_name(tool);
5074 if !matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5075 return args;
5076 }
5077 let Some(path) = extract_file_path_arg(&args) else {
5078 return args;
5079 };
5080 let Some(rewritten) = normalize_workspace_alias_path(&path, workspace_root) else {
5081 return args;
5082 };
5083 set_file_path_arg(args, rewritten)
5084}
5085
5086fn set_write_content_arg(args: Value, content: String) -> Value {
5087 let mut obj = args.as_object().cloned().unwrap_or_default();
5088 obj.insert("content".to_string(), Value::String(content));
5089 Value::Object(obj)
5090}
5091
5092fn extract_file_path_arg(args: &Value) -> Option<String> {
5093 extract_file_path_arg_internal(args, 0)
5094}
5095
5096fn extract_write_content_arg(args: &Value) -> Option<String> {
5097 extract_write_content_arg_internal(args, 0)
5098}
5099
5100fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
5101 if depth > 5 {
5102 return None;
5103 }
5104
5105 match args {
5106 Value::String(raw) => {
5107 let trimmed = raw.trim();
5108 if trimmed.is_empty() {
5109 return None;
5110 }
5111 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5113 return sanitize_path_candidate(trimmed);
5114 }
5115 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5116 return extract_file_path_arg_internal(&parsed, depth + 1);
5117 }
5118 sanitize_path_candidate(trimmed)
5119 }
5120 Value::Array(items) => items
5121 .iter()
5122 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
5123 Value::Object(obj) => {
5124 for key in FILE_PATH_KEYS {
5125 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5126 if let Some(path) = sanitize_path_candidate(raw) {
5127 return Some(path);
5128 }
5129 }
5130 }
5131 for container in NESTED_ARGS_KEYS {
5132 if let Some(nested) = obj.get(container) {
5133 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
5134 return Some(path);
5135 }
5136 }
5137 }
5138 None
5139 }
5140 _ => None,
5141 }
5142}
5143
5144fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
5145 if depth > 5 {
5146 return None;
5147 }
5148
5149 match args {
5150 Value::String(raw) => {
5151 let trimmed = raw.trim();
5152 if trimmed.is_empty() {
5153 return None;
5154 }
5155 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5156 return extract_write_content_arg_internal(&parsed, depth + 1);
5157 }
5158 if sanitize_path_candidate(trimmed).is_some()
5161 && !trimmed.contains('\n')
5162 && trimmed.split_whitespace().count() <= 3
5163 {
5164 return None;
5165 }
5166 Some(trimmed.to_string())
5167 }
5168 Value::Array(items) => items
5169 .iter()
5170 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
5171 Value::Object(obj) => {
5172 for key in WRITE_CONTENT_KEYS {
5173 if let Some(value) = obj.get(key) {
5174 if let Some(raw) = value.as_str() {
5175 if !raw.is_empty() {
5176 return Some(raw.to_string());
5177 }
5178 } else if let Some(recovered) =
5179 extract_write_content_arg_internal(value, depth + 1)
5180 {
5181 return Some(recovered);
5182 }
5183 }
5184 }
5185 for container in NESTED_ARGS_KEYS {
5186 if let Some(nested) = obj.get(container) {
5187 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
5188 return Some(content);
5189 }
5190 }
5191 }
5192 None
5193 }
5194 _ => None,
5195 }
5196}
5197
5198fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
5199 let text = latest_assistant_context.trim();
5200 if text.len() < 32 {
5201 return None;
5202 }
5203 Some(text.to_string())
5204}
5205
5206fn set_shell_command(args: Value, command: String) -> Value {
5207 let mut obj = args.as_object().cloned().unwrap_or_default();
5208 obj.insert("command".to_string(), Value::String(command));
5209 Value::Object(obj)
5210}
5211
5212fn extract_shell_command(args: &Value) -> Option<String> {
5213 extract_shell_command_internal(args, 0)
5214}
5215
5216fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
5217 if depth > 5 {
5218 return None;
5219 }
5220
5221 match args {
5222 Value::String(raw) => {
5223 let trimmed = raw.trim();
5224 if trimmed.is_empty() {
5225 return None;
5226 }
5227 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5228 return sanitize_shell_command_candidate(trimmed);
5229 }
5230 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5231 return extract_shell_command_internal(&parsed, depth + 1);
5232 }
5233 sanitize_shell_command_candidate(trimmed)
5234 }
5235 Value::Array(items) => items
5236 .iter()
5237 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
5238 Value::Object(obj) => {
5239 for key in SHELL_COMMAND_KEYS {
5240 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5241 if let Some(command) = sanitize_shell_command_candidate(raw) {
5242 return Some(command);
5243 }
5244 }
5245 }
5246 for container in NESTED_ARGS_KEYS {
5247 if let Some(nested) = obj.get(container) {
5248 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
5249 return Some(command);
5250 }
5251 }
5252 }
5253 None
5254 }
5255 _ => None,
5256 }
5257}
5258
5259fn infer_shell_command_from_text(text: &str) -> Option<String> {
5260 let trimmed = text.trim();
5261 if trimmed.is_empty() {
5262 return None;
5263 }
5264
5265 let mut in_tick = false;
5267 let mut tick_buf = String::new();
5268 for ch in trimmed.chars() {
5269 if ch == '`' {
5270 if in_tick {
5271 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
5272 if looks_like_shell_command(&candidate) {
5273 return Some(candidate);
5274 }
5275 }
5276 tick_buf.clear();
5277 }
5278 in_tick = !in_tick;
5279 continue;
5280 }
5281 if in_tick {
5282 tick_buf.push(ch);
5283 }
5284 }
5285
5286 for line in trimmed.lines() {
5287 let line = line.trim();
5288 if line.is_empty() {
5289 continue;
5290 }
5291 let lower = line.to_ascii_lowercase();
5292 for prefix in [
5293 "run ",
5294 "execute ",
5295 "call ",
5296 "use bash ",
5297 "use shell ",
5298 "bash ",
5299 "shell ",
5300 "powershell ",
5301 "pwsh ",
5302 ] {
5303 if lower.starts_with(prefix) {
5304 let candidate = line[prefix.len()..].trim();
5305 if let Some(command) = sanitize_shell_command_candidate(candidate) {
5306 if looks_like_shell_command(&command) {
5307 return Some(command);
5308 }
5309 }
5310 }
5311 }
5312 }
5313
5314 None
5315}
5316
5317fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
5318 let mut obj = args.as_object().cloned().unwrap_or_default();
5319 if let Some(q) = query {
5320 obj.insert("query".to_string(), Value::String(q));
5321 }
5322 obj.insert(
5323 "__query_source".to_string(),
5324 Value::String(query_source.to_string()),
5325 );
5326 Value::Object(obj)
5327}
5328
5329fn set_webfetch_url_arg(args: Value, url: String) -> Value {
5330 let mut obj = args.as_object().cloned().unwrap_or_default();
5331 obj.insert("url".to_string(), Value::String(url));
5332 Value::Object(obj)
5333}
5334
5335fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
5336 let mut obj = args.as_object().cloned().unwrap_or_default();
5337 obj.insert("goal".to_string(), Value::String(goal));
5338 Value::Object(obj)
5339}
5340
5341fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
5342 let mut obj = args.as_object().cloned().unwrap_or_default();
5343 obj.insert("mode".to_string(), Value::String("apply".to_string()));
5344 obj.insert("plan_id".to_string(), Value::String(plan_id));
5345 obj.insert(
5346 "approve_connector_registration".to_string(),
5347 Value::Bool(true),
5348 );
5349 obj.insert("approve_pack_install".to_string(), Value::Bool(true));
5350 obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
5351 Value::Object(obj)
5352}
5353
5354fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
5355 for key in ["mode"] {
5356 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5357 let mode = value.trim().to_ascii_lowercase();
5358 if !mode.is_empty() {
5359 return Some(mode);
5360 }
5361 }
5362 }
5363 for container in ["arguments", "args", "input", "params"] {
5364 if let Some(obj) = args.get(container) {
5365 if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
5366 let mode = value.trim().to_ascii_lowercase();
5367 if !mode.is_empty() {
5368 return Some(mode);
5369 }
5370 }
5371 }
5372 }
5373 None
5374}
5375
5376fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
5377 for key in ["plan_id", "planId"] {
5378 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5379 let plan_id = value.trim();
5380 if !plan_id.is_empty() {
5381 return Some(plan_id.to_string());
5382 }
5383 }
5384 }
5385 for container in ["arguments", "args", "input", "params"] {
5386 if let Some(obj) = args.get(container) {
5387 for key in ["plan_id", "planId"] {
5388 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5389 let plan_id = value.trim();
5390 if !plan_id.is_empty() {
5391 return Some(plan_id.to_string());
5392 }
5393 }
5394 }
5395 }
5396 }
5397 None
5398}
5399
5400fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
5401 if text.trim().is_empty() {
5402 return None;
5403 }
5404 let bytes = text.as_bytes();
5405 let mut idx = 0usize;
5406 while idx + 5 <= bytes.len() {
5407 if &bytes[idx..idx + 5] != b"plan-" {
5408 idx += 1;
5409 continue;
5410 }
5411 let mut end = idx + 5;
5412 while end < bytes.len() {
5413 let ch = bytes[end] as char;
5414 if ch.is_ascii_alphanumeric() || ch == '-' {
5415 end += 1;
5416 } else {
5417 break;
5418 }
5419 }
5420 if end > idx + 5 {
5421 let candidate = &text[idx..end];
5422 if candidate.len() >= 10 {
5423 return Some(candidate.to_string());
5424 }
5425 }
5426 idx = end.saturating_add(1);
5427 }
5428 None
5429}
5430
5431fn is_pack_builder_confirmation_text(text: &str) -> bool {
5432 let trimmed = text.trim();
5433 if trimmed.is_empty() {
5434 return false;
5435 }
5436 let lower = trimmed.to_ascii_lowercase();
5437 matches!(
5438 lower.as_str(),
5439 "confirm"
5440 | "confirmed"
5441 | "approve"
5442 | "approved"
5443 | "yes"
5444 | "y"
5445 | "ok"
5446 | "okay"
5447 | "go"
5448 | "go ahead"
5449 | "ship it"
5450 | "do it"
5451 | "apply"
5452 | "run it"
5453 | "✅"
5454 | "👍"
5455 )
5456}
5457
5458fn infer_pack_builder_apply_plan_id(
5459 latest_user_text: &str,
5460 latest_assistant_context: &str,
5461) -> Option<String> {
5462 if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
5463 return Some(plan_id);
5464 }
5465 if !is_pack_builder_confirmation_text(latest_user_text) {
5466 return None;
5467 }
5468 extract_pack_builder_plan_id_from_text(latest_assistant_context)
5469}
5470
5471fn ensure_pack_builder_default_mode(args: Value) -> Value {
5472 let mut obj = args.as_object().cloned().unwrap_or_default();
5473 let has_mode = obj
5474 .get("mode")
5475 .and_then(Value::as_str)
5476 .map(str::trim)
5477 .is_some_and(|v| !v.is_empty());
5478 if !has_mode {
5479 obj.insert("mode".to_string(), Value::String("preview".to_string()));
5480 }
5481 Value::Object(obj)
5482}
5483
5484fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
5485 const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
5486 for key in URL_KEYS {
5487 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5488 if let Some(url) = sanitize_url_candidate(value) {
5489 return Some(url);
5490 }
5491 }
5492 }
5493 for container in ["arguments", "args", "input", "params"] {
5494 if let Some(obj) = args.get(container) {
5495 for key in URL_KEYS {
5496 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5497 if let Some(url) = sanitize_url_candidate(value) {
5498 return Some(url);
5499 }
5500 }
5501 }
5502 }
5503 }
5504 args.as_str().and_then(sanitize_url_candidate)
5505}
5506
5507fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
5508 const GOAL_KEYS: [&str; 1] = ["goal"];
5509 for key in GOAL_KEYS {
5510 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5511 let trimmed = value.trim();
5512 if !trimmed.is_empty() {
5513 return Some(trimmed.to_string());
5514 }
5515 }
5516 }
5517 for container in ["arguments", "args", "input", "params"] {
5518 if let Some(obj) = args.get(container) {
5519 for key in GOAL_KEYS {
5520 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5521 let trimmed = value.trim();
5522 if !trimmed.is_empty() {
5523 return Some(trimmed.to_string());
5524 }
5525 }
5526 }
5527 }
5528 }
5529 args.as_str()
5530 .map(str::trim)
5531 .filter(|v| !v.is_empty())
5532 .map(ToString::to_string)
5533}
5534
5535fn extract_websearch_query(args: &Value) -> Option<String> {
5536 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
5537 for key in QUERY_KEYS {
5538 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5539 if let Some(query) = sanitize_websearch_query_candidate(value) {
5540 return Some(query);
5541 }
5542 }
5543 }
5544 for container in ["arguments", "args", "input", "params"] {
5545 if let Some(obj) = args.get(container) {
5546 for key in QUERY_KEYS {
5547 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5548 if let Some(query) = sanitize_websearch_query_candidate(value) {
5549 return Some(query);
5550 }
5551 }
5552 }
5553 }
5554 }
5555 args.as_str().and_then(sanitize_websearch_query_candidate)
5556}
5557
5558fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
5559 let trimmed = raw.trim();
5560 if trimmed.is_empty() {
5561 return None;
5562 }
5563
5564 let lower = trimmed.to_ascii_lowercase();
5565 if let Some(start) = lower.find("<arg_value>") {
5566 let value_start = start + "<arg_value>".len();
5567 let tail = &trimmed[value_start..];
5568 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
5569 &tail[..end]
5570 } else {
5571 tail
5572 };
5573 let cleaned = value.trim();
5574 if !cleaned.is_empty() {
5575 return Some(cleaned.to_string());
5576 }
5577 }
5578
5579 let without_wrappers = trimmed
5580 .replace("<arg_key>", " ")
5581 .replace("</arg_key>", " ")
5582 .replace("<arg_value>", " ")
5583 .replace("</arg_value>", " ");
5584 let collapsed = without_wrappers
5585 .split_whitespace()
5586 .collect::<Vec<_>>()
5587 .join(" ");
5588 if collapsed.is_empty() {
5589 return None;
5590 }
5591
5592 let collapsed_lower = collapsed.to_ascii_lowercase();
5593 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
5594 let offset = collapsed.len() - rest.len();
5595 let q = collapsed[offset..].trim();
5596 if !q.is_empty() {
5597 return Some(q.to_string());
5598 }
5599 }
5600 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
5601 let offset = collapsed.len() - rest.len();
5602 let q = collapsed[offset..].trim();
5603 if !q.is_empty() {
5604 return Some(q.to_string());
5605 }
5606 }
5607
5608 Some(collapsed)
5609}
5610
5611fn infer_websearch_query_from_text(text: &str) -> Option<String> {
5612 let trimmed = text.trim();
5613 if trimmed.is_empty() {
5614 return None;
5615 }
5616
5617 let lower = trimmed.to_lowercase();
5618 const PREFIXES: [&str; 11] = [
5619 "web search",
5620 "websearch",
5621 "search web for",
5622 "search web",
5623 "search for",
5624 "search",
5625 "look up",
5626 "lookup",
5627 "find",
5628 "web lookup",
5629 "query",
5630 ];
5631
5632 let mut candidate = trimmed;
5633 for prefix in PREFIXES {
5634 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
5635 let remainder = trimmed[prefix.len()..]
5636 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
5637 candidate = remainder;
5638 break;
5639 }
5640 }
5641
5642 let normalized = candidate
5643 .trim()
5644 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
5645 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
5646 .trim()
5647 .to_string();
5648
5649 if normalized.split_whitespace().count() < 2 {
5650 return None;
5651 }
5652 Some(normalized)
5653}
5654
5655fn infer_file_path_from_text(text: &str) -> Option<String> {
5656 let trimmed = text.trim();
5657 if trimmed.is_empty() {
5658 return None;
5659 }
5660
5661 let mut candidates: Vec<String> = Vec::new();
5662
5663 let mut in_tick = false;
5665 let mut tick_buf = String::new();
5666 for ch in trimmed.chars() {
5667 if ch == '`' {
5668 if in_tick {
5669 let cand = sanitize_path_candidate(&tick_buf);
5670 if let Some(path) = cand {
5671 candidates.push(path);
5672 }
5673 tick_buf.clear();
5674 }
5675 in_tick = !in_tick;
5676 continue;
5677 }
5678 if in_tick {
5679 tick_buf.push(ch);
5680 }
5681 }
5682
5683 for raw in trimmed.split_whitespace() {
5685 if let Some(path) = sanitize_path_candidate(raw) {
5686 candidates.push(path);
5687 }
5688 }
5689
5690 let mut deduped = Vec::new();
5691 let mut seen = HashSet::new();
5692 for candidate in candidates {
5693 if seen.insert(candidate.clone()) {
5694 deduped.push(candidate);
5695 }
5696 }
5697
5698 deduped.into_iter().next()
5699}
5700
5701fn infer_workspace_root_from_text(text: &str) -> Option<String> {
5702 text.lines().find_map(|line| {
5703 let trimmed = line.trim();
5704 let value = trimmed.strip_prefix("Workspace:")?.trim();
5705 sanitize_path_candidate(value)
5706 })
5707}
5708
5709fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
5710 let marker = "Required output target:";
5713 if let Some(idx) = text.find(marker) {
5714 let tail = text[idx + marker.len()..].trim_start();
5715 if let Some(start) = tail.find('{') {
5716 let json_candidate = tail[start..]
5717 .lines()
5718 .take_while(|line| {
5719 let trimmed = line.trim();
5720 !(trimmed.is_empty() && !trimmed.starts_with('{'))
5721 })
5722 .collect::<Vec<_>>()
5723 .join("\n");
5724 if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
5725 if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
5726 if let Some(clean) = sanitize_explicit_output_target_path(path) {
5727 return Some(clean);
5728 }
5729 }
5730 }
5731 }
5732 }
5733 let auto_marker = "Create or update `";
5736 if let Some(idx) = text.find(auto_marker) {
5737 let after = &text[idx + auto_marker.len()..];
5738 if let Some(end) = after.find('`') {
5739 let path = after[..end].trim();
5740 if let Some(clean) = sanitize_explicit_output_target_path(path) {
5741 return Some(clean);
5742 }
5743 }
5744 }
5745 None
5746}
5747
5748fn infer_write_file_path_from_text(text: &str) -> Option<String> {
5749 let inferred = infer_file_path_from_text(text)?;
5750 let workspace_root = infer_workspace_root_from_text(text);
5751 if workspace_root
5752 .as_deref()
5753 .is_some_and(|root| root == inferred)
5754 {
5755 return None;
5756 }
5757 Some(inferred)
5758}
5759
5760fn infer_url_from_text(text: &str) -> Option<String> {
5761 let trimmed = text.trim();
5762 if trimmed.is_empty() {
5763 return None;
5764 }
5765
5766 let mut candidates: Vec<String> = Vec::new();
5767
5768 let mut in_tick = false;
5770 let mut tick_buf = String::new();
5771 for ch in trimmed.chars() {
5772 if ch == '`' {
5773 if in_tick {
5774 if let Some(url) = sanitize_url_candidate(&tick_buf) {
5775 candidates.push(url);
5776 }
5777 tick_buf.clear();
5778 }
5779 in_tick = !in_tick;
5780 continue;
5781 }
5782 if in_tick {
5783 tick_buf.push(ch);
5784 }
5785 }
5786
5787 for raw in trimmed.split_whitespace() {
5789 if let Some(url) = sanitize_url_candidate(raw) {
5790 candidates.push(url);
5791 }
5792 }
5793
5794 let mut seen = HashSet::new();
5795 candidates
5796 .into_iter()
5797 .find(|candidate| seen.insert(candidate.clone()))
5798}
5799
5800fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
5801 let trimmed = text.trim();
5802 if trimmed.is_empty() {
5803 None
5804 } else {
5805 Some(trimmed.to_string())
5806 }
5807}
5808
5809fn sanitize_url_candidate(raw: &str) -> Option<String> {
5810 let token = raw
5811 .trim()
5812 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
5813 .trim_start_matches(['(', '[', '{', '<'])
5814 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
5815 .trim_end_matches('.')
5816 .trim();
5817
5818 if token.is_empty() {
5819 return None;
5820 }
5821 let lower = token.to_ascii_lowercase();
5822 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
5823 return None;
5824 }
5825 Some(token.to_string())
5826}
5827
5828fn clean_path_candidate_token(raw: &str) -> Option<String> {
5829 let token = raw.trim();
5830 let token = token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'));
5831 let token = token.trim_start_matches(['(', '[', '{', '<']);
5832 let token = token.trim_end_matches([',', ';', ':', ')', ']', '}', '>']);
5833 let token = token.trim_end_matches('.').trim();
5834
5835 if token.is_empty() {
5836 return None;
5837 }
5838 Some(token.to_string())
5839}
5840
5841fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
5842 let token = clean_path_candidate_token(raw)?;
5843 let lower = token.to_ascii_lowercase();
5844 if lower.starts_with("http://") || lower.starts_with("https://") {
5845 return None;
5846 }
5847 if is_malformed_tool_path_token(&token) {
5848 return None;
5849 }
5850 if is_root_only_path_token(&token) {
5851 return None;
5852 }
5853 if is_placeholder_path_token(&token) {
5854 return None;
5855 }
5856 if token.ends_with('/') || token.ends_with('\\') {
5857 return None;
5858 }
5859 Some(token.to_string())
5860}
5861
5862fn sanitize_path_candidate(raw: &str) -> Option<String> {
5863 let token = clean_path_candidate_token(raw)?;
5864 let lower = token.to_ascii_lowercase();
5865 if lower.starts_with("http://") || lower.starts_with("https://") {
5866 return None;
5867 }
5868 if is_malformed_tool_path_token(token.as_str()) {
5869 return None;
5870 }
5871 if is_root_only_path_token(token.as_str()) {
5872 return None;
5873 }
5874 if is_placeholder_path_token(token.as_str()) {
5875 return None;
5876 }
5877 if token.ends_with('/') || token.ends_with('\\') {
5878 return None;
5879 }
5880
5881 let looks_like_path = token.contains('/') || token.contains('\\');
5882 let has_file_ext = [
5883 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
5884 ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
5885 ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
5886 ]
5887 .iter()
5888 .any(|ext| lower.ends_with(ext));
5889
5890 if !looks_like_path && !has_file_ext {
5891 return None;
5892 }
5893
5894 Some(token)
5895}
5896
5897fn is_placeholder_path_token(token: &str) -> bool {
5898 let lowered = token.trim().to_ascii_lowercase();
5899 if lowered.is_empty() {
5900 return true;
5901 }
5902 matches!(
5903 lowered.as_str(),
5904 "files/directories"
5905 | "file/directory"
5906 | "relative/or/absolute/path"
5907 | "path/to/file"
5908 | "path/to/your/file"
5909 | "tool/policy"
5910 | "tools/policy"
5911 | "the expected artifact file"
5912 | "workspace/file"
5913 )
5914}
5915
5916fn is_malformed_tool_path_token(token: &str) -> bool {
5917 let lower = token.to_ascii_lowercase();
5918 if lower.contains("<tool_call")
5920 || lower.contains("</tool_call")
5921 || lower.contains("<function=")
5922 || lower.contains("<parameter=")
5923 || lower.contains("</function>")
5924 || lower.contains("</parameter>")
5925 {
5926 return true;
5927 }
5928 if token.contains('\n') || token.contains('\r') {
5930 return true;
5931 }
5932 if token.contains('*') || token.contains('?') {
5934 return true;
5935 }
5936 false
5937}
5938
5939fn is_root_only_path_token(token: &str) -> bool {
5940 let trimmed = token.trim();
5941 if trimmed.is_empty() {
5942 return true;
5943 }
5944 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
5945 return true;
5946 }
5947 let bytes = trimmed.as_bytes();
5949 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
5950 return true;
5951 }
5952 if bytes.len() == 3
5953 && bytes[1] == b':'
5954 && (bytes[0] as char).is_ascii_alphabetic()
5955 && (bytes[2] == b'\\' || bytes[2] == b'/')
5956 {
5957 return true;
5958 }
5959 false
5960}
5961
5962fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
5963 let token = raw
5964 .trim()
5965 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
5966 .trim();
5967 if token.is_empty() {
5968 return None;
5969 }
5970 Some(token.to_string())
5971}
5972
5973fn looks_like_shell_command(candidate: &str) -> bool {
5974 let lower = candidate.to_ascii_lowercase();
5975 if lower.is_empty() {
5976 return false;
5977 }
5978 let first = lower.split_whitespace().next().unwrap_or_default();
5979 let common = [
5980 "rg",
5981 "git",
5982 "cargo",
5983 "pnpm",
5984 "npm",
5985 "node",
5986 "python",
5987 "pytest",
5988 "pwsh",
5989 "powershell",
5990 "cmd",
5991 "dir",
5992 "ls",
5993 "cat",
5994 "type",
5995 "echo",
5996 "cd",
5997 "mkdir",
5998 "cp",
5999 "copy",
6000 "move",
6001 "del",
6002 "rm",
6003 ];
6004 common.contains(&first)
6005 || first.starts_with("get-")
6006 || first.starts_with("./")
6007 || first.starts_with(".\\")
6008 || lower.contains(" | ")
6009 || lower.contains(" && ")
6010 || lower.contains(" ; ")
6011}
6012
6013const FILE_PATH_KEYS: [&str; 10] = [
6014 "path",
6015 "file_path",
6016 "filePath",
6017 "filepath",
6018 "filename",
6019 "file",
6020 "target",
6021 "targetFile",
6022 "absolutePath",
6023 "uri",
6024];
6025
6026const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
6027
6028const WRITE_CONTENT_KEYS: [&str; 8] = [
6029 "content",
6030 "text",
6031 "body",
6032 "value",
6033 "markdown",
6034 "document",
6035 "output",
6036 "file_content",
6037];
6038
6039const NESTED_ARGS_KEYS: [&str; 10] = [
6040 "arguments",
6041 "args",
6042 "input",
6043 "params",
6044 "payload",
6045 "data",
6046 "tool_input",
6047 "toolInput",
6048 "tool_args",
6049 "toolArgs",
6050];
6051
6052fn tool_signature(tool_name: &str, args: &Value) -> String {
6053 let normalized = normalize_tool_name(tool_name);
6054 if normalized == "websearch" {
6055 let query = extract_websearch_query(args)
6056 .unwrap_or_default()
6057 .to_lowercase();
6058 let limit = args
6059 .get("limit")
6060 .or_else(|| args.get("numResults"))
6061 .or_else(|| args.get("num_results"))
6062 .and_then(|v| v.as_u64())
6063 .unwrap_or(8);
6064 let domains = args
6065 .get("domains")
6066 .or_else(|| args.get("domain"))
6067 .map(|v| v.to_string())
6068 .unwrap_or_default();
6069 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
6070 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
6071 }
6072 format!("{}:{}", normalized, args)
6073}
6074
6075fn stable_hash(input: &str) -> String {
6076 let mut hasher = DefaultHasher::new();
6077 input.hash(&mut hasher);
6078 format!("{:016x}", hasher.finish())
6079}
6080
6081fn summarize_tool_outputs(outputs: &[String]) -> String {
6082 outputs
6083 .iter()
6084 .take(6)
6085 .map(|output| truncate_text(output, 600))
6086 .collect::<Vec<_>>()
6087 .join("\n\n")
6088}
6089
6090fn is_os_mismatch_tool_output(output: &str) -> bool {
6091 let lower = output.to_ascii_lowercase();
6092 lower.contains("os error 3")
6093 || lower.contains("system cannot find the path specified")
6094 || lower.contains("command not found")
6095 || lower.contains("is not recognized as an internal or external command")
6096 || lower.contains("shell command blocked on windows")
6097}
6098
6099fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
6100 match requested {
6101 ContextMode::Full => "full",
6102 ContextMode::Compact => "compact",
6103 ContextMode::Auto => {
6104 if auto_compact {
6105 "auto_compact"
6106 } else {
6107 "auto_standard"
6108 }
6109 }
6110 }
6111}
6112
6113fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
6114 let mut sections = Vec::new();
6115 if os_aware_prompts_enabled() {
6116 sections.push(format!(
6117 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
6118 host_os_label(host.os),
6119 shell_family_label(host.shell_family),
6120 path_style_label(host.path_style),
6121 host.arch
6122 ));
6123 }
6124 sections.push(
6125 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
6126Use tool calls to inspect and modify the workspace when needed instead of asking the user
6127to manually run basic discovery steps. Permission prompts may occur for some tools; if
6128a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
6129 .to_string(),
6130 );
6131 sections.push(
6132 "For greetings or simple conversational messages (for example: hi, hello, thanks),
6133respond directly without calling tools."
6134 .to_string(),
6135 );
6136 if host.os == HostOs::Windows {
6137 sections.push(
6138 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
6139Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
6140If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
6141 .to_string(),
6142 );
6143 } else {
6144 sections.push(
6145 "POSIX guidance: standard shell commands are available.
6146Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
6147 .to_string(),
6148 );
6149 }
6150 if !mcp_server_names.is_empty() {
6151 let cap = mcp_catalog_max_servers();
6152 let mut listed = mcp_server_names
6153 .iter()
6154 .take(cap)
6155 .cloned()
6156 .collect::<Vec<_>>();
6157 listed.sort();
6158 let mut catalog = listed
6159 .iter()
6160 .map(|name| format!("- {name}"))
6161 .collect::<Vec<_>>();
6162 if mcp_server_names.len() > cap {
6163 catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
6164 }
6165 sections.push(format!(
6166 "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
6167 catalog.join("\n")
6168 ));
6169 }
6170 sections.join("\n\n")
6171}
6172
6173fn os_aware_prompts_enabled() -> bool {
6174 std::env::var("TANDEM_OS_AWARE_PROMPTS")
6175 .ok()
6176 .map(|v| {
6177 let normalized = v.trim().to_ascii_lowercase();
6178 !(normalized == "0" || normalized == "false" || normalized == "off")
6179 })
6180 .unwrap_or(true)
6181}
6182
6183fn semantic_tool_retrieval_enabled() -> bool {
6184 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
6185 .ok()
6186 .map(|raw| {
6187 !matches!(
6188 raw.trim().to_ascii_lowercase().as_str(),
6189 "0" | "false" | "off" | "no"
6190 )
6191 })
6192 .unwrap_or(true)
6193}
6194
6195fn semantic_tool_retrieval_k() -> usize {
6196 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
6197 .ok()
6198 .and_then(|raw| raw.trim().parse::<usize>().ok())
6199 .filter(|value| *value > 0)
6200 .unwrap_or_else(max_tools_per_call_expanded)
6201}
6202
6203fn mcp_catalog_in_system_prompt_enabled() -> bool {
6204 std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
6205 .ok()
6206 .map(|raw| {
6207 !matches!(
6208 raw.trim().to_ascii_lowercase().as_str(),
6209 "0" | "false" | "off" | "no"
6210 )
6211 })
6212 .unwrap_or(true)
6213}
6214
6215fn mcp_catalog_max_servers() -> usize {
6216 std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
6217 .ok()
6218 .and_then(|raw| raw.trim().parse::<usize>().ok())
6219 .filter(|value| *value > 0)
6220 .unwrap_or(20)
6221}
6222
6223fn host_os_label(os: HostOs) -> &'static str {
6224 match os {
6225 HostOs::Windows => "windows",
6226 HostOs::Linux => "linux",
6227 HostOs::Macos => "macos",
6228 }
6229}
6230
6231fn shell_family_label(shell: ShellFamily) -> &'static str {
6232 match shell {
6233 ShellFamily::Powershell => "powershell",
6234 ShellFamily::Posix => "posix",
6235 }
6236}
6237
6238fn path_style_label(path_style: PathStyle) -> &'static str {
6239 match path_style {
6240 PathStyle::Windows => "windows",
6241 PathStyle::Posix => "posix",
6242 }
6243}
6244
6245fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
6246 let user = user_text.to_lowercase();
6247 let reply = completion.to_lowercase();
6248
6249 let asked_for_project_context = [
6250 "what is this project",
6251 "what's this project",
6252 "what project is this",
6253 "explain this project",
6254 "analyze this project",
6255 "inspect this project",
6256 "look at the project",
6257 "summarize this project",
6258 "show me this project",
6259 "what files are in",
6260 "show files",
6261 "list files",
6262 "read files",
6263 "browse files",
6264 "use glob",
6265 "run glob",
6266 ]
6267 .iter()
6268 .any(|needle| user.contains(needle));
6269
6270 if !asked_for_project_context {
6271 return false;
6272 }
6273
6274 let assistant_claimed_no_access = [
6275 "can't inspect",
6276 "cannot inspect",
6277 "unable to inspect",
6278 "unable to directly inspect",
6279 "can't access",
6280 "cannot access",
6281 "unable to access",
6282 "can't read files",
6283 "cannot read files",
6284 "unable to read files",
6285 "tool restriction",
6286 "tool restrictions",
6287 "don't have visibility",
6288 "no visibility",
6289 "haven't been able to inspect",
6290 "i don't know what this project is",
6291 "need your help to",
6292 "sandbox",
6293 "restriction",
6294 "system restriction",
6295 "permissions restrictions",
6296 ]
6297 .iter()
6298 .any(|needle| reply.contains(needle));
6299
6300 asked_for_project_context && assistant_claimed_no_access
6303}
6304
6305fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
6306 let raw = input.trim();
6307 if !raw.starts_with("/tool ") {
6308 return None;
6309 }
6310 let rest = raw.trim_start_matches("/tool ").trim();
6311 let mut split = rest.splitn(2, ' ');
6312 let tool = normalize_tool_name(split.next()?.trim());
6313 let args = split
6314 .next()
6315 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
6316 .unwrap_or_else(|| json!({}));
6317 Some((tool, args))
6318}
6319
6320fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
6321 let trimmed = input.trim();
6322 if trimmed.is_empty() {
6323 return Vec::new();
6324 }
6325
6326 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
6327 if let Some(found) = extract_tool_call_from_value(&parsed) {
6328 return vec![found];
6329 }
6330 }
6331
6332 if let Some(block) = extract_first_json_object(trimmed) {
6333 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
6334 if let Some(found) = extract_tool_call_from_value(&parsed) {
6335 return vec![found];
6336 }
6337 }
6338 }
6339
6340 parse_function_style_tool_calls(trimmed)
6341}
6342
6343#[cfg(test)]
6344fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
6345 parse_tool_invocations_from_response(input)
6346 .into_iter()
6347 .next()
6348}
6349
6350fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
6351 let mut calls = Vec::new();
6352 let lower = input.to_lowercase();
6353 let names = [
6354 "todo_write",
6355 "todowrite",
6356 "update_todo_list",
6357 "update_todos",
6358 ];
6359 let mut cursor = 0usize;
6360
6361 while cursor < lower.len() {
6362 let mut best: Option<(usize, &str)> = None;
6363 for name in names {
6364 let needle = format!("{name}(");
6365 if let Some(rel_idx) = lower[cursor..].find(&needle) {
6366 let idx = cursor + rel_idx;
6367 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
6368 best = Some((idx, name));
6369 }
6370 }
6371 }
6372
6373 let Some((tool_start, tool_name)) = best else {
6374 break;
6375 };
6376
6377 let open_paren = tool_start + tool_name.len();
6378 if let Some(close_paren) = find_matching_paren(input, open_paren) {
6379 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
6380 let args = parse_function_style_args(args_text.trim());
6381 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
6382 }
6383 cursor = close_paren.saturating_add(1);
6384 } else {
6385 cursor = tool_start.saturating_add(tool_name.len());
6386 }
6387 }
6388
6389 calls
6390}
6391
6392fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
6393 if input.as_bytes().get(open_paren).copied()? != b'(' {
6394 return None;
6395 }
6396
6397 let mut depth = 0usize;
6398 let mut in_single = false;
6399 let mut in_double = false;
6400 let mut escaped = false;
6401
6402 for (offset, ch) in input.get(open_paren..)?.char_indices() {
6403 if escaped {
6404 escaped = false;
6405 continue;
6406 }
6407 if ch == '\\' && (in_single || in_double) {
6408 escaped = true;
6409 continue;
6410 }
6411 if ch == '\'' && !in_double {
6412 in_single = !in_single;
6413 continue;
6414 }
6415 if ch == '"' && !in_single {
6416 in_double = !in_double;
6417 continue;
6418 }
6419 if in_single || in_double {
6420 continue;
6421 }
6422
6423 match ch {
6424 '(' => depth += 1,
6425 ')' => {
6426 depth = depth.saturating_sub(1);
6427 if depth == 0 {
6428 return Some(open_paren + offset);
6429 }
6430 }
6431 _ => {}
6432 }
6433 }
6434
6435 None
6436}
6437
6438fn parse_function_style_args(input: &str) -> Map<String, Value> {
6439 let mut args = Map::new();
6440 if input.trim().is_empty() {
6441 return args;
6442 }
6443
6444 let mut parts = Vec::<String>::new();
6445 let mut current = String::new();
6446 let mut in_single = false;
6447 let mut in_double = false;
6448 let mut escaped = false;
6449 let mut depth_paren = 0usize;
6450 let mut depth_bracket = 0usize;
6451 let mut depth_brace = 0usize;
6452
6453 for ch in input.chars() {
6454 if escaped {
6455 current.push(ch);
6456 escaped = false;
6457 continue;
6458 }
6459 if ch == '\\' && (in_single || in_double) {
6460 current.push(ch);
6461 escaped = true;
6462 continue;
6463 }
6464 if ch == '\'' && !in_double {
6465 in_single = !in_single;
6466 current.push(ch);
6467 continue;
6468 }
6469 if ch == '"' && !in_single {
6470 in_double = !in_double;
6471 current.push(ch);
6472 continue;
6473 }
6474 if in_single || in_double {
6475 current.push(ch);
6476 continue;
6477 }
6478
6479 match ch {
6480 '(' => depth_paren += 1,
6481 ')' => depth_paren = depth_paren.saturating_sub(1),
6482 '[' => depth_bracket += 1,
6483 ']' => depth_bracket = depth_bracket.saturating_sub(1),
6484 '{' => depth_brace += 1,
6485 '}' => depth_brace = depth_brace.saturating_sub(1),
6486 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
6487 let part = current.trim();
6488 if !part.is_empty() {
6489 parts.push(part.to_string());
6490 }
6491 current.clear();
6492 continue;
6493 }
6494 _ => {}
6495 }
6496 current.push(ch);
6497 }
6498 let tail = current.trim();
6499 if !tail.is_empty() {
6500 parts.push(tail.to_string());
6501 }
6502
6503 for part in parts {
6504 let Some((raw_key, raw_value)) = part
6505 .split_once('=')
6506 .or_else(|| part.split_once(':'))
6507 .map(|(k, v)| (k.trim(), v.trim()))
6508 else {
6509 continue;
6510 };
6511 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
6512 if key.is_empty() {
6513 continue;
6514 }
6515 if !is_valid_function_style_key(key) {
6516 continue;
6517 }
6518 let value = parse_scalar_like_value(raw_value);
6519 args.insert(key.to_string(), value);
6520 }
6521
6522 args
6523}
6524
6525fn is_valid_function_style_key(key: &str) -> bool {
6526 let mut chars = key.chars();
6530 let Some(first) = chars.next() else {
6531 return false;
6532 };
6533 if !(first.is_ascii_alphanumeric() || first == '_') {
6534 return false;
6535 }
6536 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
6537}
6538
6539fn parse_scalar_like_value(raw: &str) -> Value {
6540 let trimmed = raw.trim();
6541 if trimmed.is_empty() {
6542 return Value::Null;
6543 }
6544
6545 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
6546 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
6547 {
6548 if trimmed.len() < 2 {
6549 return Value::String(trimmed.to_string());
6550 }
6551 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
6552 }
6553
6554 if trimmed.eq_ignore_ascii_case("true") {
6555 return Value::Bool(true);
6556 }
6557 if trimmed.eq_ignore_ascii_case("false") {
6558 return Value::Bool(false);
6559 }
6560 if trimmed.eq_ignore_ascii_case("null") {
6561 return Value::Null;
6562 }
6563
6564 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
6565 return v;
6566 }
6567 if let Ok(v) = trimmed.parse::<i64>() {
6568 return Value::Number(Number::from(v));
6569 }
6570 if let Ok(v) = trimmed.parse::<f64>() {
6571 if let Some(n) = Number::from_f64(v) {
6572 return Value::Number(n);
6573 }
6574 }
6575
6576 Value::String(trimmed.to_string())
6577}
6578
6579fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
6580 let content = extract_loose_json_string_field(raw, "content")?;
6581 let mut obj = Map::new();
6582 if let Some(path) = extract_loose_json_string_field(raw, "path") {
6583 obj.insert("path".to_string(), Value::String(path));
6584 }
6585 obj.insert("content".to_string(), Value::String(content));
6586 Some(Value::Object(obj))
6587}
6588
6589fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
6590 let pattern = format!("\"{key}\"");
6591 let start = input.find(&pattern)?;
6592 let remainder = input.get(start + pattern.len()..)?;
6593 let colon = remainder.find(':')?;
6594 let value = remainder.get(colon + 1..)?.trim_start();
6595 let value = value.strip_prefix('"')?;
6596 Some(parse_loose_json_string_value(value))
6597}
6598
6599fn parse_loose_json_string_value(input: &str) -> String {
6600 let mut out = String::new();
6601 let mut chars = input.chars().peekable();
6602 let mut closed = false;
6603
6604 while let Some(ch) = chars.next() {
6605 if ch == '"' {
6606 closed = true;
6607 break;
6608 }
6609 if ch != '\\' {
6610 out.push(ch);
6611 continue;
6612 }
6613
6614 let Some(escaped) = chars.next() else {
6615 out.push('\\');
6616 break;
6617 };
6618 match escaped {
6619 '"' => out.push('"'),
6620 '\\' => out.push('\\'),
6621 '/' => out.push('/'),
6622 'b' => out.push('\u{0008}'),
6623 'f' => out.push('\u{000C}'),
6624 'n' => out.push('\n'),
6625 'r' => out.push('\r'),
6626 't' => out.push('\t'),
6627 'u' => {
6628 let mut hex = String::new();
6629 for _ in 0..4 {
6630 let Some(next) = chars.next() else {
6631 break;
6632 };
6633 hex.push(next);
6634 }
6635 if hex.len() == 4 {
6636 if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
6637 if let Some(decoded) = char::from_u32(codepoint as u32) {
6638 out.push(decoded);
6639 continue;
6640 }
6641 }
6642 }
6643 out.push('\\');
6644 out.push('u');
6645 out.push_str(&hex);
6646 }
6647 other => {
6648 out.push('\\');
6649 out.push(other);
6650 }
6651 }
6652 }
6653
6654 if !closed {
6655 return out;
6656 }
6657 out
6658}
6659
6660fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
6661 if is_todo_status_update_args(&args) {
6662 return args;
6663 }
6664
6665 let mut obj = match args {
6666 Value::Object(map) => map,
6667 Value::Array(items) => {
6668 return json!({ "todos": normalize_todo_arg_items(items) });
6669 }
6670 Value::String(text) => {
6671 let derived = extract_todo_candidates_from_text(&text);
6672 if !derived.is_empty() {
6673 return json!({ "todos": derived });
6674 }
6675 return json!({});
6676 }
6677 _ => return json!({}),
6678 };
6679
6680 if obj
6681 .get("todos")
6682 .and_then(|v| v.as_array())
6683 .map(|arr| !arr.is_empty())
6684 .unwrap_or(false)
6685 {
6686 return Value::Object(obj);
6687 }
6688
6689 for alias in ["tasks", "items", "list", "checklist"] {
6690 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
6691 let normalized = normalize_todo_arg_items(items.clone());
6692 if !normalized.is_empty() {
6693 obj.insert("todos".to_string(), Value::Array(normalized));
6694 return Value::Object(obj);
6695 }
6696 }
6697 }
6698
6699 let derived = extract_todo_candidates_from_text(completion);
6700 if !derived.is_empty() {
6701 obj.insert("todos".to_string(), Value::Array(derived));
6702 }
6703 Value::Object(obj)
6704}
6705
6706fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
6707 items
6708 .into_iter()
6709 .filter_map(|item| match item {
6710 Value::String(text) => {
6711 let content = text.trim();
6712 if content.is_empty() {
6713 None
6714 } else {
6715 Some(json!({"content": content}))
6716 }
6717 }
6718 Value::Object(mut obj) => {
6719 if !obj.contains_key("content") {
6720 if let Some(text) = obj.get("text").cloned() {
6721 obj.insert("content".to_string(), text);
6722 } else if let Some(title) = obj.get("title").cloned() {
6723 obj.insert("content".to_string(), title);
6724 } else if let Some(name) = obj.get("name").cloned() {
6725 obj.insert("content".to_string(), name);
6726 }
6727 }
6728 let content = obj
6729 .get("content")
6730 .and_then(|v| v.as_str())
6731 .map(str::trim)
6732 .unwrap_or("");
6733 if content.is_empty() {
6734 None
6735 } else {
6736 Some(Value::Object(obj))
6737 }
6738 }
6739 _ => None,
6740 })
6741 .collect()
6742}
6743
6744fn is_todo_status_update_args(args: &Value) -> bool {
6745 let Some(obj) = args.as_object() else {
6746 return false;
6747 };
6748 let has_status = obj
6749 .get("status")
6750 .and_then(|v| v.as_str())
6751 .map(|s| !s.trim().is_empty())
6752 .unwrap_or(false);
6753 let has_target =
6754 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
6755 has_status && has_target
6756}
6757
6758fn is_empty_todo_write_args(args: &Value) -> bool {
6759 if is_todo_status_update_args(args) {
6760 return false;
6761 }
6762 let Some(obj) = args.as_object() else {
6763 return true;
6764 };
6765 !obj.get("todos")
6766 .and_then(|v| v.as_array())
6767 .map(|arr| !arr.is_empty())
6768 .unwrap_or(false)
6769}
6770
6771fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
6772 let trimmed = raw_args.trim();
6773 if trimmed.is_empty() {
6774 return json!({});
6775 }
6776
6777 let normalized_tool = normalize_tool_name(tool_name);
6778 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
6779 return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
6780 }
6781
6782 if normalized_tool == "write" {
6783 if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
6784 return recovered;
6785 }
6786 }
6787
6788 let kv_args = parse_function_style_args(trimmed);
6791 if !kv_args.is_empty() {
6792 return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
6793 }
6794
6795 if normalized_tool == "websearch" {
6796 if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
6797 return json!({ "query": query });
6798 }
6799 return json!({});
6800 }
6801
6802 Value::String(trimmed.to_string())
6803}
6804
6805fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
6806 let normalized_tool = normalize_tool_name(tool_name);
6807 if normalized_tool != "websearch" {
6808 return parsed;
6809 }
6810
6811 match parsed {
6812 Value::Object(mut obj) => {
6813 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
6814 if let Some(query) = sanitize_websearch_query_candidate(raw) {
6815 obj.insert("query".to_string(), Value::String(query));
6816 }
6817 }
6818 Value::Object(obj)
6819 }
6820 Value::String(s) => match sanitize_websearch_query_candidate(&s) {
6821 Some(query) => json!({ "query": query }),
6822 None => json!({}),
6823 },
6824 other => other,
6825 }
6826}
6827
6828fn has_websearch_query(obj: &Map<String, Value>) -> bool {
6829 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6830 QUERY_KEYS.iter().any(|key| {
6831 obj.get(*key)
6832 .and_then(|v| v.as_str())
6833 .map(|s| !s.trim().is_empty())
6834 .unwrap_or(false)
6835 })
6836}
6837
6838fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
6839 if let Some(obj) = value.as_object() {
6840 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
6841 return Some((
6842 normalize_tool_name(tool),
6843 obj.get("args").cloned().unwrap_or_else(|| json!({})),
6844 ));
6845 }
6846
6847 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
6848 let args = obj
6849 .get("args")
6850 .cloned()
6851 .or_else(|| obj.get("arguments").cloned())
6852 .unwrap_or_else(|| json!({}));
6853 let normalized_tool = normalize_tool_name(tool);
6854 let args = if let Some(raw) = args.as_str() {
6855 parse_streamed_tool_args(&normalized_tool, raw)
6856 } else {
6857 args
6858 };
6859 return Some((normalized_tool, args));
6860 }
6861
6862 for key in [
6863 "tool_call",
6864 "toolCall",
6865 "call",
6866 "function_call",
6867 "functionCall",
6868 ] {
6869 if let Some(nested) = obj.get(key) {
6870 if let Some(found) = extract_tool_call_from_value(nested) {
6871 return Some(found);
6872 }
6873 }
6874 }
6875
6876 if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
6877 for call in calls {
6878 if let Some(found) = extract_tool_call_from_value(call) {
6879 return Some(found);
6880 }
6881 }
6882 }
6883 }
6884
6885 if let Some(items) = value.as_array() {
6886 for item in items {
6887 if let Some(found) = extract_tool_call_from_value(item) {
6888 return Some(found);
6889 }
6890 }
6891 }
6892
6893 None
6894}
6895
6896fn extract_first_json_object(input: &str) -> Option<String> {
6897 let mut start = None;
6898 let mut depth = 0usize;
6899 for (idx, ch) in input.char_indices() {
6900 if ch == '{' {
6901 if start.is_none() {
6902 start = Some(idx);
6903 }
6904 depth += 1;
6905 } else if ch == '}' {
6906 if depth == 0 {
6907 continue;
6908 }
6909 depth -= 1;
6910 if depth == 0 {
6911 let begin = start?;
6912 let block = input.get(begin..=idx)?;
6913 return Some(block.to_string());
6914 }
6915 }
6916 }
6917 None
6918}
6919
6920fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
6921 let mut seen = HashSet::<String>::new();
6922 let mut todos = Vec::new();
6923
6924 for raw_line in input.lines() {
6925 let mut line = raw_line.trim();
6926 let mut structured_line = false;
6927 if line.is_empty() {
6928 continue;
6929 }
6930 if line.starts_with("```") {
6931 continue;
6932 }
6933 if line.ends_with(':') {
6934 continue;
6935 }
6936 if let Some(rest) = line
6937 .strip_prefix("- [ ]")
6938 .or_else(|| line.strip_prefix("* [ ]"))
6939 .or_else(|| line.strip_prefix("- [x]"))
6940 .or_else(|| line.strip_prefix("* [x]"))
6941 {
6942 line = rest.trim();
6943 structured_line = true;
6944 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
6945 line = rest.trim();
6946 structured_line = true;
6947 } else {
6948 let bytes = line.as_bytes();
6949 let mut i = 0usize;
6950 while i < bytes.len() && bytes[i].is_ascii_digit() {
6951 i += 1;
6952 }
6953 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
6954 line = line[i + 1..].trim();
6955 structured_line = true;
6956 }
6957 }
6958 if !structured_line {
6959 continue;
6960 }
6961
6962 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
6963 if content.len() < 5 || content.len() > 180 {
6964 continue;
6965 }
6966 let key = content.to_lowercase();
6967 if seen.contains(&key) {
6968 continue;
6969 }
6970 seen.insert(key);
6971 todos.push(json!({ "content": content }));
6972 if todos.len() >= 25 {
6973 break;
6974 }
6975 }
6976
6977 todos
6978}
6979
6980async fn emit_plan_todo_fallback(
6981 storage: std::sync::Arc<Storage>,
6982 bus: &EventBus,
6983 session_id: &str,
6984 message_id: &str,
6985 completion: &str,
6986) {
6987 let todos = extract_todo_candidates_from_text(completion);
6988 if todos.is_empty() {
6989 return;
6990 }
6991
6992 let invoke_part = WireMessagePart::tool_invocation(
6993 session_id,
6994 message_id,
6995 "todo_write",
6996 json!({"todos": todos.clone()}),
6997 );
6998 let call_id = invoke_part.id.clone();
6999 bus.publish(EngineEvent::new(
7000 "message.part.updated",
7001 json!({"part": invoke_part}),
7002 ));
7003
7004 if storage.set_todos(session_id, todos.clone()).await.is_err() {
7005 let mut failed_part = WireMessagePart::tool_result(
7006 session_id,
7007 message_id,
7008 "todo_write",
7009 Some(json!({"todos": todos.clone()})),
7010 json!(null),
7011 );
7012 failed_part.id = call_id;
7013 failed_part.state = Some("failed".to_string());
7014 failed_part.error = Some("failed to persist plan todos".to_string());
7015 bus.publish(EngineEvent::new(
7016 "message.part.updated",
7017 json!({"part": failed_part}),
7018 ));
7019 return;
7020 }
7021
7022 let normalized = storage.get_todos(session_id).await;
7023 let mut result_part = WireMessagePart::tool_result(
7024 session_id,
7025 message_id,
7026 "todo_write",
7027 Some(json!({"todos": todos.clone()})),
7028 json!({ "todos": normalized }),
7029 );
7030 result_part.id = call_id;
7031 bus.publish(EngineEvent::new(
7032 "message.part.updated",
7033 json!({"part": result_part}),
7034 ));
7035 bus.publish(EngineEvent::new(
7036 "todo.updated",
7037 json!({
7038 "sessionID": session_id,
7039 "todos": normalized
7040 }),
7041 ));
7042}
7043
7044async fn emit_plan_question_fallback(
7045 storage: std::sync::Arc<Storage>,
7046 bus: &EventBus,
7047 session_id: &str,
7048 message_id: &str,
7049 completion: &str,
7050) {
7051 let trimmed = completion.trim();
7052 if trimmed.is_empty() {
7053 return;
7054 }
7055
7056 let hints = extract_todo_candidates_from_text(trimmed)
7057 .into_iter()
7058 .take(6)
7059 .filter_map(|v| {
7060 v.get("content")
7061 .and_then(|c| c.as_str())
7062 .map(ToString::to_string)
7063 })
7064 .collect::<Vec<_>>();
7065
7066 let mut options = hints
7067 .iter()
7068 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
7069 .collect::<Vec<_>>();
7070 if options.is_empty() {
7071 options = vec![
7072 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
7073 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
7074 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
7075 ];
7076 }
7077
7078 let question_payload = vec![json!({
7079 "header":"Planning Input",
7080 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
7081 "options": options,
7082 "multiple": true,
7083 "custom": true
7084 })];
7085
7086 let request = storage
7087 .add_question_request(session_id, message_id, question_payload.clone())
7088 .await
7089 .ok();
7090 bus.publish(EngineEvent::new(
7091 "question.asked",
7092 json!({
7093 "id": request
7094 .as_ref()
7095 .map(|req| req.id.clone())
7096 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7097 "sessionID": session_id,
7098 "messageID": message_id,
7099 "questions": question_payload,
7100 "tool": request.and_then(|req| {
7101 req.tool.map(|tool| {
7102 json!({
7103 "callID": tool.call_id,
7104 "messageID": tool.message_id
7105 })
7106 })
7107 })
7108 }),
7109 ));
7110}
7111
7112#[derive(Debug, Clone, Copy)]
7113enum ChatHistoryProfile {
7114 Full,
7115 Standard,
7116 Compact,
7117}
7118
7119async fn load_chat_history(
7120 storage: std::sync::Arc<Storage>,
7121 session_id: &str,
7122 profile: ChatHistoryProfile,
7123) -> Vec<ChatMessage> {
7124 let Some(session) = storage.get_session(session_id).await else {
7125 return Vec::new();
7126 };
7127 let messages = session
7128 .messages
7129 .into_iter()
7130 .map(|m| {
7131 let role = format!("{:?}", m.role).to_lowercase();
7132 let content = m
7133 .parts
7134 .into_iter()
7135 .map(|part| match part {
7136 MessagePart::Text { text } => text,
7137 MessagePart::Reasoning { text } => text,
7138 MessagePart::ToolInvocation {
7139 tool,
7140 args,
7141 result,
7142 error,
7143 } => summarize_tool_invocation_for_history(
7144 &tool,
7145 &args,
7146 result.as_ref(),
7147 error.as_deref(),
7148 ),
7149 })
7150 .collect::<Vec<_>>()
7151 .join("\n");
7152 ChatMessage {
7153 role,
7154 content,
7155 attachments: Vec::new(),
7156 }
7157 })
7158 .collect::<Vec<_>>();
7159 compact_chat_history(messages, profile)
7160}
7161
7162fn summarize_tool_invocation_for_history(
7163 tool: &str,
7164 args: &Value,
7165 result: Option<&Value>,
7166 error: Option<&str>,
7167) -> String {
7168 let mut segments = vec![format!("Tool {tool}")];
7169 if !args.is_null()
7170 && !args.as_object().is_some_and(|value| value.is_empty())
7171 && !args
7172 .as_str()
7173 .map(|value| value.trim().is_empty())
7174 .unwrap_or(false)
7175 {
7176 segments.push(format!("args={args}"));
7177 }
7178 if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
7179 segments.push(format!("error={error}"));
7180 }
7181 if let Some(result) = result.filter(|value| !value.is_null()) {
7182 segments.push(format!("result={result}"));
7183 }
7184 if segments.len() == 1 {
7185 segments.push("result={}".to_string());
7186 }
7187 segments.join(" ")
7188}
7189
7190fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
7191 if attachments.is_empty() {
7192 return;
7193 }
7194 if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
7195 message.attachments = attachments.to_vec();
7196 }
7197}
7198
7199async fn build_runtime_attachments(
7200 provider_id: &str,
7201 parts: &[MessagePartInput],
7202) -> Vec<ChatAttachment> {
7203 if !supports_image_attachments(provider_id) {
7204 return Vec::new();
7205 }
7206
7207 let mut attachments = Vec::new();
7208 for part in parts {
7209 let MessagePartInput::File { mime, url, .. } = part else {
7210 continue;
7211 };
7212 if !mime.to_ascii_lowercase().starts_with("image/") {
7213 continue;
7214 }
7215 if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
7216 attachments.push(ChatAttachment::ImageUrl { url: source_url });
7217 }
7218 }
7219
7220 attachments
7221}
7222
7223fn supports_image_attachments(provider_id: &str) -> bool {
7224 matches!(
7225 provider_id,
7226 "openai"
7227 | "openrouter"
7228 | "ollama"
7229 | "groq"
7230 | "mistral"
7231 | "together"
7232 | "azure"
7233 | "bedrock"
7234 | "vertex"
7235 | "copilot"
7236 )
7237}
7238
7239async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
7240 let trimmed = url.trim();
7241 if trimmed.is_empty() {
7242 return None;
7243 }
7244 if trimmed.starts_with("http://")
7245 || trimmed.starts_with("https://")
7246 || trimmed.starts_with("data:")
7247 {
7248 return Some(trimmed.to_string());
7249 }
7250
7251 let file_path = trimmed
7252 .strip_prefix("file://")
7253 .map(PathBuf::from)
7254 .unwrap_or_else(|| PathBuf::from(trimmed));
7255 if !file_path.exists() {
7256 return None;
7257 }
7258
7259 let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
7260 .ok()
7261 .and_then(|v| v.parse::<usize>().ok())
7262 .unwrap_or(20 * 1024 * 1024);
7263
7264 let bytes = match tokio::fs::read(&file_path).await {
7265 Ok(bytes) => bytes,
7266 Err(err) => {
7267 tracing::warn!(
7268 "failed reading local attachment '{}': {}",
7269 file_path.to_string_lossy(),
7270 err
7271 );
7272 return None;
7273 }
7274 };
7275 if bytes.len() > max_bytes {
7276 tracing::warn!(
7277 "local attachment '{}' exceeds max bytes ({} > {})",
7278 file_path.to_string_lossy(),
7279 bytes.len(),
7280 max_bytes
7281 );
7282 return None;
7283 }
7284
7285 use base64::Engine as _;
7286 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
7287 Some(format!("data:{mime};base64,{b64}"))
7288}
7289
7290struct ToolSideEventContext<'a> {
7291 session_id: &'a str,
7292 message_id: &'a str,
7293 tool: &'a str,
7294 args: &'a serde_json::Value,
7295 metadata: &'a serde_json::Value,
7296 workspace_root: Option<&'a str>,
7297 effective_cwd: Option<&'a str>,
7298}
7299
7300async fn emit_tool_side_events(
7301 storage: std::sync::Arc<Storage>,
7302 bus: &EventBus,
7303 ctx: ToolSideEventContext<'_>,
7304) {
7305 let ToolSideEventContext {
7306 session_id,
7307 message_id,
7308 tool,
7309 args,
7310 metadata,
7311 workspace_root,
7312 effective_cwd,
7313 } = ctx;
7314 if tool == "todo_write" {
7315 let todos_from_metadata = metadata
7316 .get("todos")
7317 .and_then(|v| v.as_array())
7318 .cloned()
7319 .unwrap_or_default();
7320
7321 if !todos_from_metadata.is_empty() {
7322 let _ = storage.set_todos(session_id, todos_from_metadata).await;
7323 } else {
7324 let current = storage.get_todos(session_id).await;
7325 if let Some(updated) = apply_todo_updates_from_args(current, args) {
7326 let _ = storage.set_todos(session_id, updated).await;
7327 }
7328 }
7329
7330 let normalized = storage.get_todos(session_id).await;
7331 bus.publish(EngineEvent::new(
7332 "todo.updated",
7333 json!({
7334 "sessionID": session_id,
7335 "todos": normalized,
7336 "workspaceRoot": workspace_root,
7337 "effectiveCwd": effective_cwd
7338 }),
7339 ));
7340 }
7341 if tool == "question" {
7342 let questions = metadata
7343 .get("questions")
7344 .and_then(|v| v.as_array())
7345 .cloned()
7346 .unwrap_or_default();
7347 if questions.is_empty() {
7348 tracing::warn!(
7349 "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
7350 session_id,
7351 message_id
7352 );
7353 } else {
7354 let request = storage
7355 .add_question_request(session_id, message_id, questions.clone())
7356 .await
7357 .ok();
7358 bus.publish(EngineEvent::new(
7359 "question.asked",
7360 json!({
7361 "id": request
7362 .as_ref()
7363 .map(|req| req.id.clone())
7364 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7365 "sessionID": session_id,
7366 "messageID": message_id,
7367 "questions": questions,
7368 "tool": request.and_then(|req| {
7369 req.tool.map(|tool| {
7370 json!({
7371 "callID": tool.call_id,
7372 "messageID": tool.message_id
7373 })
7374 })
7375 }),
7376 "workspaceRoot": workspace_root,
7377 "effectiveCwd": effective_cwd
7378 }),
7379 ));
7380 }
7381 }
7382 if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
7383 for event in events {
7384 let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
7385 continue;
7386 };
7387 if !event_type.starts_with("agent_team.") {
7388 continue;
7389 }
7390 let mut properties = event
7391 .get("properties")
7392 .and_then(|v| v.as_object())
7393 .cloned()
7394 .unwrap_or_default();
7395 properties
7396 .entry("sessionID".to_string())
7397 .or_insert(json!(session_id));
7398 properties
7399 .entry("messageID".to_string())
7400 .or_insert(json!(message_id));
7401 properties
7402 .entry("workspaceRoot".to_string())
7403 .or_insert(json!(workspace_root));
7404 properties
7405 .entry("effectiveCwd".to_string())
7406 .or_insert(json!(effective_cwd));
7407 bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
7408 }
7409 }
7410}
7411
7412fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
7413 let obj = args.as_object()?;
7414 let mut todos = current;
7415 let mut changed = false;
7416
7417 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
7418 for item in items {
7419 let Some(item_obj) = item.as_object() else {
7420 continue;
7421 };
7422 let status = item_obj
7423 .get("status")
7424 .and_then(|v| v.as_str())
7425 .map(normalize_todo_status);
7426 let target = item_obj
7427 .get("task_id")
7428 .or_else(|| item_obj.get("todo_id"))
7429 .or_else(|| item_obj.get("id"));
7430
7431 if let (Some(status), Some(target)) = (status, target) {
7432 changed |= apply_single_todo_status_update(&mut todos, target, &status);
7433 }
7434 }
7435 }
7436
7437 let status = obj
7438 .get("status")
7439 .and_then(|v| v.as_str())
7440 .map(normalize_todo_status);
7441 let target = obj
7442 .get("task_id")
7443 .or_else(|| obj.get("todo_id"))
7444 .or_else(|| obj.get("id"));
7445 if let (Some(status), Some(target)) = (status, target) {
7446 changed |= apply_single_todo_status_update(&mut todos, target, &status);
7447 }
7448
7449 if changed {
7450 Some(todos)
7451 } else {
7452 None
7453 }
7454}
7455
7456fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
7457 let idx_from_value = match target {
7458 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
7459 Value::String(s) => {
7460 let trimmed = s.trim();
7461 trimmed
7462 .parse::<usize>()
7463 .ok()
7464 .map(|v| v.saturating_sub(1))
7465 .or_else(|| {
7466 let digits = trimmed
7467 .chars()
7468 .rev()
7469 .take_while(|c| c.is_ascii_digit())
7470 .collect::<String>()
7471 .chars()
7472 .rev()
7473 .collect::<String>();
7474 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
7475 })
7476 }
7477 _ => None,
7478 };
7479
7480 if let Some(idx) = idx_from_value {
7481 if idx < todos.len() {
7482 if let Some(obj) = todos[idx].as_object_mut() {
7483 obj.insert("status".to_string(), Value::String(status.to_string()));
7484 return true;
7485 }
7486 }
7487 }
7488
7489 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
7490 if let Some(id_target) = id_target {
7491 for todo in todos.iter_mut() {
7492 if let Some(obj) = todo.as_object_mut() {
7493 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
7494 obj.insert("status".to_string(), Value::String(status.to_string()));
7495 return true;
7496 }
7497 }
7498 }
7499 }
7500
7501 false
7502}
7503
7504fn normalize_todo_status(raw: &str) -> String {
7505 match raw.trim().to_lowercase().as_str() {
7506 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
7507 "done" | "complete" | "completed" => "completed".to_string(),
7508 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
7509 "open" | "todo" | "pending" => "pending".to_string(),
7510 other => other.to_string(),
7511 }
7512}
7513
7514fn compact_chat_history(
7515 messages: Vec<ChatMessage>,
7516 profile: ChatHistoryProfile,
7517) -> Vec<ChatMessage> {
7518 let (max_context_chars, keep_recent_messages) = match profile {
7519 ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
7520 ChatHistoryProfile::Standard => (80_000usize, 40usize),
7521 ChatHistoryProfile::Compact => (12_000usize, 12usize),
7522 };
7523
7524 if messages.len() <= keep_recent_messages {
7525 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
7526 if total_chars <= max_context_chars {
7527 return messages;
7528 }
7529 }
7530
7531 let mut kept = messages;
7532 let mut dropped_count = 0usize;
7533 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
7534
7535 while kept.len() > keep_recent_messages || total_chars > max_context_chars {
7536 if kept.is_empty() {
7537 break;
7538 }
7539 let removed = kept.remove(0);
7540 total_chars = total_chars.saturating_sub(removed.content.len());
7541 dropped_count += 1;
7542 }
7543
7544 if dropped_count > 0 {
7545 kept.insert(
7546 0,
7547 ChatMessage {
7548 role: "system".to_string(),
7549 content: format!(
7550 "[history compacted: omitted {} older messages to fit context window]",
7551 dropped_count
7552 ),
7553 attachments: Vec::new(),
7554 },
7555 );
7556 }
7557 kept
7558}
7559
7560#[cfg(test)]
7561mod tests {
7562 use super::*;
7563 use crate::{EventBus, Storage};
7564 use std::sync::{Mutex, OnceLock};
7565 use tandem_types::Session;
7566 use uuid::Uuid;
7567
7568 fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
7569 static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
7570 ENV_TEST_LOCK
7571 .get_or_init(|| Mutex::new(()))
7572 .lock()
7573 .expect("env test lock")
7574 }
7575
7576 #[tokio::test]
7577 async fn todo_updated_event_is_normalized() {
7578 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
7579 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
7580 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
7581 let session_id = session.id.clone();
7582 storage.save_session(session).await.expect("save session");
7583
7584 let bus = EventBus::new();
7585 let mut rx = bus.subscribe();
7586 emit_tool_side_events(
7587 storage.clone(),
7588 &bus,
7589 ToolSideEventContext {
7590 session_id: &session_id,
7591 message_id: "m1",
7592 tool: "todo_write",
7593 args: &json!({"todos":[{"content":"ship parity"}]}),
7594 metadata: &json!({"todos":[{"content":"ship parity"}]}),
7595 workspace_root: Some("."),
7596 effective_cwd: Some("."),
7597 },
7598 )
7599 .await;
7600
7601 let event = rx.recv().await.expect("event");
7602 assert_eq!(event.event_type, "todo.updated");
7603 let todos = event
7604 .properties
7605 .get("todos")
7606 .and_then(|v| v.as_array())
7607 .cloned()
7608 .unwrap_or_default();
7609 assert_eq!(todos.len(), 1);
7610 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
7611 assert_eq!(
7612 todos[0].get("content").and_then(|v| v.as_str()),
7613 Some("ship parity")
7614 );
7615 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
7616 }
7617
7618 #[tokio::test]
7619 async fn question_asked_event_contains_tool_reference() {
7620 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
7621 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
7622 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
7623 let session_id = session.id.clone();
7624 storage.save_session(session).await.expect("save session");
7625
7626 let bus = EventBus::new();
7627 let mut rx = bus.subscribe();
7628 emit_tool_side_events(
7629 storage,
7630 &bus,
7631 ToolSideEventContext {
7632 session_id: &session_id,
7633 message_id: "msg-1",
7634 tool: "question",
7635 args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
7636 metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
7637 workspace_root: Some("."),
7638 effective_cwd: Some("."),
7639 },
7640 )
7641 .await;
7642
7643 let event = rx.recv().await.expect("event");
7644 assert_eq!(event.event_type, "question.asked");
7645 assert_eq!(
7646 event
7647 .properties
7648 .get("sessionID")
7649 .and_then(|v| v.as_str())
7650 .unwrap_or(""),
7651 session_id
7652 );
7653 let tool = event
7654 .properties
7655 .get("tool")
7656 .cloned()
7657 .unwrap_or_else(|| json!({}));
7658 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
7659 assert_eq!(
7660 tool.get("messageID").and_then(|v| v.as_str()),
7661 Some("msg-1")
7662 );
7663 }
7664
7665 #[test]
7666 fn compact_chat_history_keeps_recent_and_inserts_summary() {
7667 let mut messages = Vec::new();
7668 for i in 0..60 {
7669 messages.push(ChatMessage {
7670 role: "user".to_string(),
7671 content: format!("message-{i}"),
7672 attachments: Vec::new(),
7673 });
7674 }
7675 let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
7676 assert!(compacted.len() <= 41);
7677 assert_eq!(compacted[0].role, "system");
7678 assert!(compacted[0].content.contains("history compacted"));
7679 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
7680 }
7681
7682 #[tokio::test]
7683 async fn load_chat_history_preserves_tool_args_and_error_context() {
7684 let base = std::env::temp_dir().join(format!(
7685 "tandem-core-load-chat-history-error-{}",
7686 uuid::Uuid::new_v4()
7687 ));
7688 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
7689 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
7690 let session_id = session.id.clone();
7691 storage.save_session(session).await.expect("save session");
7692
7693 let message = Message::new(
7694 MessageRole::User,
7695 vec![
7696 MessagePart::Text {
7697 text: "build the page".to_string(),
7698 },
7699 MessagePart::ToolInvocation {
7700 tool: "write".to_string(),
7701 args: json!({"path":"game.html","content":"<html>draft</html>"}),
7702 result: None,
7703 error: Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
7704 },
7705 ],
7706 );
7707 storage
7708 .append_message(&session_id, message)
7709 .await
7710 .expect("append message");
7711
7712 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
7713 let content = history
7714 .iter()
7715 .find(|message| message.role == "user")
7716 .map(|message| message.content.clone())
7717 .unwrap_or_default();
7718 assert!(content.contains("build the page"));
7719 assert!(content.contains("Tool write"));
7720 assert!(content.contains(r#"args={"content":"<html>draft</html>","path":"game.html"}"#));
7721 assert!(content.contains("error=WRITE_ARGS_EMPTY_FROM_PROVIDER"));
7722 }
7723
7724 #[tokio::test]
7725 async fn load_chat_history_preserves_tool_args_and_result_context() {
7726 let base = std::env::temp_dir().join(format!(
7727 "tandem-core-load-chat-history-result-{}",
7728 uuid::Uuid::new_v4()
7729 ));
7730 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
7731 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
7732 let session_id = session.id.clone();
7733 storage.save_session(session).await.expect("save session");
7734
7735 let message = Message::new(
7736 MessageRole::Assistant,
7737 vec![MessagePart::ToolInvocation {
7738 tool: "glob".to_string(),
7739 args: json!({"pattern":"src/**/*.rs"}),
7740 result: Some(json!({"output":"src/lib.rs\nsrc/main.rs"})),
7741 error: None,
7742 }],
7743 );
7744 storage
7745 .append_message(&session_id, message)
7746 .await
7747 .expect("append message");
7748
7749 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
7750 let content = history
7751 .iter()
7752 .find(|message| message.role == "assistant")
7753 .map(|message| message.content.clone())
7754 .unwrap_or_default();
7755 assert!(content.contains("Tool glob"));
7756 assert!(content.contains(r#"args={"pattern":"src/**/*.rs"}"#));
7757 assert!(content.contains(r#"result={"output":"src/lib.rs\nsrc/main.rs"}"#));
7758 }
7759
7760 #[test]
7761 fn extracts_todos_from_checklist_and_numbered_lines() {
7762 let input = r#"
7763Plan:
7764- [ ] Audit current implementation
7765- [ ] Add planner fallback
77661. Add regression test coverage
7767"#;
7768 let todos = extract_todo_candidates_from_text(input);
7769 assert_eq!(todos.len(), 3);
7770 assert_eq!(
7771 todos[0].get("content").and_then(|v| v.as_str()),
7772 Some("Audit current implementation")
7773 );
7774 }
7775
7776 #[test]
7777 fn does_not_extract_todos_from_plain_prose_lines() {
7778 let input = r#"
7779I need more information to proceed.
7780Can you tell me the event size and budget?
7781Once I have that, I can provide a detailed plan.
7782"#;
7783 let todos = extract_todo_candidates_from_text(input);
7784 assert!(todos.is_empty());
7785 }
7786
7787 #[test]
7788 fn parses_wrapped_tool_call_from_markdown_response() {
7789 let input = r#"
7790Here is the tool call:
7791```json
7792{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
7793```
7794"#;
7795 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
7796 assert_eq!(parsed.0, "todo_write");
7797 assert!(parsed.1.get("todos").is_some());
7798 }
7799
7800 #[test]
7801 fn parses_top_level_name_args_tool_call() {
7802 let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
7803 let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
7804 assert_eq!(parsed.0, "bash");
7805 assert_eq!(
7806 parsed.1.get("command").and_then(|v| v.as_str()),
7807 Some("echo hi")
7808 );
7809 }
7810
7811 #[test]
7812 fn parses_function_style_todowrite_call() {
7813 let input = r#"Status: Completed
7814Call: todowrite(task_id=2, status="completed")"#;
7815 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
7816 assert_eq!(parsed.0, "todo_write");
7817 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
7818 assert_eq!(
7819 parsed.1.get("status").and_then(|v| v.as_str()),
7820 Some("completed")
7821 );
7822 }
7823
7824 #[test]
7825 fn parses_multiple_function_style_todowrite_calls() {
7826 let input = r#"
7827Call: todowrite(task_id=2, status="completed")
7828Call: todowrite(task_id=3, status="in_progress")
7829"#;
7830 let parsed = parse_tool_invocations_from_response(input);
7831 assert_eq!(parsed.len(), 2);
7832 assert_eq!(parsed[0].0, "todo_write");
7833 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
7834 assert_eq!(
7835 parsed[0].1.get("status").and_then(|v| v.as_str()),
7836 Some("completed")
7837 );
7838 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
7839 assert_eq!(
7840 parsed[1].1.get("status").and_then(|v| v.as_str()),
7841 Some("in_progress")
7842 );
7843 }
7844
7845 #[test]
7846 fn applies_todo_status_update_from_task_id_args() {
7847 let current = vec![
7848 json!({"id":"todo-1","content":"a","status":"pending"}),
7849 json!({"id":"todo-2","content":"b","status":"pending"}),
7850 json!({"id":"todo-3","content":"c","status":"pending"}),
7851 ];
7852 let updated =
7853 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
7854 .expect("status update");
7855 assert_eq!(
7856 updated[1].get("status").and_then(|v| v.as_str()),
7857 Some("completed")
7858 );
7859 }
7860
7861 #[test]
7862 fn normalizes_todo_write_tasks_alias() {
7863 let normalized = normalize_todo_write_args(
7864 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
7865 "",
7866 );
7867 let todos = normalized
7868 .get("todos")
7869 .and_then(|v| v.as_array())
7870 .cloned()
7871 .unwrap_or_default();
7872 assert_eq!(todos.len(), 2);
7873 assert_eq!(
7874 todos[0].get("content").and_then(|v| v.as_str()),
7875 Some("Book venue")
7876 );
7877 assert_eq!(
7878 todos[1].get("content").and_then(|v| v.as_str()),
7879 Some("Send invites")
7880 );
7881 }
7882
7883 #[test]
7884 fn normalizes_todo_write_from_completion_when_args_empty() {
7885 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
7886 let normalized = normalize_todo_write_args(json!({}), completion);
7887 let todos = normalized
7888 .get("todos")
7889 .and_then(|v| v.as_array())
7890 .cloned()
7891 .unwrap_or_default();
7892 assert_eq!(todos.len(), 3);
7893 assert!(!is_empty_todo_write_args(&normalized));
7894 }
7895
7896 #[test]
7897 fn empty_todo_write_args_allows_status_updates() {
7898 let args = json!({"task_id": 2, "status":"completed"});
7899 assert!(!is_empty_todo_write_args(&args));
7900 }
7901
7902 #[test]
7903 fn streamed_websearch_args_fallback_to_query_string() {
7904 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
7905 assert_eq!(
7906 parsed.get("query").and_then(|v| v.as_str()),
7907 Some("meaning of life")
7908 );
7909 }
7910
7911 #[test]
7912 fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
7913 assert_eq!(
7914 parse_scalar_like_value("\""),
7915 Value::String("\"".to_string())
7916 );
7917 assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
7918 }
7919
7920 #[test]
7921 fn streamed_websearch_stringified_json_args_are_unwrapped() {
7922 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
7923 assert_eq!(
7924 parsed.get("query").and_then(|v| v.as_str()),
7925 Some("donkey gestation period")
7926 );
7927 }
7928
7929 #[test]
7930 fn streamed_websearch_args_strip_arg_key_value_wrappers() {
7931 let parsed = parse_streamed_tool_args(
7932 "websearch",
7933 "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
7934 );
7935 assert_eq!(
7936 parsed.get("query").and_then(|v| v.as_str()),
7937 Some("taj card what is it benefits how to apply")
7938 );
7939 }
7940
7941 #[test]
7942 fn normalize_tool_args_websearch_infers_from_user_text() {
7943 let normalized =
7944 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
7945 assert_eq!(
7946 normalized.args.get("query").and_then(|v| v.as_str()),
7947 Some("meaning of life")
7948 );
7949 assert_eq!(normalized.args_source, "inferred_from_user");
7950 assert_eq!(normalized.args_integrity, "recovered");
7951 }
7952
7953 #[test]
7954 fn normalize_tool_args_websearch_keeps_existing_query() {
7955 let normalized = normalize_tool_args(
7956 "websearch",
7957 json!({"query":"already set"}),
7958 "web search should not override",
7959 "",
7960 );
7961 assert_eq!(
7962 normalized.args.get("query").and_then(|v| v.as_str()),
7963 Some("already set")
7964 );
7965 assert_eq!(normalized.args_source, "provider_json");
7966 assert_eq!(normalized.args_integrity, "ok");
7967 }
7968
7969 #[test]
7970 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
7971 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
7972 assert!(normalized.query.is_none());
7973 assert!(normalized.missing_terminal);
7974 assert_eq!(normalized.args_source, "missing");
7975 assert_eq!(normalized.args_integrity, "empty");
7976 }
7977
7978 #[test]
7979 fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
7980 let normalized = normalize_tool_args(
7981 "webfetch",
7982 json!({}),
7983 "Please fetch `https://docs.tandem.ac/` in markdown mode",
7984 "",
7985 );
7986 assert!(!normalized.missing_terminal);
7987 assert_eq!(
7988 normalized.args.get("url").and_then(|v| v.as_str()),
7989 Some("https://docs.tandem.ac/")
7990 );
7991 assert_eq!(normalized.args_source, "inferred_from_user");
7992 assert_eq!(normalized.args_integrity, "recovered");
7993 }
7994
7995 #[test]
7996 fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
7997 let normalized = normalize_tool_args(
7998 "webfetch",
7999 json!({"args":{"uri":"https://example.com/page"}}),
8000 "",
8001 "",
8002 );
8003 assert!(!normalized.missing_terminal);
8004 assert_eq!(
8005 normalized.args.get("url").and_then(|v| v.as_str()),
8006 Some("https://example.com/page")
8007 );
8008 assert_eq!(normalized.args_source, "provider_json");
8009 }
8010
8011 #[test]
8012 fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
8013 let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
8014 assert!(normalized.missing_terminal);
8015 assert_eq!(
8016 normalized.missing_terminal_reason.as_deref(),
8017 Some("WEBFETCH_URL_MISSING")
8018 );
8019 }
8020
8021 #[test]
8022 fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
8023 let user_text =
8024 "Create a pack that checks latest headline news every day at 8 AM and emails me.";
8025 let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
8026 assert!(!normalized.missing_terminal);
8027 assert_eq!(
8028 normalized.args.get("goal").and_then(|v| v.as_str()),
8029 Some(user_text)
8030 );
8031 assert_eq!(
8032 normalized.args.get("mode").and_then(|v| v.as_str()),
8033 Some("preview")
8034 );
8035 assert_eq!(normalized.args_source, "inferred_from_user");
8036 assert_eq!(normalized.args_integrity, "recovered");
8037 }
8038
8039 #[test]
8040 fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
8041 let normalized = normalize_tool_args(
8042 "pack_builder",
8043 json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
8044 "new goal should not override",
8045 "",
8046 );
8047 assert!(!normalized.missing_terminal);
8048 assert_eq!(
8049 normalized.args.get("goal").and_then(|v| v.as_str()),
8050 Some("existing goal")
8051 );
8052 assert_eq!(
8053 normalized.args.get("mode").and_then(|v| v.as_str()),
8054 Some("apply")
8055 );
8056 assert_eq!(normalized.args_source, "provider_json");
8057 assert_eq!(normalized.args_integrity, "ok");
8058 }
8059
8060 #[test]
8061 fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
8062 let assistant_context =
8063 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8064 let normalized =
8065 normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
8066 assert!(!normalized.missing_terminal);
8067 assert_eq!(
8068 normalized.args.get("mode").and_then(|v| v.as_str()),
8069 Some("apply")
8070 );
8071 assert_eq!(
8072 normalized.args.get("plan_id").and_then(|v| v.as_str()),
8073 Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
8074 );
8075 assert_eq!(
8076 normalized
8077 .args
8078 .get("approve_pack_install")
8079 .and_then(|v| v.as_bool()),
8080 Some(true)
8081 );
8082 assert_eq!(normalized.args_source, "recovered_from_context");
8083 }
8084
8085 #[test]
8086 fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
8087 let assistant_context =
8088 "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
8089 let normalized = normalize_tool_args(
8090 "pack_builder",
8091 json!({"mode":"apply"}),
8092 "yes",
8093 assistant_context,
8094 );
8095 assert!(!normalized.missing_terminal);
8096 assert_eq!(
8097 normalized.args.get("mode").and_then(|v| v.as_str()),
8098 Some("apply")
8099 );
8100 assert_eq!(
8101 normalized.args.get("plan_id").and_then(|v| v.as_str()),
8102 Some("plan-11111111-2222-3333-4444-555555555555")
8103 );
8104 }
8105
8106 #[test]
8107 fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
8108 let assistant_context =
8109 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8110 let normalized = normalize_tool_args(
8111 "pack_builder",
8112 json!({}),
8113 "create jira sync",
8114 assistant_context,
8115 );
8116 assert!(!normalized.missing_terminal);
8117 assert_eq!(
8118 normalized.args.get("mode").and_then(|v| v.as_str()),
8119 Some("preview")
8120 );
8121 assert_eq!(
8122 normalized.args.get("goal").and_then(|v| v.as_str()),
8123 Some("create jira sync")
8124 );
8125 }
8126
8127 #[test]
8128 fn normalize_tool_args_write_requires_path() {
8129 let normalized = normalize_tool_args("write", json!({}), "", "");
8130 assert!(normalized.missing_terminal);
8131 assert_eq!(
8132 normalized.missing_terminal_reason.as_deref(),
8133 Some("FILE_PATH_MISSING")
8134 );
8135 }
8136
8137 #[test]
8138 fn persisted_failed_tool_args_prefers_normalized_when_raw_is_empty() {
8139 let args = persisted_failed_tool_args(
8140 &json!({}),
8141 &json!({"path":"game.html","content":"<html></html>"}),
8142 );
8143 assert_eq!(args["path"], "game.html");
8144 assert_eq!(args["content"], "<html></html>");
8145 }
8146
8147 #[test]
8148 fn persisted_failed_tool_args_keeps_non_empty_raw_payload() {
8149 let args = persisted_failed_tool_args(
8150 &json!("path=game.html content"),
8151 &json!({"path":"game.html"}),
8152 );
8153 assert_eq!(args, json!("path=game.html content"));
8154 }
8155
8156 #[test]
8157 fn normalize_tool_args_write_recovers_alias_path_key() {
8158 let normalized = normalize_tool_args(
8159 "write",
8160 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
8161 "",
8162 "",
8163 );
8164 assert!(!normalized.missing_terminal);
8165 assert_eq!(
8166 normalized.args.get("path").and_then(|v| v.as_str()),
8167 Some("docs/CONCEPT.md")
8168 );
8169 assert_eq!(
8170 normalized.args.get("content").and_then(|v| v.as_str()),
8171 Some("hello")
8172 );
8173 }
8174
8175 #[test]
8176 fn normalize_tool_args_write_recovers_html_output_target_path() {
8177 let normalized = normalize_tool_args_with_mode(
8178 "write",
8179 json!({"content":"<html></html>"}),
8180 "Execute task.\n\nRequired output target:\n{\n \"path\": \"game.html\",\n \"kind\": \"source\",\n \"operation\": \"create_or_update\"\n}\n",
8181 "",
8182 WritePathRecoveryMode::OutputTargetOnly,
8183 );
8184 assert!(!normalized.missing_terminal);
8185 assert_eq!(
8186 normalized.args.get("path").and_then(|v| v.as_str()),
8187 Some("game.html")
8188 );
8189 }
8190
8191 #[test]
8192 fn normalize_tool_args_read_infers_path_from_user_prompt() {
8193 let normalized = normalize_tool_args(
8194 "read",
8195 json!({}),
8196 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
8197 "",
8198 );
8199 assert!(!normalized.missing_terminal);
8200 assert_eq!(
8201 normalized.args.get("path").and_then(|v| v.as_str()),
8202 Some("FEATURE_LIST.md")
8203 );
8204 assert_eq!(normalized.args_source, "inferred_from_user");
8205 assert_eq!(normalized.args_integrity, "recovered");
8206 }
8207
8208 #[test]
8209 fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
8210 let normalized = normalize_tool_args(
8211 "read",
8212 json!({}),
8213 "generic instruction",
8214 "I will read src-tauri/src/orchestrator/engine.rs first.",
8215 );
8216 assert!(normalized.missing_terminal);
8217 assert_eq!(
8218 normalized.missing_terminal_reason.as_deref(),
8219 Some("FILE_PATH_MISSING")
8220 );
8221 }
8222
8223 #[test]
8224 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
8225 let normalized = normalize_tool_args(
8226 "write",
8227 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
8228 "",
8229 "",
8230 );
8231 assert!(!normalized.missing_terminal);
8232 assert_eq!(
8233 normalized.args.get("path").and_then(|v| v.as_str()),
8234 Some("docs/CONCEPT.md")
8235 );
8236 }
8237
8238 #[test]
8239 fn normalize_tool_args_write_recovers_content_alias() {
8240 let normalized = normalize_tool_args(
8241 "write",
8242 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
8243 "",
8244 "",
8245 );
8246 assert!(!normalized.missing_terminal);
8247 assert_eq!(
8248 normalized.args.get("content").and_then(|v| v.as_str()),
8249 Some("feature notes")
8250 );
8251 }
8252
8253 #[test]
8254 fn normalize_tool_args_write_fails_when_content_missing() {
8255 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
8256 assert!(normalized.missing_terminal);
8257 assert_eq!(
8258 normalized.missing_terminal_reason.as_deref(),
8259 Some("WRITE_CONTENT_MISSING")
8260 );
8261 }
8262
8263 #[test]
8264 fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
8265 let normalized = normalize_tool_args_with_mode(
8266 "write",
8267 json!({}),
8268 "Please implement the screen/state structure in the workspace.",
8269 "",
8270 WritePathRecoveryMode::OutputTargetOnly,
8271 );
8272 assert!(normalized.missing_terminal);
8273 assert_eq!(
8274 normalized.missing_terminal_reason.as_deref(),
8275 Some("FILE_PATH_MISSING")
8276 );
8277 }
8278
8279 #[test]
8280 fn normalize_tool_args_write_output_target_only_recovers_from_dot_slash_path() {
8281 let normalized = normalize_tool_args_with_mode(
8282 "write",
8283 json!({"path":"./","content":"{}"}),
8284 "Required Workspace Output:\n- Create or update `.tandem/runs/automation-v2-run-123/artifacts/research-sources.json` relative to the workspace root.",
8285 "",
8286 WritePathRecoveryMode::OutputTargetOnly,
8287 );
8288 assert!(!normalized.missing_terminal);
8289 assert_eq!(
8290 normalized.args.get("path").and_then(|v| v.as_str()),
8291 Some(".tandem/runs/automation-v2-run-123/artifacts/research-sources.json")
8292 );
8293 }
8294
8295 #[test]
8296 fn normalize_tool_args_write_recovers_content_from_assistant_context() {
8297 let normalized = normalize_tool_args(
8298 "write",
8299 json!({"path":"docs/FEATURES.md"}),
8300 "",
8301 "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
8302 );
8303 assert!(!normalized.missing_terminal);
8304 assert_eq!(
8305 normalized.args.get("path").and_then(|v| v.as_str()),
8306 Some("docs/FEATURES.md")
8307 );
8308 assert_eq!(
8309 normalized.args.get("content").and_then(|v| v.as_str()),
8310 Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
8311 );
8312 assert_eq!(normalized.args_source, "recovered_from_context");
8313 assert_eq!(normalized.args_integrity, "recovered");
8314 }
8315
8316 #[test]
8317 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
8318 let normalized = normalize_tool_args(
8319 "write",
8320 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
8321 "",
8322 "",
8323 );
8324 assert!(!normalized.missing_terminal);
8325 assert_eq!(
8326 normalized.args.get("path").and_then(|v| v.as_str()),
8327 Some("docs/FEATURES.md")
8328 );
8329 assert_eq!(
8330 normalized.args.get("content").and_then(|v| v.as_str()),
8331 Some("Line 1\nLine 2")
8332 );
8333 }
8334
8335 #[test]
8336 fn normalize_tool_args_write_does_not_treat_path_as_content() {
8337 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
8338 assert!(normalized.missing_terminal);
8339 assert_eq!(
8340 normalized.missing_terminal_reason.as_deref(),
8341 Some("WRITE_CONTENT_MISSING")
8342 );
8343 }
8344
8345 #[test]
8346 fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
8347 let normalized = normalize_tool_args(
8348 "mcp.composio_1.gmail_send_email",
8349 json!({
8350 "to": "user123@example.com",
8351 "subject": "Test",
8352 "body": "Hello",
8353 "attachment": {
8354 "s3key": ""
8355 }
8356 }),
8357 "",
8358 "",
8359 );
8360 assert!(normalized.args.get("attachment").is_none());
8361 assert_eq!(normalized.args_source, "sanitized_attachment");
8362 }
8363
8364 #[test]
8365 fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
8366 let normalized = normalize_tool_args(
8367 "mcp.composio_1.gmail_send_email",
8368 json!({
8369 "to": "user123@example.com",
8370 "subject": "Test",
8371 "body": "Hello",
8372 "attachment": {
8373 "s3key": "file_123"
8374 }
8375 }),
8376 "",
8377 "",
8378 );
8379 assert_eq!(
8380 normalized
8381 .args
8382 .get("attachment")
8383 .and_then(|value| value.get("s3key"))
8384 .and_then(|value| value.as_str()),
8385 Some("file_123")
8386 );
8387 }
8388
8389 #[test]
8390 fn classify_required_tool_failure_detects_empty_provider_write_args() {
8391 let reason = classify_required_tool_failure(
8392 &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
8393 true,
8394 1,
8395 false,
8396 false,
8397 );
8398 assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
8399 }
8400
8401 #[test]
8402 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
8403 let normalized = normalize_tool_args(
8404 "read",
8405 json!({}),
8406 "Please read **FEATURE_LIST.md** and summarize.",
8407 "",
8408 );
8409 assert!(!normalized.missing_terminal);
8410 assert_eq!(
8411 normalized.args.get("path").and_then(|v| v.as_str()),
8412 Some("FEATURE_LIST.md")
8413 );
8414 }
8415
8416 #[test]
8417 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
8418 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
8419 assert!(!normalized.missing_terminal);
8420 assert_eq!(
8421 normalized.args.get("command").and_then(|v| v.as_str()),
8422 Some("rg -n \"TODO\" .")
8423 );
8424 assert_eq!(normalized.args_source, "inferred_from_user");
8425 assert_eq!(normalized.args_integrity, "recovered");
8426 }
8427
8428 #[test]
8429 fn normalize_tool_args_read_rejects_root_only_path() {
8430 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
8431 assert!(normalized.missing_terminal);
8432 assert_eq!(
8433 normalized.missing_terminal_reason.as_deref(),
8434 Some("FILE_PATH_MISSING")
8435 );
8436 }
8437
8438 #[test]
8439 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
8440 let normalized =
8441 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
8442 assert!(!normalized.missing_terminal);
8443 assert_eq!(
8444 normalized.args.get("path").and_then(|v| v.as_str()),
8445 Some("CONCEPT.md")
8446 );
8447 assert_eq!(normalized.args_source, "inferred_from_user");
8448 assert_eq!(normalized.args_integrity, "recovered");
8449 }
8450
8451 #[test]
8452 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
8453 let normalized = normalize_tool_args(
8454 "read",
8455 json!({
8456 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
8457 }),
8458 "",
8459 "",
8460 );
8461 assert!(normalized.missing_terminal);
8462 assert_eq!(
8463 normalized.missing_terminal_reason.as_deref(),
8464 Some("FILE_PATH_MISSING")
8465 );
8466 }
8467
8468 #[test]
8469 fn normalize_tool_args_read_rejects_glob_pattern_path() {
8470 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
8471 assert!(normalized.missing_terminal);
8472 assert_eq!(
8473 normalized.missing_terminal_reason.as_deref(),
8474 Some("FILE_PATH_MISSING")
8475 );
8476 }
8477
8478 #[test]
8479 fn normalize_tool_args_read_rejects_placeholder_path() {
8480 let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
8481 assert!(normalized.missing_terminal);
8482 assert_eq!(
8483 normalized.missing_terminal_reason.as_deref(),
8484 Some("FILE_PATH_MISSING")
8485 );
8486 }
8487
8488 #[test]
8489 fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
8490 let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
8491 assert!(normalized.missing_terminal);
8492 assert_eq!(
8493 normalized.missing_terminal_reason.as_deref(),
8494 Some("FILE_PATH_MISSING")
8495 );
8496 }
8497
8498 #[test]
8499 fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
8500 let normalized = normalize_tool_args(
8501 "read",
8502 json!({"path":"tool/policy"}),
8503 "Read `T1011U kitöltési útmutató.pdf` and summarize.",
8504 "",
8505 );
8506 assert!(!normalized.missing_terminal);
8507 assert_eq!(
8508 normalized.args.get("path").and_then(|v| v.as_str()),
8509 Some("T1011U kitöltési útmutató.pdf")
8510 );
8511 assert_eq!(normalized.args_source, "inferred_from_user");
8512 assert_eq!(normalized.args_integrity, "recovered");
8513 }
8514
8515 #[test]
8516 fn normalize_tool_name_strips_default_api_namespace() {
8517 assert_eq!(normalize_tool_name("default_api:read"), "read");
8518 assert_eq!(normalize_tool_name("functions.shell"), "bash");
8519 }
8520
8521 #[test]
8522 fn mcp_server_from_tool_name_parses_server_segment() {
8523 assert_eq!(
8524 mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
8525 Some("arcade")
8526 );
8527 assert_eq!(mcp_server_from_tool_name("read"), None);
8528 assert_eq!(mcp_server_from_tool_name("mcp"), None);
8529 }
8530
8531 #[test]
8532 fn batch_helpers_use_name_when_tool_is_wrapper() {
8533 let args = json!({
8534 "tool_calls":[
8535 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
8536 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
8537 ]
8538 });
8539 let calls = extract_batch_calls(&args);
8540 assert_eq!(calls.len(), 2);
8541 assert_eq!(calls[0].0, "read");
8542 assert_eq!(calls[1].0, "glob");
8543 assert!(is_read_only_batch_call(&args));
8544 let sig = batch_tool_signature(&args).unwrap_or_default();
8545 assert!(sig.contains("read:"));
8546 assert!(sig.contains("glob:"));
8547 }
8548
8549 #[test]
8550 fn batch_helpers_resolve_nested_function_name() {
8551 let args = json!({
8552 "tool_calls":[
8553 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
8554 ]
8555 });
8556 let calls = extract_batch_calls(&args);
8557 assert_eq!(calls.len(), 1);
8558 assert_eq!(calls[0].0, "read");
8559 assert!(is_read_only_batch_call(&args));
8560 }
8561
8562 #[test]
8563 fn batch_output_classifier_detects_non_productive_unknown_results() {
8564 let output = r#"
8565[
8566 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
8567 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
8568]
8569"#;
8570 assert!(is_non_productive_batch_output(output));
8571 }
8572
8573 #[test]
8574 fn runtime_prompt_includes_execution_environment_block() {
8575 let prompt = tandem_runtime_system_prompt(
8576 &HostRuntimeContext {
8577 os: HostOs::Windows,
8578 arch: "x86_64".to_string(),
8579 shell_family: ShellFamily::Powershell,
8580 path_style: PathStyle::Windows,
8581 },
8582 &[],
8583 );
8584 assert!(prompt.contains("[Execution Environment]"));
8585 assert!(prompt.contains("Host OS: windows"));
8586 assert!(prompt.contains("Shell: powershell"));
8587 assert!(prompt.contains("Path style: windows"));
8588 }
8589
8590 #[test]
8591 fn runtime_prompt_includes_connected_integrations_block() {
8592 let prompt = tandem_runtime_system_prompt(
8593 &HostRuntimeContext {
8594 os: HostOs::Linux,
8595 arch: "x86_64".to_string(),
8596 shell_family: ShellFamily::Posix,
8597 path_style: PathStyle::Posix,
8598 },
8599 &["notion".to_string(), "github".to_string()],
8600 );
8601 assert!(prompt.contains("[Connected Integrations]"));
8602 assert!(prompt.contains("- notion"));
8603 assert!(prompt.contains("- github"));
8604 }
8605
8606 #[test]
8607 fn detects_web_research_prompt_keywords() {
8608 assert!(requires_web_research_prompt(
8609 "research todays top news stories and include links"
8610 ));
8611 assert!(!requires_web_research_prompt(
8612 "say hello and summarize this text"
8613 ));
8614 }
8615
8616 #[test]
8617 fn detects_email_delivery_prompt_keywords() {
8618 assert!(requires_email_delivery_prompt(
8619 "send a full report with links to user123@example.com"
8620 ));
8621 assert!(!requires_email_delivery_prompt("draft a summary for later"));
8622 }
8623
8624 #[test]
8625 fn completion_claim_detector_flags_sent_language() {
8626 assert!(completion_claims_email_sent(
8627 "Email Status: Sent to user123@example.com."
8628 ));
8629 assert!(!completion_claims_email_sent(
8630 "I could not send email in this run."
8631 ));
8632 }
8633
8634 #[test]
8635 fn email_tool_detector_finds_mcp_gmail_tools() {
8636 let schemas = vec![
8637 ToolSchema::new("read", "", json!({})),
8638 ToolSchema::new("mcp.composio.gmail_send_email", "", json!({})),
8639 ];
8640 assert!(has_email_action_tools(&schemas));
8641 }
8642
8643 #[test]
8644 fn extract_mcp_auth_required_metadata_parses_expected_shape() {
8645 let metadata = json!({
8646 "server": "arcade",
8647 "mcpAuth": {
8648 "required": true,
8649 "challengeId": "abc123",
8650 "authorizationUrl": "https://example.com/oauth",
8651 "message": "Authorize first",
8652 "pending": true,
8653 "blocked": true,
8654 "retryAfterMs": 8000
8655 }
8656 });
8657 let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
8658 assert_eq!(parsed.challenge_id, "abc123");
8659 assert_eq!(parsed.authorization_url, "https://example.com/oauth");
8660 assert_eq!(parsed.message, "Authorize first");
8661 assert_eq!(parsed.server.as_deref(), Some("arcade"));
8662 assert!(parsed.pending);
8663 assert!(parsed.blocked);
8664 assert_eq!(parsed.retry_after_ms, Some(8000));
8665 }
8666
8667 #[test]
8668 fn auth_required_output_detector_matches_auth_text() {
8669 assert!(is_auth_required_tool_output(
8670 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
8671 ));
8672 assert!(is_auth_required_tool_output(
8673 "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
8674 ));
8675 assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
8676 }
8677
8678 #[test]
8679 fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
8680 assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
8681 assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
8682 assert!(!is_productive_tool_output(
8683 "write",
8684 "Tool `write` result:\nWRITE_CONTENT_MISSING"
8685 ));
8686 assert!(!is_productive_tool_output(
8687 "edit",
8688 "Tool `edit` result:\nFILE_PATH_MISSING"
8689 ));
8690 assert!(!is_productive_tool_output(
8691 "write",
8692 "Tool `write` result:\ninvalid_function_parameters"
8693 ));
8694 }
8695
8696 #[test]
8697 fn productive_tool_output_detector_accepts_real_tool_results() {
8698 assert!(is_productive_tool_output(
8699 "write",
8700 "Tool `write` result:\nWrote /tmp/probe.html"
8701 ));
8702 assert!(!is_productive_tool_output(
8703 "write",
8704 "Authorization required for `write`.\nAuthorize here: https://example.com"
8705 ));
8706 }
8707
8708 #[test]
8709 fn guard_budget_output_detector_matches_expected_text() {
8710 assert!(is_guard_budget_tool_output(
8711 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
8712 ));
8713 assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
8714 }
8715
8716 #[test]
8717 fn summarize_guard_budget_outputs_returns_run_scoped_message() {
8718 let outputs = vec![
8719 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
8720 .to_string(),
8721 "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
8722 .to_string(),
8723 ];
8724 let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
8725 assert!(summary.contains("per-run tool guard budget"));
8726 assert!(summary.contains("fresh run"));
8727 }
8728
8729 #[test]
8730 fn duplicate_signature_output_detector_matches_expected_text() {
8731 assert!(is_duplicate_signature_limit_output(
8732 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
8733 ));
8734 assert!(!is_duplicate_signature_limit_output(
8735 "Tool `read` result: ok"
8736 ));
8737 }
8738
8739 #[test]
8740 fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
8741 let outputs = vec![
8742 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
8743 .to_string(),
8744 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
8745 .to_string(),
8746 ];
8747 let summary =
8748 summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
8749 assert!(summary.contains("same tool call kept repeating"));
8750 assert!(summary.contains("clearer command target"));
8751 }
8752
8753 #[test]
8754 fn required_tool_mode_unsatisfied_completion_includes_marker() {
8755 let message =
8756 required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
8757 assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
8758 assert!(message.contains("NO_TOOL_CALL_EMITTED"));
8759 assert!(message.contains("tool_mode=required"));
8760 }
8761
8762 #[test]
8763 fn post_tool_final_narrative_generation_is_allowed_after_required_tools_succeed() {
8764 assert!(should_generate_post_tool_final_narrative(
8765 ToolMode::Required,
8766 1
8767 ));
8768 assert!(!should_generate_post_tool_final_narrative(
8769 ToolMode::Required,
8770 0
8771 ));
8772 assert!(should_generate_post_tool_final_narrative(ToolMode::Auto, 0));
8773 }
8774
8775 #[test]
8776 fn post_tool_final_narrative_prompt_preserves_structured_response_requirements() {
8777 let prompt = build_post_tool_final_narrative_prompt(&[String::from(
8778 "Tool `glob` result:\n/home/user123/marketing-tandem/tandem-reference/SOURCES.md",
8779 )]);
8780 assert!(prompt.contains("Preserve any requested output contract"));
8781 assert!(prompt.contains("required JSON structure"));
8782 assert!(prompt.contains("required handoff fields"));
8783 assert!(prompt.contains("required final status object"));
8784 assert!(prompt.contains("Do not stop at a tool summary"));
8785 }
8786
8787 #[test]
8788 fn required_tool_retry_context_mentions_offered_tools() {
8789 let prompt = build_required_tool_retry_context(
8790 "read, write, apply_patch",
8791 RequiredToolFailureKind::ToolCallInvalidArgs,
8792 );
8793 assert!(prompt.contains("Tool access is mandatory"));
8794 assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
8795 assert!(prompt.contains("full `content`"));
8796 assert!(prompt.contains("write, edit, or apply_patch"));
8797 }
8798
8799 #[test]
8800 fn required_tool_retry_context_requires_write_after_read_only_pass() {
8801 let prompt = build_required_tool_retry_context(
8802 "glob, read, write, edit, apply_patch",
8803 RequiredToolFailureKind::WriteRequiredNotSatisfied,
8804 );
8805 assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
8806 assert!(prompt.contains("Inspection is complete"));
8807 assert!(prompt.contains("write, edit, or apply_patch"));
8808 }
8809
8810 #[test]
8811 fn classify_required_tool_failure_detects_invalid_args() {
8812 let reason = classify_required_tool_failure(
8813 &[String::from("WRITE_CONTENT_MISSING")],
8814 true,
8815 1,
8816 false,
8817 false,
8818 );
8819 assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
8820 }
8821
8822 #[test]
8823 fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
8824 assert!(looks_like_unparsed_tool_payload(
8825 r#"{"content":[{"type":"tool_call","name":"write"}]}"#
8826 ));
8827 assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
8828 }
8829
8830 #[test]
8831 fn workspace_write_tool_detection_is_limited_to_mutations() {
8832 assert!(is_workspace_write_tool("write"));
8833 assert!(is_workspace_write_tool("edit"));
8834 assert!(is_workspace_write_tool("apply_patch"));
8835 assert!(!is_workspace_write_tool("read"));
8836 assert!(!is_workspace_write_tool("glob"));
8837 }
8838
8839 #[test]
8840 fn proactive_write_gate_applies_only_before_prewrite_is_satisfied() {
8841 let decision = evaluate_prewrite_gate(
8842 true,
8843 &PrewriteRequirements {
8844 workspace_inspection_required: true,
8845 web_research_required: false,
8846 concrete_read_required: true,
8847 successful_web_research_required: false,
8848 repair_on_unmet_requirements: true,
8849 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
8850 },
8851 PrewriteProgress {
8852 productive_write_tool_calls_total: 0,
8853 productive_workspace_inspection_total: 0,
8854 productive_concrete_read_total: 0,
8855 productive_web_research_total: 0,
8856 successful_web_research_total: 0,
8857 required_write_retry_count: 0,
8858 unmet_prewrite_repair_retry_count: 0,
8859 prewrite_gate_waived: false,
8860 },
8861 );
8862 assert!(decision.gate_write);
8863 }
8864
8865 #[test]
8866 fn prewrite_repair_can_start_before_any_write_attempt() {
8867 assert!(should_start_prewrite_repair_before_first_write(
8868 true, 0, false, false
8869 ));
8870 assert!(!should_start_prewrite_repair_before_first_write(
8871 true, 0, true, false
8872 ));
8873 assert!(!should_start_prewrite_repair_before_first_write(
8874 false, 0, false, false
8875 ));
8876 assert!(should_start_prewrite_repair_before_first_write(
8877 false, 0, false, true
8878 ));
8879 }
8880
8881 #[test]
8882 fn prewrite_repair_does_not_fire_after_first_write() {
8883 assert!(!should_start_prewrite_repair_before_first_write(
8884 true, 1, false, false
8885 ));
8886 assert!(!should_start_prewrite_repair_before_first_write(
8887 true, 2, false, true
8888 ));
8889 }
8890
8891 #[test]
8892 fn infer_code_workflow_from_text_detects_code_agent_contract() {
8893 let prompt = "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test";
8894 assert!(infer_code_workflow_from_text(prompt));
8895 }
8896
8897 #[test]
8898 fn infer_code_workflow_from_text_detects_source_target_path() {
8899 let prompt = "Required Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.";
8900 assert!(infer_code_workflow_from_text(prompt));
8901 }
8902
8903 #[test]
8904 fn required_tool_retry_context_for_task_adds_code_loop_guidance() {
8905 let prompt = build_required_tool_retry_context_for_task(
8906 "read, edit, apply_patch, bash",
8907 RequiredToolFailureKind::WriteRequiredNotSatisfied,
8908 "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test\nRequired Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.",
8909 );
8910 assert!(prompt.contains("inspect -> patch -> apply -> test -> repair"));
8911 assert!(prompt.contains("apply_patch"));
8912 assert!(prompt.contains("cargo test"));
8913 assert!(prompt.contains("src/lib.rs"));
8914 }
8915
8916 #[test]
8917 fn write_tool_removed_after_first_productive_write() {
8918 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
8919 let repair_on_unmet_requirements = true;
8920 let productive_write_tool_calls_total = 1usize;
8921 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
8922 offered.retain(|tool| !is_workspace_write_tool(tool));
8923 }
8924 assert_eq!(offered, vec!["glob", "read", "websearch", "write", "edit"]);
8925 }
8926
8927 #[test]
8928 fn write_tool_removed_after_third_productive_write() {
8929 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
8930 let repair_on_unmet_requirements = true;
8931 let productive_write_tool_calls_total = 3usize;
8932 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
8933 offered.retain(|tool| !is_workspace_write_tool(tool));
8934 }
8935 assert_eq!(offered, vec!["glob", "read", "websearch"]);
8936 }
8937
8938 #[test]
8939 fn force_write_only_retry_disabled_for_prewrite_repair_nodes() {
8940 let requested_write_required = true;
8941 let required_write_retry_count = 1usize;
8942 let productive_write_tool_calls_total = 0usize;
8943 let prewrite_satisfied = true;
8944 let prewrite_gate_write = false;
8945 let repair_on_unmet_requirements = true;
8946
8947 let force_write_only_retry = requested_write_required
8948 && required_write_retry_count > 0
8949 && (productive_write_tool_calls_total == 0 || prewrite_satisfied)
8950 && !prewrite_gate_write
8951 && !repair_on_unmet_requirements;
8952
8953 assert!(!force_write_only_retry);
8954 }
8955
8956 #[test]
8957 fn infer_required_output_target_path_reads_prompt_json_block() {
8958 let prompt = r#"Execute task.
8959
8960Required output target:
8961{
8962 "path": "src/game.html",
8963 "kind": "source",
8964 "operation": "create"
8965}
8966"#;
8967 assert_eq!(
8968 infer_required_output_target_path_from_text(prompt).as_deref(),
8969 Some("src/game.html")
8970 );
8971 }
8972
8973 #[test]
8974 fn infer_required_output_target_path_accepts_extensionless_target() {
8975 let prompt = r#"Execute task.
8976
8977Required output target:
8978{
8979 "path": "Dockerfile",
8980 "kind": "source",
8981 "operation": "create"
8982}
8983"#;
8984 assert_eq!(
8985 infer_required_output_target_path_from_text(prompt).as_deref(),
8986 Some("Dockerfile")
8987 );
8988 }
8989
8990 #[test]
8991 fn infer_write_file_path_from_text_rejects_workspace_root() {
8992 let prompt = "Workspace: /home/user123/game\nCreate the scaffold in the workspace now.";
8993 assert_eq!(infer_write_file_path_from_text(prompt), None);
8994 }
8995
8996 #[test]
8997 fn duplicate_signature_limit_defaults_to_200_for_general_tools_and_1_for_email_delivery() {
8998 let _guard = env_test_lock();
8999 unsafe {
9000 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9001 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9002 }
9003 assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
9004 assert_eq!(duplicate_signature_limit_for("bash"), 200);
9005 assert_eq!(duplicate_signature_limit_for("write"), 200);
9006 assert_eq!(
9007 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9008 1
9009 );
9010 assert_eq!(
9011 duplicate_signature_limit_for("mcp.composio_1.gmail_create_email_draft"),
9012 1
9013 );
9014 }
9015
9016 #[test]
9017 fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
9018 let parsed = parse_streamed_tool_args("write", "path=game.html content");
9019 assert_ne!(parsed, json!({}));
9020 }
9021
9022 #[test]
9023 fn parse_streamed_tool_args_rejects_malformed_json_fragment_as_function_style() {
9024 let parsed = parse_streamed_tool_args("write", r#"{"allow_empty": null"#);
9025 assert_eq!(parsed, json!(r#"{"allow_empty": null"#));
9026 }
9027
9028 #[test]
9029 fn parse_streamed_tool_args_preserves_large_write_payload() {
9030 let content = "x".repeat(4096);
9031 let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
9032 let parsed = parse_streamed_tool_args("write", &raw_args);
9033 assert_eq!(
9034 parsed.get("path").and_then(|value| value.as_str()),
9035 Some("game.html")
9036 );
9037 assert_eq!(
9038 parsed.get("content").and_then(|value| value.as_str()),
9039 Some(content.as_str())
9040 );
9041 }
9042
9043 #[test]
9044 fn parse_streamed_tool_args_recovers_truncated_write_json() {
9045 let raw_args = concat!(
9046 r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9047 r#"<html lang=\"en\"><body>Neon Drift"#
9048 );
9049 let parsed = parse_streamed_tool_args("write", raw_args);
9050 assert_eq!(
9051 parsed,
9052 json!({
9053 "path": "game.html",
9054 "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
9055 })
9056 );
9057 }
9058
9059 #[test]
9060 fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
9061 let raw_args = concat!(
9062 r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9063 r#"<html lang=\"en\"><body>Neon Drift"#
9064 );
9065 let parsed = parse_streamed_tool_args("write", raw_args);
9066 assert_eq!(parsed.get("path"), None);
9067 assert_eq!(
9068 parsed.get("content").and_then(|value| value.as_str()),
9069 Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
9070 );
9071 }
9072
9073 #[test]
9074 fn duplicate_signature_limit_env_override_respects_minimum_floor() {
9075 let _guard = env_test_lock();
9076 unsafe {
9077 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
9078 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9079 }
9080 assert_eq!(duplicate_signature_limit_for("write"), 200);
9081 assert_eq!(duplicate_signature_limit_for("bash"), 200);
9082 unsafe {
9083 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
9084 }
9085 assert_eq!(duplicate_signature_limit_for("bash"), 250);
9086 unsafe {
9087 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9088 }
9089 }
9090
9091 #[test]
9092 fn email_delivery_duplicate_signature_limit_env_override_respects_floor_of_one() {
9093 let _guard = env_test_lock();
9094 unsafe {
9095 std::env::set_var(
9096 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9097 "1",
9098 );
9099 }
9100 assert_eq!(
9101 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9102 1
9103 );
9104 unsafe {
9105 std::env::set_var(
9106 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9107 "3",
9108 );
9109 }
9110 assert_eq!(
9111 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9112 3
9113 );
9114 unsafe {
9115 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9116 }
9117 }
9118
9119 #[test]
9120 fn email_delivery_detection_is_provider_agnostic() {
9121 assert!(is_email_delivery_tool_name(
9122 "mcp.composio_1.gmail_send_email"
9123 ));
9124 assert!(is_email_delivery_tool_name("mcp.sendgrid.send_email"));
9125 assert!(is_email_delivery_tool_name("mcp.resend.create_email_draft"));
9126 assert!(is_email_delivery_tool_name("mcp.outlook.reply_email"));
9127 assert!(!is_email_delivery_tool_name("mcp.reddit.send_message"));
9128 assert!(!is_email_delivery_tool_name("mcp.github.create_issue"));
9129 }
9130
9131 #[test]
9132 fn websearch_duplicate_signature_limit_is_unset_by_default() {
9133 let _guard = env_test_lock();
9134 unsafe {
9135 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9136 }
9137 assert_eq!(websearch_duplicate_signature_limit(), None);
9138 }
9139
9140 #[test]
9141 fn websearch_duplicate_signature_limit_reads_env() {
9142 let _guard = env_test_lock();
9143 unsafe {
9144 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
9145 }
9146 assert_eq!(websearch_duplicate_signature_limit(), Some(200));
9147 unsafe {
9148 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
9149 }
9150 assert_eq!(websearch_duplicate_signature_limit(), Some(300));
9151 unsafe {
9152 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9153 }
9154 }
9155
9156 #[test]
9157 fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
9158 let outputs = vec![
9159 "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
9160 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
9161 ];
9162 let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
9163 assert!(summary.contains("Authorization is required before I can continue"));
9164 assert!(summary.contains("gmail_sendemail"));
9165 assert!(summary.contains("gmail_whoami"));
9166 }
9167
9168 #[test]
9169 fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
9170 let outputs = vec![
9171 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
9172 "Tool `read` result:\nok".to_string(),
9173 ];
9174 assert!(summarize_auth_pending_outputs(&outputs).is_none());
9175 }
9176
9177 #[test]
9178 fn invalid_tool_args_retry_context_handles_missing_bash_command() {
9179 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9180 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
9181 .expect("retry expected");
9182 assert!(message.contains("required `command` field"));
9183 assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
9184 }
9185
9186 #[test]
9187 fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
9188 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9189 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
9190 .expect("retry expected");
9191 assert!(message.contains("Do not repeat an empty bash call"));
9192 }
9193
9194 #[test]
9195 fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
9196 let outputs = vec!["Tool `read` result:\nok".to_string()];
9197 assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
9198 }
9199
9200 #[test]
9201 fn prewrite_repair_retry_context_prioritizes_research_tools_before_write() {
9202 let requirements = PrewriteRequirements {
9203 workspace_inspection_required: true,
9204 web_research_required: true,
9205 concrete_read_required: true,
9206 successful_web_research_required: true,
9207 repair_on_unmet_requirements: true,
9208 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9209 };
9210 let prompt = build_prewrite_repair_retry_context(
9211 "glob, read, websearch, write",
9212 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9213 r#"Required output target:
9214{
9215 "path": "marketing-brief.md",
9216 "kind": "artifact"
9217}"#,
9218 &requirements,
9219 true,
9220 false,
9221 false,
9222 false,
9223 );
9224 assert!(prompt.contains("requires concrete `read` calls"));
9225 assert!(prompt.contains("call `websearch` with a concrete query now"));
9226 assert!(prompt.contains("Use `read` and `websearch` now to gather evidence"));
9227 assert!(prompt.contains("Do not declare the output blocked"));
9228 assert!(!prompt.contains("blocked-but-substantive artifact"));
9229 assert!(!prompt.contains("Your next response must be a `write` tool call"));
9230 assert!(!prompt.contains("Do not call `glob`, `read`, or `websearch` again"));
9231 }
9232
9233 #[test]
9234 fn empty_completion_retry_context_requires_write_when_prewrite_is_satisfied() {
9235 let requirements = PrewriteRequirements {
9236 workspace_inspection_required: true,
9237 web_research_required: false,
9238 concrete_read_required: true,
9239 successful_web_research_required: false,
9240 repair_on_unmet_requirements: true,
9241 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9242 };
9243 let prompt = build_empty_completion_retry_context(
9244 "glob, read, write",
9245 "Create or update `marketing-brief.md` relative to the workspace root.",
9246 &requirements,
9247 true,
9248 true,
9249 false,
9250 false,
9251 );
9252 assert!(prompt.contains("returned no final output"));
9253 assert!(prompt.contains("marketing-brief.md"));
9254 assert!(prompt.contains("must be a `write` tool call"));
9255 }
9256
9257 #[test]
9258 fn empty_completion_retry_context_mentions_missing_prewrite_work() {
9259 let requirements = PrewriteRequirements {
9260 workspace_inspection_required: true,
9261 web_research_required: true,
9262 concrete_read_required: true,
9263 successful_web_research_required: true,
9264 repair_on_unmet_requirements: true,
9265 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9266 };
9267 let prompt = build_empty_completion_retry_context(
9268 "glob, read, websearch, write",
9269 "Create or update `marketing-brief.md` relative to the workspace root.",
9270 &requirements,
9271 true,
9272 false,
9273 false,
9274 false,
9275 );
9276 assert!(prompt.contains("still need to use `read`"));
9277 assert!(prompt.contains("use `websearch`"));
9278 assert!(prompt.contains("After completing the missing requirement"));
9279 }
9280
9281 #[test]
9282 fn synthesize_artifact_write_completion_from_tool_state_marks_completed() {
9283 let completion = synthesize_artifact_write_completion_from_tool_state(
9284 "Create or update `marketing-brief.md` relative to the workspace root.",
9285 true,
9286 false,
9287 );
9288 assert!(completion.contains("wrote `marketing-brief.md`"));
9289 assert!(completion.contains("\"status\":\"completed\""));
9290 assert!(completion.contains("Runtime validation will verify"));
9291 }
9292
9293 #[test]
9294 fn synthesize_artifact_write_completion_from_tool_state_mentions_waived_evidence() {
9295 let completion = synthesize_artifact_write_completion_from_tool_state(
9296 "Create or update `marketing-brief.md` relative to the workspace root.",
9297 false,
9298 true,
9299 );
9300 assert!(completion.contains("waived in-run"));
9301 assert!(completion.contains("\"status\":\"completed\""));
9302 }
9303
9304 #[test]
9305 fn prewrite_repair_retry_budget_allows_five_repair_attempts() {
9306 assert_eq!(prewrite_repair_retry_max_attempts(), 5);
9307 }
9308
9309 #[test]
9310 fn prewrite_repair_tool_filter_removes_write_until_evidence_is_satisfied() {
9311 let offered = ["glob", "read", "websearch", "write", "edit"];
9312 let filtered = offered
9313 .iter()
9314 .copied()
9315 .filter(|tool| {
9316 tool_matches_unmet_prewrite_repair_requirement(
9317 tool,
9318 &[
9319 "workspace_inspection_required",
9320 "concrete_read_required",
9321 "successful_web_research_required",
9322 ],
9323 )
9324 })
9325 .collect::<Vec<_>>();
9326 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9327 }
9328
9329 #[test]
9330 fn prewrite_repair_tool_filter_restricts_to_glob_and_read_for_concrete_reads() {
9331 let offered = ["glob", "read", "search", "write"];
9332 let filtered = offered
9333 .iter()
9334 .copied()
9335 .filter(|tool| {
9336 tool_matches_unmet_prewrite_repair_requirement(tool, &["concrete_read_required"])
9337 })
9338 .collect::<Vec<_>>();
9339 assert_eq!(filtered, vec!["glob", "read"]);
9340 }
9341
9342 #[test]
9343 fn prewrite_repair_tool_filter_allows_glob_only_for_workspace_inspection() {
9344 let offered = ["glob", "read", "websearch", "write"];
9345 let with_inspection_unmet = offered
9346 .iter()
9347 .copied()
9348 .filter(|tool| {
9349 tool_matches_unmet_prewrite_repair_requirement(
9350 tool,
9351 &["workspace_inspection_required", "concrete_read_required"],
9352 )
9353 })
9354 .collect::<Vec<_>>();
9355 assert_eq!(with_inspection_unmet, vec!["glob", "read"]);
9356
9357 let without_inspection_unmet = offered
9358 .iter()
9359 .copied()
9360 .filter(|tool| {
9361 tool_matches_unmet_prewrite_repair_requirement(
9362 tool,
9363 &["concrete_read_required", "web_research_required"],
9364 )
9365 })
9366 .collect::<Vec<_>>();
9367 assert_eq!(without_inspection_unmet, vec!["glob", "read", "websearch"]);
9368 }
9369
9370 #[test]
9371 fn prewrite_repair_after_glob_restricts_to_glob_read_and_websearch() {
9372 let offered = ["glob", "read", "websearch", "write", "edit"];
9373 let filtered = offered
9374 .iter()
9375 .copied()
9376 .filter(|tool| {
9377 tool_matches_unmet_prewrite_repair_requirement(
9378 tool,
9379 &[
9380 "concrete_read_required",
9381 "successful_web_research_required",
9382 "coverage_mode",
9383 ],
9384 )
9385 })
9386 .collect::<Vec<_>>();
9387 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9388 }
9389
9390 #[test]
9391 fn prewrite_requirements_exhausted_completion_reports_structured_repair_state() {
9392 let message = prewrite_requirements_exhausted_completion(
9393 &["concrete_read_required", "successful_web_research_required"],
9394 2,
9395 0,
9396 );
9397 assert!(message.contains("PREWRITE_REQUIREMENTS_EXHAUSTED"));
9398 assert!(message.contains("\"status\":\"blocked\""));
9399 assert!(message.contains("\"repairAttempt\":2"));
9400 assert!(message.contains("\"repairAttemptsRemaining\":0"));
9401 assert!(message.contains("\"repairExhausted\":true"));
9402 assert!(message.contains("\"unmetRequirements\":[\"concrete_read_required\", \"successful_web_research_required\"]"));
9403 }
9404
9405 #[test]
9406 fn prewrite_waived_write_context_includes_unmet_codes() {
9407 let user_text = "Some task text without output target marker.";
9408 let unmet = vec!["concrete_read_required", "coverage_mode"];
9409 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9410 assert!(ctx.contains("could not be fully satisfied"));
9411 assert!(ctx.contains("concrete_read_required"));
9412 assert!(ctx.contains("coverage_mode"));
9413 assert!(ctx.contains("write"));
9414 assert!(ctx.contains("Do not write a blocked or placeholder file"));
9415 }
9416
9417 #[test]
9418 fn prewrite_waived_write_context_includes_output_path_when_present() {
9419 let user_text = "Required output target: {\"path\": \"marketing-brief.md\"}";
9420 let unmet = vec!["concrete_read_required"];
9421 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9422 assert!(ctx.contains("marketing-brief.md"));
9423 assert!(ctx.contains("`write`"));
9424 }
9425
9426 #[test]
9427 fn prewrite_gate_waived_disables_prewrite_gate_write() {
9428 let requirements = PrewriteRequirements {
9429 workspace_inspection_required: true,
9430 web_research_required: false,
9431 concrete_read_required: true,
9432 successful_web_research_required: false,
9433 repair_on_unmet_requirements: true,
9434 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9435 };
9436 let before = evaluate_prewrite_gate(
9437 true,
9438 &requirements,
9439 PrewriteProgress {
9440 productive_write_tool_calls_total: 0,
9441 productive_workspace_inspection_total: 0,
9442 productive_concrete_read_total: 0,
9443 productive_web_research_total: 0,
9444 successful_web_research_total: 0,
9445 required_write_retry_count: 0,
9446 unmet_prewrite_repair_retry_count: 0,
9447 prewrite_gate_waived: false,
9448 },
9449 );
9450 assert!(before.gate_write, "gate should be active before waiver");
9451 let after = evaluate_prewrite_gate(
9452 true,
9453 &requirements,
9454 PrewriteProgress {
9455 productive_write_tool_calls_total: 0,
9456 productive_workspace_inspection_total: 0,
9457 productive_concrete_read_total: 0,
9458 productive_web_research_total: 0,
9459 successful_web_research_total: 0,
9460 required_write_retry_count: 0,
9461 unmet_prewrite_repair_retry_count: 0,
9462 prewrite_gate_waived: true,
9463 },
9464 );
9465 assert!(!after.gate_write, "gate should be off after waiver");
9466 }
9467
9468 #[test]
9469 fn prewrite_gate_waived_disables_allow_repair_tools() {
9470 let requirements = PrewriteRequirements {
9471 workspace_inspection_required: true,
9472 web_research_required: true,
9473 concrete_read_required: true,
9474 successful_web_research_required: true,
9475 repair_on_unmet_requirements: true,
9476 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9477 };
9478 let before = evaluate_prewrite_gate(
9479 true,
9480 &requirements,
9481 PrewriteProgress {
9482 productive_write_tool_calls_total: 0,
9483 productive_workspace_inspection_total: 0,
9484 productive_concrete_read_total: 0,
9485 productive_web_research_total: 0,
9486 successful_web_research_total: 0,
9487 required_write_retry_count: 0,
9488 unmet_prewrite_repair_retry_count: 1,
9489 prewrite_gate_waived: false,
9490 },
9491 );
9492 assert!(
9493 before.allow_repair_tools,
9494 "repair tools should be active before waiver"
9495 );
9496 let after = evaluate_prewrite_gate(
9497 true,
9498 &requirements,
9499 PrewriteProgress {
9500 productive_write_tool_calls_total: 0,
9501 productive_workspace_inspection_total: 0,
9502 productive_concrete_read_total: 0,
9503 productive_web_research_total: 0,
9504 successful_web_research_total: 0,
9505 required_write_retry_count: 0,
9506 unmet_prewrite_repair_retry_count: 1,
9507 prewrite_gate_waived: true,
9508 },
9509 );
9510 assert!(
9511 !after.allow_repair_tools,
9512 "repair tools should be disabled after waiver"
9513 );
9514 }
9515
9516 #[test]
9517 fn force_write_only_enabled_after_prewrite_waiver() {
9518 let requirements = PrewriteRequirements {
9519 workspace_inspection_required: true,
9520 web_research_required: true,
9521 concrete_read_required: true,
9522 successful_web_research_required: true,
9523 repair_on_unmet_requirements: true,
9524 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9525 };
9526 let decision = evaluate_prewrite_gate(
9527 true,
9528 &requirements,
9529 PrewriteProgress {
9530 productive_write_tool_calls_total: 0,
9531 productive_workspace_inspection_total: 0,
9532 productive_concrete_read_total: 0,
9533 productive_web_research_total: 0,
9534 successful_web_research_total: 0,
9535 required_write_retry_count: 1,
9536 unmet_prewrite_repair_retry_count: 1,
9537 prewrite_gate_waived: true,
9538 },
9539 );
9540 assert!(
9541 decision.force_write_only_retry,
9542 "force_write_only should be active after prewrite waiver + write retry"
9543 );
9544 }
9545
9546 #[test]
9547 fn force_write_only_disabled_before_prewrite_waiver() {
9548 let requirements = PrewriteRequirements {
9549 workspace_inspection_required: true,
9550 web_research_required: true,
9551 concrete_read_required: true,
9552 successful_web_research_required: true,
9553 repair_on_unmet_requirements: true,
9554 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9555 };
9556 let decision = evaluate_prewrite_gate(
9557 true,
9558 &requirements,
9559 PrewriteProgress {
9560 productive_write_tool_calls_total: 0,
9561 productive_workspace_inspection_total: 0,
9562 productive_concrete_read_total: 0,
9563 productive_web_research_total: 0,
9564 successful_web_research_total: 0,
9565 required_write_retry_count: 1,
9566 unmet_prewrite_repair_retry_count: 1,
9567 prewrite_gate_waived: false,
9568 },
9569 );
9570 assert!(
9571 !decision.force_write_only_retry,
9572 "force_write_only should be disabled before waiver for prewrite nodes"
9573 );
9574 }
9575
9576 #[test]
9577 fn parse_budget_override_zero_disables_budget() {
9578 unsafe {
9579 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
9580 }
9581 assert_eq!(
9582 parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
9583 Some(usize::MAX)
9584 );
9585 unsafe {
9586 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
9587 }
9588 }
9589
9590 #[test]
9591 fn disable_tool_guard_budgets_env_overrides_all_budgets() {
9592 unsafe {
9593 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
9594 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9595 }
9596 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), 1);
9597 assert_eq!(tool_budget_for("websearch"), usize::MAX);
9598 unsafe {
9599 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9600 }
9601 }
9602
9603 #[test]
9604 fn email_delivery_budget_can_still_be_explicitly_overridden_when_global_budgets_are_disabled() {
9605 let _guard = env_test_lock();
9606 unsafe {
9607 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
9608 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "0");
9609 }
9610 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
9611 unsafe {
9612 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9613 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9614 }
9615 }
9616
9617 #[test]
9618 fn tool_budget_defaults_to_200_calls_and_1_for_email_delivery() {
9619 let _guard = env_test_lock();
9620 unsafe {
9621 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9622 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
9623 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
9624 std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
9625 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9626 }
9627 assert_eq!(tool_budget_for("bash"), 200);
9628 assert_eq!(tool_budget_for("websearch"), 200);
9629 assert_eq!(tool_budget_for("read"), 200);
9630 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
9631 assert_eq!(
9632 tool_budget_for("mcp.composio_1.gmail_create_email_draft"),
9633 1
9634 );
9635 }
9636
9637 #[test]
9638 fn tool_budget_env_override_respects_minimum_floor() {
9639 let _guard = env_test_lock();
9640 unsafe {
9641 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9642 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
9643 std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
9644 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9645 }
9646 assert_eq!(tool_budget_for("bash"), 200);
9647 assert_eq!(tool_budget_for("websearch"), 250);
9648 unsafe {
9649 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
9650 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
9651 }
9652 }
9653
9654 #[test]
9655 fn email_delivery_tool_budget_env_override_respects_floor_of_one() {
9656 let _guard = env_test_lock();
9657 unsafe {
9658 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9659 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "1");
9660 }
9661 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
9662 unsafe {
9663 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "5");
9664 }
9665 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 5);
9666 unsafe {
9667 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9668 }
9669 }
9670
9671 #[test]
9672 fn provider_agnostic_email_tools_share_single_send_budget() {
9673 let _guard = env_test_lock();
9674 unsafe {
9675 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
9676 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
9677 }
9678 assert_eq!(tool_budget_for("mcp.sendgrid.send_email"), 1);
9679 assert_eq!(tool_budget_for("mcp.resend.create_email_draft"), 1);
9680 assert_eq!(duplicate_signature_limit_for("mcp.outlook.reply_email"), 1);
9681 }
9682}