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