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 if let Some(summary) = summarize_auth_pending_outputs(&last_tool_outputs) {
2227 completion = summary;
2228 } else if let Some(hint) =
2229 summarize_terminal_tool_failure_for_user(&last_tool_outputs)
2230 {
2231 completion = hint;
2232 } else {
2233 let preview = summarize_user_visible_tool_outputs(&last_tool_outputs);
2234 if preview.trim().is_empty() {
2235 completion = "I used tools for this request, but I couldn't turn the results into a clean final answer. Please retry with the docs page URL, docs path, or exact search query you want me to use.".to_string();
2236 } else {
2237 completion = format!(
2238 "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
2239 preview
2240 );
2241 }
2242 }
2243 }
2244 if completion.trim().is_empty() {
2245 completion =
2246 "I couldn't produce a final response for that run. Please retry your request."
2247 .to_string();
2248 }
2249 if email_delivery_requested && !email_action_executed {
2254 let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
2255 .to_string();
2256 if let Some(note) = latest_email_action_note.as_ref() {
2257 fallback.push_str("\n\nLast email tool status: ");
2258 fallback.push_str(note);
2259 }
2260 fallback.push_str(
2261 "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
2262 );
2263 completion = fallback;
2264 }
2265 completion = strip_model_control_markers(&completion);
2266 truncate_text(&completion, 16_000)
2267 };
2268 emit_event(
2269 Level::INFO,
2270 ProcessKind::Engine,
2271 ObservabilityEvent {
2272 event: "provider.call.finish",
2273 component: "engine.loop",
2274 correlation_id: correlation_ref,
2275 session_id: Some(&session_id),
2276 run_id: None,
2277 message_id: Some(&user_message_id),
2278 provider_id: Some(provider_id.as_str()),
2279 model_id,
2280 status: Some("ok"),
2281 error_code: None,
2282 detail: Some("provider stream complete"),
2283 },
2284 );
2285 if active_agent.name.eq_ignore_ascii_case("plan") {
2286 emit_plan_todo_fallback(
2287 self.storage.clone(),
2288 &self.event_bus,
2289 &session_id,
2290 &user_message_id,
2291 &completion,
2292 )
2293 .await;
2294 let todos_after_fallback = self.storage.get_todos(&session_id).await;
2295 if todos_after_fallback.is_empty() && !question_tool_used {
2296 emit_plan_question_fallback(
2297 self.storage.clone(),
2298 &self.event_bus,
2299 &session_id,
2300 &user_message_id,
2301 &completion,
2302 )
2303 .await;
2304 }
2305 }
2306 if cancel.is_cancelled() {
2307 self.event_bus.publish(EngineEvent::new(
2308 "session.status",
2309 json!({"sessionID": session_id, "status":"cancelled"}),
2310 ));
2311 self.cancellations.remove(&session_id).await;
2312 return Ok(());
2313 }
2314 let assistant = Message::new(
2315 MessageRole::Assistant,
2316 vec![MessagePart::Text {
2317 text: completion.clone(),
2318 }],
2319 );
2320 let assistant_message_id = assistant.id.clone();
2321 self.storage.append_message(&session_id, assistant).await?;
2322 let final_part = WireMessagePart::text(
2323 &session_id,
2324 &assistant_message_id,
2325 truncate_text(&completion, 16_000),
2326 );
2327 self.event_bus.publish(EngineEvent::new(
2328 "message.part.updated",
2329 json!({"part": final_part}),
2330 ));
2331 self.event_bus.publish(EngineEvent::new(
2332 "session.updated",
2333 json!({"sessionID": session_id, "status":"idle"}),
2334 ));
2335 self.event_bus.publish(EngineEvent::new(
2336 "session.status",
2337 json!({"sessionID": session_id, "status":"idle"}),
2338 ));
2339 self.cancellations.remove(&session_id).await;
2340 Ok(())
2341 }
2342
2343 pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
2344 self.providers.default_complete(&prompt).await
2345 }
2346
2347 pub async fn run_oneshot_for_provider(
2348 &self,
2349 prompt: String,
2350 provider_id: Option<&str>,
2351 ) -> anyhow::Result<String> {
2352 self.providers
2353 .complete_for_provider(provider_id, &prompt, None)
2354 .await
2355 }
2356
2357 #[allow(clippy::too_many_arguments)]
2358 async fn execute_tool_with_permission(
2359 &self,
2360 session_id: &str,
2361 message_id: &str,
2362 tool: String,
2363 args: Value,
2364 initial_tool_call_id: Option<String>,
2365 equipped_skills: Option<&[String]>,
2366 latest_user_text: &str,
2367 write_required: bool,
2368 latest_assistant_context: Option<&str>,
2369 cancel: CancellationToken,
2370 ) -> anyhow::Result<Option<String>> {
2371 let tool = normalize_tool_name(&tool);
2372 let raw_args = args.clone();
2373 let publish_tool_effect = |tool_call_id: Option<&str>,
2374 phase: ToolEffectLedgerPhase,
2375 status: ToolEffectLedgerStatus,
2376 args: &Value,
2377 metadata: Option<&Value>,
2378 output: Option<&str>,
2379 error: Option<&str>| {
2380 self.event_bus
2381 .publish(tool_effect_ledger_event(build_tool_effect_ledger_record(
2382 session_id,
2383 message_id,
2384 tool_call_id,
2385 &tool,
2386 phase,
2387 status,
2388 args,
2389 metadata,
2390 output,
2391 error,
2392 )));
2393 };
2394 let normalized = normalize_tool_args_with_mode(
2395 &tool,
2396 args,
2397 latest_user_text,
2398 latest_assistant_context.unwrap_or_default(),
2399 if write_required {
2400 WritePathRecoveryMode::OutputTargetOnly
2401 } else {
2402 WritePathRecoveryMode::Heuristic
2403 },
2404 );
2405 let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
2406 let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
2407 self.event_bus.publish(EngineEvent::new(
2408 "tool.args.normalized",
2409 json!({
2410 "sessionID": session_id,
2411 "messageID": message_id,
2412 "tool": tool,
2413 "argsSource": normalized.args_source,
2414 "argsIntegrity": normalized.args_integrity,
2415 "rawArgsState": normalized.raw_args_state.as_str(),
2416 "rawArgsPreview": raw_args_preview,
2417 "normalizedArgsPreview": normalized_args_preview,
2418 "query": normalized.query,
2419 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2420 "requestID": Value::Null
2421 }),
2422 ));
2423 if normalized.args_integrity == "recovered" {
2424 self.event_bus.publish(EngineEvent::new(
2425 "tool.args.recovered",
2426 json!({
2427 "sessionID": session_id,
2428 "messageID": message_id,
2429 "tool": tool,
2430 "argsSource": normalized.args_source,
2431 "rawArgsPreview": raw_args_preview,
2432 "normalizedArgsPreview": normalized_args_preview,
2433 "query": normalized.query,
2434 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2435 "requestID": Value::Null
2436 }),
2437 ));
2438 }
2439 if normalized.missing_terminal {
2440 let missing_reason = normalized
2441 .missing_terminal_reason
2442 .clone()
2443 .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
2444 let latest_user_preview = truncate_text(latest_user_text, 500);
2445 let latest_assistant_preview =
2446 truncate_text(latest_assistant_context.unwrap_or_default(), 500);
2447 self.event_bus.publish(EngineEvent::new(
2448 "tool.args.missing_terminal",
2449 json!({
2450 "sessionID": session_id,
2451 "messageID": message_id,
2452 "tool": tool,
2453 "argsSource": normalized.args_source,
2454 "argsIntegrity": normalized.args_integrity,
2455 "rawArgsState": normalized.raw_args_state.as_str(),
2456 "requestID": Value::Null,
2457 "error": missing_reason,
2458 "rawArgsPreview": raw_args_preview,
2459 "normalizedArgsPreview": normalized_args_preview,
2460 "latestUserPreview": latest_user_preview,
2461 "latestAssistantPreview": latest_assistant_preview,
2462 }),
2463 ));
2464 if tool == "write" {
2465 tracing::warn!(
2466 session_id = %session_id,
2467 message_id = %message_id,
2468 tool = %tool,
2469 reason = %missing_reason,
2470 args_source = %normalized.args_source,
2471 args_integrity = %normalized.args_integrity,
2472 raw_args_state = %normalized.raw_args_state.as_str(),
2473 raw_args = %raw_args_preview,
2474 normalized_args = %normalized_args_preview,
2475 latest_user = %latest_user_preview,
2476 latest_assistant = %latest_assistant_preview,
2477 "write tool arguments missing terminal field"
2478 );
2479 }
2480 let best_effort_args = persisted_failed_tool_args(&raw_args, &normalized.args);
2481 let mut failed_part = WireMessagePart::tool_result(
2482 session_id,
2483 message_id,
2484 tool.clone(),
2485 Some(best_effort_args),
2486 json!(null),
2487 );
2488 failed_part.state = Some("failed".to_string());
2489 let surfaced_reason =
2490 provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
2491 .unwrap_or_else(|| missing_reason.clone());
2492 failed_part.error = Some(surfaced_reason.clone());
2493 self.event_bus.publish(EngineEvent::new(
2494 "message.part.updated",
2495 json!({"part": failed_part}),
2496 ));
2497 publish_tool_effect(
2498 None,
2499 ToolEffectLedgerPhase::Outcome,
2500 ToolEffectLedgerStatus::Blocked,
2501 &normalized.args,
2502 None,
2503 None,
2504 Some(&surfaced_reason),
2505 );
2506 return Ok(Some(surfaced_reason));
2507 }
2508
2509 let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
2510 Ok(args) => args,
2511 Err(message) => {
2512 publish_tool_effect(
2513 None,
2514 ToolEffectLedgerPhase::Outcome,
2515 ToolEffectLedgerStatus::Blocked,
2516 &raw_args,
2517 None,
2518 None,
2519 Some(&message),
2520 );
2521 return Ok(Some(message));
2522 }
2523 };
2524 if let Some(allowed_tools) = self
2525 .session_allowed_tools
2526 .read()
2527 .await
2528 .get(session_id)
2529 .cloned()
2530 {
2531 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
2532 let reason = format!("Tool `{tool}` is not allowed for this run.");
2533 publish_tool_effect(
2534 None,
2535 ToolEffectLedgerPhase::Outcome,
2536 ToolEffectLedgerStatus::Blocked,
2537 &args,
2538 None,
2539 None,
2540 Some(&reason),
2541 );
2542 return Ok(Some(reason));
2543 }
2544 }
2545 if let Some(hook) = self.tool_policy_hook.read().await.clone() {
2546 let decision = hook
2547 .evaluate_tool(ToolPolicyContext {
2548 session_id: session_id.to_string(),
2549 message_id: message_id.to_string(),
2550 tool: tool.clone(),
2551 args: args.clone(),
2552 })
2553 .await?;
2554 if !decision.allowed {
2555 let reason = decision
2556 .reason
2557 .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
2558 let mut blocked_part = WireMessagePart::tool_result(
2559 session_id,
2560 message_id,
2561 tool.clone(),
2562 Some(args.clone()),
2563 json!(null),
2564 );
2565 blocked_part.state = Some("failed".to_string());
2566 blocked_part.error = Some(reason.clone());
2567 self.event_bus.publish(EngineEvent::new(
2568 "message.part.updated",
2569 json!({"part": blocked_part}),
2570 ));
2571 publish_tool_effect(
2572 None,
2573 ToolEffectLedgerPhase::Outcome,
2574 ToolEffectLedgerStatus::Blocked,
2575 &args,
2576 None,
2577 None,
2578 Some(&reason),
2579 );
2580 return Ok(Some(reason));
2581 }
2582 }
2583 let mut tool_call_id: Option<String> = initial_tool_call_id;
2584 if let Some(violation) = self
2585 .workspace_sandbox_violation(session_id, &tool, &args)
2586 .await
2587 {
2588 let mut blocked_part = WireMessagePart::tool_result(
2589 session_id,
2590 message_id,
2591 tool.clone(),
2592 Some(args.clone()),
2593 json!(null),
2594 );
2595 blocked_part.state = Some("failed".to_string());
2596 blocked_part.error = Some(violation.clone());
2597 self.event_bus.publish(EngineEvent::new(
2598 "message.part.updated",
2599 json!({"part": blocked_part}),
2600 ));
2601 publish_tool_effect(
2602 tool_call_id.as_deref(),
2603 ToolEffectLedgerPhase::Outcome,
2604 ToolEffectLedgerStatus::Blocked,
2605 &args,
2606 None,
2607 None,
2608 Some(&violation),
2609 );
2610 return Ok(Some(violation));
2611 }
2612 let rule = self
2613 .plugins
2614 .permission_override(&tool)
2615 .await
2616 .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
2617 if matches!(rule, PermissionAction::Deny) {
2618 let reason = format!("Permission denied for tool `{tool}` by policy.");
2619 publish_tool_effect(
2620 tool_call_id.as_deref(),
2621 ToolEffectLedgerPhase::Outcome,
2622 ToolEffectLedgerStatus::Blocked,
2623 &args,
2624 None,
2625 None,
2626 Some(&reason),
2627 );
2628 return Ok(Some(reason));
2629 }
2630
2631 let mut effective_args = args.clone();
2632 if matches!(rule, PermissionAction::Ask) {
2633 let auto_approve_permissions = self
2634 .session_auto_approve_permissions
2635 .read()
2636 .await
2637 .get(session_id)
2638 .copied()
2639 .unwrap_or(false);
2640 if auto_approve_permissions {
2641 if normalized.args_integrity == "recovered" && is_workspace_write_tool(&tool) {
2646 tracing::warn!(
2647 session_id = %session_id,
2648 message_id = %message_id,
2649 tool = %tool,
2650 args_source = %normalized.args_source,
2651 "auto-approve granted for mutating tool with recovered args; verify intent"
2652 );
2653 self.event_bus.publish(EngineEvent::new(
2654 "tool.args.recovered_write_auto_approved",
2655 json!({
2656 "sessionID": session_id,
2657 "messageID": message_id,
2658 "tool": tool,
2659 "argsSource": normalized.args_source,
2660 "argsIntegrity": normalized.args_integrity,
2661 }),
2662 ));
2663 }
2664 self.event_bus.publish(EngineEvent::new(
2665 "permission.auto_approved",
2666 json!({
2667 "sessionID": session_id,
2668 "messageID": message_id,
2669 "tool": tool,
2670 }),
2671 ));
2672 effective_args = args;
2673 } else {
2674 let pending = self
2675 .permissions
2676 .ask_for_session_with_context(
2677 Some(session_id),
2678 &tool,
2679 args.clone(),
2680 Some(crate::PermissionArgsContext {
2681 args_source: normalized.args_source.clone(),
2682 args_integrity: normalized.args_integrity.clone(),
2683 query: normalized.query.clone(),
2684 }),
2685 )
2686 .await;
2687 let mut pending_part = WireMessagePart::tool_invocation(
2688 session_id,
2689 message_id,
2690 tool.clone(),
2691 args.clone(),
2692 );
2693 pending_part.id = Some(pending.id.clone());
2694 tool_call_id = Some(pending.id.clone());
2695 pending_part.state = Some("pending".to_string());
2696 self.event_bus.publish(EngineEvent::new(
2697 "message.part.updated",
2698 json!({"part": pending_part}),
2699 ));
2700 let reply = self
2701 .permissions
2702 .wait_for_reply_with_timeout(
2703 &pending.id,
2704 cancel.clone(),
2705 Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2706 )
2707 .await;
2708 let (reply, timed_out) = reply;
2709 if cancel.is_cancelled() {
2710 return Ok(None);
2711 }
2712 if timed_out {
2713 let timeout_ms = permission_wait_timeout_ms();
2714 self.event_bus.publish(EngineEvent::new(
2715 "permission.wait.timeout",
2716 json!({
2717 "sessionID": session_id,
2718 "messageID": message_id,
2719 "tool": tool,
2720 "requestID": pending.id,
2721 "timeoutMs": timeout_ms,
2722 }),
2723 ));
2724 let mut timeout_part = WireMessagePart::tool_result(
2725 session_id,
2726 message_id,
2727 tool.clone(),
2728 Some(args.clone()),
2729 json!(null),
2730 );
2731 timeout_part.id = Some(pending.id);
2732 timeout_part.state = Some("failed".to_string());
2733 timeout_part.error = Some(format!(
2734 "Permission request timed out after {} ms",
2735 timeout_ms
2736 ));
2737 self.event_bus.publish(EngineEvent::new(
2738 "message.part.updated",
2739 json!({"part": timeout_part}),
2740 ));
2741 let timeout_reason = format!(
2742 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2743 );
2744 publish_tool_effect(
2745 tool_call_id.as_deref(),
2746 ToolEffectLedgerPhase::Outcome,
2747 ToolEffectLedgerStatus::Blocked,
2748 &args,
2749 None,
2750 None,
2751 Some(&timeout_reason),
2752 );
2753 return Ok(Some(format!(
2754 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2755 )));
2756 }
2757 let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2758 if !approved {
2759 let mut denied_part = WireMessagePart::tool_result(
2760 session_id,
2761 message_id,
2762 tool.clone(),
2763 Some(args.clone()),
2764 json!(null),
2765 );
2766 denied_part.id = Some(pending.id);
2767 denied_part.state = Some("denied".to_string());
2768 denied_part.error = Some("Permission denied by user".to_string());
2769 self.event_bus.publish(EngineEvent::new(
2770 "message.part.updated",
2771 json!({"part": denied_part}),
2772 ));
2773 let denied_reason = format!("Permission denied for tool `{tool}` by user.");
2774 publish_tool_effect(
2775 tool_call_id.as_deref(),
2776 ToolEffectLedgerPhase::Outcome,
2777 ToolEffectLedgerStatus::Blocked,
2778 &args,
2779 None,
2780 None,
2781 Some(&denied_reason),
2782 );
2783 return Ok(Some(format!(
2784 "Permission denied for tool `{tool}` by user."
2785 )));
2786 }
2787 effective_args = args;
2788 }
2789 }
2790
2791 let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2792 let session = self.storage.get_session(session_id).await;
2793 if let (Some(obj), Some(session)) = (args.as_object_mut(), session.as_ref()) {
2794 obj.insert(
2795 "__session_id".to_string(),
2796 Value::String(session_id.to_string()),
2797 );
2798 if let Some(project_id) = session.project_id.clone() {
2799 obj.insert(
2800 "__project_id".to_string(),
2801 Value::String(project_id.clone()),
2802 );
2803 if project_id.starts_with("channel-public::") {
2804 obj.insert(
2805 "__memory_max_visible_scope".to_string(),
2806 Value::String("project".to_string()),
2807 );
2808 }
2809 }
2810 }
2811 let tool_context = self.resolve_tool_execution_context(session_id).await;
2812 if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2813 args = rewrite_workspace_alias_tool_args(&tool, args, workspace_root);
2814 if let Some(obj) = args.as_object_mut() {
2815 obj.insert(
2816 "__workspace_root".to_string(),
2817 Value::String(workspace_root.clone()),
2818 );
2819 obj.insert(
2820 "__effective_cwd".to_string(),
2821 Value::String(effective_cwd.clone()),
2822 );
2823 obj.insert(
2824 "__session_id".to_string(),
2825 Value::String(session_id.to_string()),
2826 );
2827 if let Some(project_id) = project_id.clone() {
2828 obj.insert("__project_id".to_string(), Value::String(project_id));
2829 }
2830 }
2831 tracing::info!(
2832 "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2833 session_id,
2834 tool,
2835 workspace_root,
2836 effective_cwd,
2837 project_id.clone().unwrap_or_default()
2838 );
2839 }
2840 let mut invoke_part =
2841 WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2842 if let Some(call_id) = tool_call_id.clone() {
2843 invoke_part.id = Some(call_id);
2844 }
2845 let invoke_part_id = invoke_part.id.clone();
2846 self.event_bus.publish(EngineEvent::new(
2847 "message.part.updated",
2848 json!({"part": invoke_part}),
2849 ));
2850 let args_for_side_events = args.clone();
2851 let mutation_checkpoint = prepare_mutation_checkpoint(&tool, &args_for_side_events);
2852 let progress_sink: SharedToolProgressSink = std::sync::Arc::new(EngineToolProgressSink {
2853 event_bus: self.event_bus.clone(),
2854 session_id: session_id.to_string(),
2855 message_id: message_id.to_string(),
2856 tool_call_id: invoke_part_id.clone(),
2857 source_tool: tool.clone(),
2858 });
2859 publish_tool_effect(
2860 invoke_part_id.as_deref(),
2861 ToolEffectLedgerPhase::Invocation,
2862 ToolEffectLedgerStatus::Started,
2863 &args_for_side_events,
2864 None,
2865 None,
2866 None,
2867 );
2868 let publish_mutation_checkpoint =
2869 |tool_call_id: Option<&str>, outcome: MutationCheckpointOutcome| {
2870 if let Some(baseline) = mutation_checkpoint.as_ref() {
2871 self.event_bus.publish(mutation_checkpoint_event(
2872 finalize_mutation_checkpoint_record(
2873 session_id,
2874 message_id,
2875 tool_call_id,
2876 baseline,
2877 outcome,
2878 ),
2879 ));
2880 }
2881 };
2882 if tool == "spawn_agent" {
2883 let hook = self.spawn_agent_hook.read().await.clone();
2884 if let Some(hook) = hook {
2885 let spawned = hook
2886 .spawn_agent(SpawnAgentToolContext {
2887 session_id: session_id.to_string(),
2888 message_id: message_id.to_string(),
2889 tool_call_id: invoke_part_id.clone(),
2890 args: args_for_side_events.clone(),
2891 })
2892 .await?;
2893 let output = self.plugins.transform_tool_output(spawned.output).await;
2894 let output = truncate_text(&output, 16_000);
2895 emit_tool_side_events(
2896 self.storage.clone(),
2897 &self.event_bus,
2898 ToolSideEventContext {
2899 session_id,
2900 message_id,
2901 tool: &tool,
2902 args: &args_for_side_events,
2903 metadata: &spawned.metadata,
2904 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2905 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2906 },
2907 )
2908 .await;
2909 let mut result_part = WireMessagePart::tool_result(
2910 session_id,
2911 message_id,
2912 tool.clone(),
2913 Some(args_for_side_events.clone()),
2914 json!(output.clone()),
2915 );
2916 result_part.id = invoke_part_id.clone();
2917 self.event_bus.publish(EngineEvent::new(
2918 "message.part.updated",
2919 json!({"part": result_part}),
2920 ));
2921 publish_tool_effect(
2922 invoke_part_id.as_deref(),
2923 ToolEffectLedgerPhase::Outcome,
2924 ToolEffectLedgerStatus::Succeeded,
2925 &args_for_side_events,
2926 Some(&spawned.metadata),
2927 Some(&output),
2928 None,
2929 );
2930 publish_mutation_checkpoint(
2931 invoke_part_id.as_deref(),
2932 MutationCheckpointOutcome::Succeeded,
2933 );
2934 return Ok(Some(truncate_text(
2935 &format!("Tool `{tool}` result:\n{output}"),
2936 16_000,
2937 )));
2938 }
2939 let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2940 let mut failed_part = WireMessagePart::tool_result(
2941 session_id,
2942 message_id,
2943 tool.clone(),
2944 Some(args_for_side_events.clone()),
2945 json!(null),
2946 );
2947 failed_part.id = invoke_part_id.clone();
2948 failed_part.state = Some("failed".to_string());
2949 failed_part.error = Some(output.to_string());
2950 self.event_bus.publish(EngineEvent::new(
2951 "message.part.updated",
2952 json!({"part": failed_part}),
2953 ));
2954 publish_tool_effect(
2955 invoke_part_id.as_deref(),
2956 ToolEffectLedgerPhase::Outcome,
2957 ToolEffectLedgerStatus::Failed,
2958 &args_for_side_events,
2959 None,
2960 None,
2961 Some(output),
2962 );
2963 publish_mutation_checkpoint(
2964 invoke_part_id.as_deref(),
2965 MutationCheckpointOutcome::Failed,
2966 );
2967 return Ok(Some(output.to_string()));
2968 }
2969 if tool == "batch" {
2976 let allowed_tools = self
2977 .session_allowed_tools
2978 .read()
2979 .await
2980 .get(session_id)
2981 .cloned()
2982 .unwrap_or_default();
2983
2984 let ctx_workspace_root = args
2986 .get("__workspace_root")
2987 .and_then(|v| v.as_str())
2988 .map(ToString::to_string);
2989 let ctx_effective_cwd = args
2990 .get("__effective_cwd")
2991 .and_then(|v| v.as_str())
2992 .map(ToString::to_string);
2993 let ctx_session_id = args
2994 .get("__session_id")
2995 .and_then(|v| v.as_str())
2996 .map(ToString::to_string);
2997 let ctx_project_id = args
2998 .get("__project_id")
2999 .and_then(|v| v.as_str())
3000 .map(ToString::to_string);
3001
3002 let raw_calls = args
3004 .get("tool_calls")
3005 .and_then(|v| v.as_array())
3006 .cloned()
3007 .unwrap_or_default();
3008
3009 let mut governed_calls: Vec<Value> = Vec::new();
3010 for mut call in raw_calls {
3011 let (sub_tool, mut sub_args) = {
3012 let obj = match call.as_object() {
3013 Some(o) => o,
3014 None => {
3015 governed_calls.push(call);
3016 continue;
3017 }
3018 };
3019 let tool_raw = non_empty_string_at(obj, "tool")
3020 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3021 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3022 .or_else(|| non_empty_string_at(obj, "name"));
3023 let sub_tool = match tool_raw {
3024 Some(t) => normalize_tool_name(t),
3025 None => {
3026 governed_calls.push(call);
3027 continue;
3028 }
3029 };
3030 let sub_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3031 (sub_tool, sub_args)
3032 };
3033
3034 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &sub_tool) {
3036 if let Some(obj) = call.as_object_mut() {
3038 obj.insert(
3039 "_blocked".to_string(),
3040 Value::String(format!(
3041 "batch sub-call skipped: tool `{sub_tool}` is not in the allowed list for this run"
3042 )),
3043 );
3044 }
3045 governed_calls.push(call);
3046 continue;
3047 }
3048
3049 if let Some(violation) = self
3051 .workspace_sandbox_violation(session_id, &sub_tool, &sub_args)
3052 .await
3053 {
3054 if let Some(obj) = call.as_object_mut() {
3055 obj.insert(
3056 "_blocked".to_string(),
3057 Value::String(format!("batch sub-call skipped: {violation}")),
3058 );
3059 }
3060 governed_calls.push(call);
3061 continue;
3062 }
3063
3064 if let Some(sub_obj) = sub_args.as_object_mut() {
3066 if let Some(ref v) = ctx_workspace_root {
3067 sub_obj
3068 .entry("__workspace_root")
3069 .or_insert_with(|| Value::String(v.clone()));
3070 }
3071 if let Some(ref v) = ctx_effective_cwd {
3072 sub_obj
3073 .entry("__effective_cwd")
3074 .or_insert_with(|| Value::String(v.clone()));
3075 }
3076 if let Some(ref v) = ctx_session_id {
3077 sub_obj
3078 .entry("__session_id")
3079 .or_insert_with(|| Value::String(v.clone()));
3080 }
3081 if let Some(ref v) = ctx_project_id {
3082 sub_obj
3083 .entry("__project_id")
3084 .or_insert_with(|| Value::String(v.clone()));
3085 }
3086 }
3087
3088 if let Some(obj) = call.as_object_mut() {
3090 obj.insert("args".to_string(), sub_args);
3091 }
3092 governed_calls.push(call);
3093 }
3094
3095 if let Some(obj) = args.as_object_mut() {
3097 obj.insert("tool_calls".to_string(), Value::Array(governed_calls));
3098 }
3099 }
3100 let result = match self
3101 .execute_tool_with_timeout(&tool, args, cancel.clone(), Some(progress_sink))
3102 .await
3103 {
3104 Ok(result) => result,
3105 Err(err) => {
3106 let err_text = err.to_string();
3107 if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
3108 let timeout_ms = tool_exec_timeout_ms();
3109 let timeout_output = format!(
3110 "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
3111 );
3112 let mut failed_part = WireMessagePart::tool_result(
3113 session_id,
3114 message_id,
3115 tool.clone(),
3116 Some(args_for_side_events.clone()),
3117 json!(null),
3118 );
3119 failed_part.id = invoke_part_id.clone();
3120 failed_part.state = Some("failed".to_string());
3121 failed_part.error = Some(timeout_output.clone());
3122 self.event_bus.publish(EngineEvent::new(
3123 "message.part.updated",
3124 json!({"part": failed_part}),
3125 ));
3126 publish_tool_effect(
3127 invoke_part_id.as_deref(),
3128 ToolEffectLedgerPhase::Outcome,
3129 ToolEffectLedgerStatus::Failed,
3130 &args_for_side_events,
3131 None,
3132 None,
3133 Some(&timeout_output),
3134 );
3135 publish_mutation_checkpoint(
3136 invoke_part_id.as_deref(),
3137 MutationCheckpointOutcome::Failed,
3138 );
3139 return Ok(Some(timeout_output));
3140 }
3141 if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
3142 self.event_bus.publish(EngineEvent::new(
3143 "mcp.auth.required",
3144 json!({
3145 "sessionID": session_id,
3146 "messageID": message_id,
3147 "tool": tool.clone(),
3148 "server": auth.server,
3149 "authorizationUrl": auth.authorization_url,
3150 "message": auth.message,
3151 "challengeId": auth.challenge_id
3152 }),
3153 ));
3154 let auth_output = format!(
3155 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3156 tool, auth.message, auth.authorization_url
3157 );
3158 let mut result_part = WireMessagePart::tool_result(
3159 session_id,
3160 message_id,
3161 tool.clone(),
3162 Some(args_for_side_events.clone()),
3163 json!(auth_output.clone()),
3164 );
3165 result_part.id = invoke_part_id.clone();
3166 self.event_bus.publish(EngineEvent::new(
3167 "message.part.updated",
3168 json!({"part": result_part}),
3169 ));
3170 publish_tool_effect(
3171 invoke_part_id.as_deref(),
3172 ToolEffectLedgerPhase::Outcome,
3173 ToolEffectLedgerStatus::Blocked,
3174 &args_for_side_events,
3175 None,
3176 Some(&auth_output),
3177 Some(&auth.message),
3178 );
3179 publish_mutation_checkpoint(
3180 invoke_part_id.as_deref(),
3181 MutationCheckpointOutcome::Blocked,
3182 );
3183 return Ok(Some(truncate_text(
3184 &format!("Tool `{tool}` result:\n{auth_output}"),
3185 16_000,
3186 )));
3187 }
3188 let mut failed_part = WireMessagePart::tool_result(
3189 session_id,
3190 message_id,
3191 tool.clone(),
3192 Some(args_for_side_events.clone()),
3193 json!(null),
3194 );
3195 failed_part.id = invoke_part_id.clone();
3196 failed_part.state = Some("failed".to_string());
3197 failed_part.error = Some(err_text.clone());
3198 self.event_bus.publish(EngineEvent::new(
3199 "message.part.updated",
3200 json!({"part": failed_part}),
3201 ));
3202 publish_tool_effect(
3203 invoke_part_id.as_deref(),
3204 ToolEffectLedgerPhase::Outcome,
3205 ToolEffectLedgerStatus::Failed,
3206 &args_for_side_events,
3207 None,
3208 None,
3209 Some(&err_text),
3210 );
3211 publish_mutation_checkpoint(
3212 invoke_part_id.as_deref(),
3213 MutationCheckpointOutcome::Failed,
3214 );
3215 return Err(err);
3216 }
3217 };
3218 if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3219 let event_name = if auth.pending && auth.blocked {
3220 "mcp.auth.pending"
3221 } else {
3222 "mcp.auth.required"
3223 };
3224 self.event_bus.publish(EngineEvent::new(
3225 event_name,
3226 json!({
3227 "sessionID": session_id,
3228 "messageID": message_id,
3229 "tool": tool.clone(),
3230 "server": auth.server,
3231 "authorizationUrl": auth.authorization_url,
3232 "message": auth.message,
3233 "challengeId": auth.challenge_id,
3234 "pending": auth.pending,
3235 "blocked": auth.blocked,
3236 "retryAfterMs": auth.retry_after_ms
3237 }),
3238 ));
3239 }
3240 emit_tool_side_events(
3241 self.storage.clone(),
3242 &self.event_bus,
3243 ToolSideEventContext {
3244 session_id,
3245 message_id,
3246 tool: &tool,
3247 args: &args_for_side_events,
3248 metadata: &result.metadata,
3249 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
3250 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
3251 },
3252 )
3253 .await;
3254 let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3255 if auth.pending && auth.blocked {
3256 let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
3257 format!(
3258 "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
3259 tool, auth.message, auth.authorization_url, retry_after_secs
3260 )
3261 } else {
3262 format!(
3263 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3264 tool, auth.message, auth.authorization_url
3265 )
3266 }
3267 } else {
3268 self.plugins.transform_tool_output(result.output).await
3269 };
3270 let output = truncate_text(&output, 16_000);
3271 let mut result_part = WireMessagePart::tool_result(
3272 session_id,
3273 message_id,
3274 tool.clone(),
3275 Some(args_for_side_events.clone()),
3276 json!(output.clone()),
3277 );
3278 result_part.id = invoke_part_id.clone();
3279 self.event_bus.publish(EngineEvent::new(
3280 "message.part.updated",
3281 json!({"part": result_part}),
3282 ));
3283 publish_tool_effect(
3284 invoke_part_id.as_deref(),
3285 ToolEffectLedgerPhase::Outcome,
3286 ToolEffectLedgerStatus::Succeeded,
3287 &args_for_side_events,
3288 Some(&result.metadata),
3289 Some(&output),
3290 None,
3291 );
3292 publish_mutation_checkpoint(
3293 invoke_part_id.as_deref(),
3294 MutationCheckpointOutcome::Succeeded,
3295 );
3296 Ok(Some(truncate_text(
3297 &format!("Tool `{tool}` result:\n{output}"),
3298 16_000,
3299 )))
3300 }
3301
3302 async fn execute_tool_with_timeout(
3303 &self,
3304 tool: &str,
3305 args: Value,
3306 cancel: CancellationToken,
3307 progress: Option<SharedToolProgressSink>,
3308 ) -> anyhow::Result<tandem_types::ToolResult> {
3309 let timeout_ms = tool_exec_timeout_ms() as u64;
3310 match tokio::time::timeout(
3311 Duration::from_millis(timeout_ms),
3312 self.tools
3313 .execute_with_cancel_and_progress(tool, args, cancel, progress),
3314 )
3315 .await
3316 {
3317 Ok(result) => result,
3318 Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
3319 }
3320 }
3321
3322 async fn find_recent_matching_user_message_id(
3323 &self,
3324 session_id: &str,
3325 text: &str,
3326 ) -> Option<String> {
3327 let session = self.storage.get_session(session_id).await?;
3328 let last = session.messages.last()?;
3329 if !matches!(last.role, MessageRole::User) {
3330 return None;
3331 }
3332 let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
3333 if age_ms > 10_000 {
3334 return None;
3335 }
3336 let last_text = last
3337 .parts
3338 .iter()
3339 .filter_map(|part| match part {
3340 MessagePart::Text { text } => Some(text.clone()),
3341 _ => None,
3342 })
3343 .collect::<Vec<_>>()
3344 .join("\n");
3345 if last_text == text {
3346 return Some(last.id.clone());
3347 }
3348 None
3349 }
3350
3351 async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
3352 let Some(mut session) = self.storage.get_session(session_id).await else {
3353 return;
3354 };
3355 if !title_needs_repair(&session.title) {
3356 return;
3357 }
3358
3359 let first_user_text = session.messages.iter().find_map(|message| {
3360 if !matches!(message.role, MessageRole::User) {
3361 return None;
3362 }
3363 message.parts.iter().find_map(|part| match part {
3364 MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
3365 _ => None,
3366 })
3367 });
3368
3369 let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
3370 let Some(title) = derive_session_title_from_prompt(&source, 60) else {
3371 return;
3372 };
3373
3374 session.title = title;
3375 session.time.updated = Utc::now();
3376 let _ = self.storage.save_session(session).await;
3377 }
3378
3379 async fn workspace_sandbox_violation(
3380 &self,
3381 session_id: &str,
3382 tool: &str,
3383 args: &Value,
3384 ) -> Option<String> {
3385 if self.workspace_override_active(session_id).await {
3386 return None;
3387 }
3388 if is_mcp_tool_name(tool) {
3392 if let Some(server) = mcp_server_from_tool_name(tool) {
3393 if is_mcp_sandbox_exempt_server(server) {
3394 return None;
3395 }
3396 }
3397 let candidate_paths = extract_tool_candidate_paths(tool, args);
3398 if candidate_paths.is_empty() {
3399 return None;
3401 }
3402 let session = self.storage.get_session(session_id).await?;
3404 let workspace = session
3405 .workspace_root
3406 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3407 let workspace_path = PathBuf::from(&workspace);
3408 if let Some(sensitive) = candidate_paths.iter().find(|path| {
3409 let raw = Path::new(path);
3410 let resolved = if raw.is_absolute() {
3411 raw.to_path_buf()
3412 } else {
3413 workspace_path.join(raw)
3414 };
3415 is_sensitive_path_candidate(&resolved)
3416 }) {
3417 return Some(format!(
3418 "Sandbox blocked MCP tool `{tool}` path `{sensitive}` (sensitive path policy)."
3419 ));
3420 }
3421 let outside = candidate_paths.iter().find(|path| {
3422 let raw = Path::new(path);
3423 let resolved = if raw.is_absolute() {
3424 raw.to_path_buf()
3425 } else {
3426 workspace_path.join(raw)
3427 };
3428 !crate::is_within_workspace_root(&resolved, &workspace_path)
3429 })?;
3430 return Some(format!(
3431 "Sandbox blocked MCP tool `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3432 ));
3433 }
3434 let session = self.storage.get_session(session_id).await?;
3435 let workspace = session
3436 .workspace_root
3437 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3438 let workspace_path = PathBuf::from(&workspace);
3439 let candidate_paths = extract_tool_candidate_paths(tool, args);
3440 if candidate_paths.is_empty() {
3441 if is_shell_tool_name(tool) {
3442 if let Some(command) = extract_shell_command(args) {
3443 if shell_command_targets_sensitive_path(&command) {
3444 return Some(format!(
3445 "Sandbox blocked `{tool}` command targeting sensitive paths."
3446 ));
3447 }
3448 }
3449 }
3450 return None;
3451 }
3452 if let Some(sensitive) = candidate_paths.iter().find(|path| {
3453 let raw = Path::new(path);
3454 let resolved = if raw.is_absolute() {
3455 raw.to_path_buf()
3456 } else {
3457 workspace_path.join(raw)
3458 };
3459 is_sensitive_path_candidate(&resolved)
3460 }) {
3461 return Some(format!(
3462 "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
3463 ));
3464 }
3465
3466 let outside = candidate_paths.iter().find(|path| {
3467 let raw = Path::new(path);
3468 let resolved = if raw.is_absolute() {
3469 raw.to_path_buf()
3470 } else {
3471 workspace_path.join(raw)
3472 };
3473 !crate::is_within_workspace_root(&resolved, &workspace_path)
3474 })?;
3475 Some(format!(
3476 "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3477 ))
3478 }
3479
3480 async fn resolve_tool_execution_context(
3481 &self,
3482 session_id: &str,
3483 ) -> Option<(String, String, Option<String>)> {
3484 let session = self.storage.get_session(session_id).await?;
3485 let workspace_root = session
3486 .workspace_root
3487 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3488 let effective_cwd = if session.directory.trim().is_empty()
3489 || session.directory.trim() == "."
3490 {
3491 workspace_root.clone()
3492 } else {
3493 crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
3494 };
3495 let project_id = session
3496 .project_id
3497 .clone()
3498 .or_else(|| crate::workspace_project_id(&workspace_root));
3499 Some((workspace_root, effective_cwd, project_id))
3500 }
3501
3502 async fn workspace_override_active(&self, session_id: &str) -> bool {
3503 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
3504 let mut overrides = self.workspace_overrides.write().await;
3505 let expired: Vec<String> = overrides
3507 .iter()
3508 .filter_map(|(id, &exp)| if exp <= now { Some(id.clone()) } else { None })
3509 .collect();
3510 overrides.retain(|_, expires_at| *expires_at > now);
3511 drop(overrides);
3512 for expired_id in expired {
3513 self.event_bus.publish(EngineEvent::new(
3514 "workspace.override.expired",
3515 json!({ "sessionID": expired_id }),
3516 ));
3517 }
3518 self.workspace_overrides
3519 .read()
3520 .await
3521 .get(session_id)
3522 .map(|expires_at| *expires_at > now)
3523 .unwrap_or(false)
3524 }
3525
3526 async fn generate_final_narrative_without_tools(
3527 &self,
3528 session_id: &str,
3529 active_agent: &AgentDefinition,
3530 provider_hint: Option<&str>,
3531 model_id: Option<&str>,
3532 cancel: CancellationToken,
3533 tool_outputs: &[String],
3534 ) -> Option<String> {
3535 if cancel.is_cancelled() {
3536 return None;
3537 }
3538 let mut messages = load_chat_history(
3539 self.storage.clone(),
3540 session_id,
3541 ChatHistoryProfile::Standard,
3542 )
3543 .await;
3544 let mut system_parts = vec![tandem_runtime_system_prompt(
3545 &self.host_runtime_context,
3546 &[],
3547 )];
3548 if let Some(system) = active_agent.system_prompt.as_ref() {
3549 system_parts.push(system.clone());
3550 }
3551 messages.insert(
3552 0,
3553 ChatMessage {
3554 role: "system".to_string(),
3555 content: system_parts.join("\n\n"),
3556 attachments: Vec::new(),
3557 },
3558 );
3559 messages.push(ChatMessage {
3560 role: "user".to_string(),
3561 content: build_post_tool_final_narrative_prompt(tool_outputs),
3562 attachments: Vec::new(),
3563 });
3564 let stream = self
3565 .providers
3566 .stream_for_provider(
3567 provider_hint,
3568 model_id,
3569 messages,
3570 ToolMode::None,
3571 None,
3572 cancel.clone(),
3573 )
3574 .await
3575 .ok()?;
3576 tokio::pin!(stream);
3577 let mut completion = String::new();
3578 while let Some(chunk) = stream.next().await {
3579 if cancel.is_cancelled() {
3580 return None;
3581 }
3582 match chunk {
3583 Ok(StreamChunk::TextDelta(delta)) => {
3584 let delta = strip_model_control_markers(&delta);
3585 if !delta.trim().is_empty() {
3586 completion.push_str(&delta);
3587 }
3588 }
3589 Ok(StreamChunk::Done { .. }) => break,
3590 Ok(_) => {}
3591 Err(_) => return None,
3592 }
3593 }
3594 let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
3595 if completion.trim().is_empty() {
3596 None
3597 } else {
3598 Some(completion)
3599 }
3600 }
3601}
3602
3603fn resolve_model_route(
3604 request_model: Option<&ModelSpec>,
3605 session_model: Option<&ModelSpec>,
3606) -> Option<(String, String)> {
3607 fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
3608 let provider_id = spec.provider_id.trim();
3609 let model_id = spec.model_id.trim();
3610 if provider_id.is_empty() || model_id.is_empty() {
3611 return None;
3612 }
3613 Some((provider_id.to_string(), model_id.to_string()))
3614 }
3615
3616 request_model
3617 .and_then(normalize)
3618 .or_else(|| session_model.and_then(normalize))
3619}
3620
3621fn strip_model_control_markers(input: &str) -> String {
3622 let mut cleaned = input.to_string();
3623 for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
3624 if cleaned.contains(marker) {
3625 cleaned = cleaned.replace(marker, "");
3626 }
3627 }
3628 cleaned
3629}
3630
3631fn truncate_text(input: &str, max_len: usize) -> String {
3632 if input.len() <= max_len {
3633 return input.to_string();
3634 }
3635 let mut end = 0usize;
3636 for (idx, ch) in input.char_indices() {
3637 let next = idx + ch.len_utf8();
3638 if next > max_len {
3639 break;
3640 }
3641 end = next;
3642 }
3643 let mut out = input[..end].to_string();
3644 out.push_str("...<truncated>");
3645 out
3646}
3647
3648fn build_post_tool_final_narrative_prompt(tool_outputs: &[String]) -> String {
3649 format!(
3650 "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.",
3651 summarize_tool_outputs(tool_outputs)
3652 )
3653}
3654
3655fn provider_error_code(error_text: &str) -> &'static str {
3656 let lower = error_text.to_lowercase();
3657 if lower.contains("invalid_function_parameters")
3658 || lower.contains("array schema missing items")
3659 || lower.contains("tool schema")
3660 {
3661 return "TOOL_SCHEMA_INVALID";
3662 }
3663 if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
3664 {
3665 return "RATE_LIMIT_EXCEEDED";
3666 }
3667 if lower.contains("context length")
3668 || lower.contains("max tokens")
3669 || lower.contains("token limit")
3670 {
3671 return "CONTEXT_LENGTH_EXCEEDED";
3672 }
3673 if lower.contains("unauthorized")
3674 || lower.contains("authentication")
3675 || lower.contains("401")
3676 || lower.contains("403")
3677 {
3678 return "AUTHENTICATION_ERROR";
3679 }
3680 if lower.contains("timeout") || lower.contains("timed out") {
3681 return "TIMEOUT";
3682 }
3683 if lower.contains("server error")
3684 || lower.contains("500")
3685 || lower.contains("502")
3686 || lower.contains("503")
3687 || lower.contains("504")
3688 {
3689 return "PROVIDER_SERVER_ERROR";
3690 }
3691 "PROVIDER_REQUEST_FAILED"
3692}
3693
3694fn normalize_tool_name(name: &str) -> String {
3695 let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
3696 for prefix in [
3697 "default_api:",
3698 "default_api.",
3699 "functions.",
3700 "function.",
3701 "tools.",
3702 "tool.",
3703 "builtin:",
3704 "builtin.",
3705 ] {
3706 if let Some(rest) = normalized.strip_prefix(prefix) {
3707 let trimmed = rest.trim();
3708 if !trimmed.is_empty() {
3709 normalized = trimmed.to_string();
3710 break;
3711 }
3712 }
3713 }
3714 match normalized.as_str() {
3715 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
3716 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
3717 other => other.to_string(),
3718 }
3719}
3720
3721fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
3722 let mut parts = tool_name.split('.');
3723 let prefix = parts.next()?;
3724 if prefix != "mcp" {
3725 return None;
3726 }
3727 parts.next().filter(|server| !server.is_empty())
3728}
3729
3730fn requires_web_research_prompt(input: &str) -> bool {
3731 let lower = input.to_ascii_lowercase();
3732 [
3733 "research",
3734 "top news",
3735 "today's news",
3736 "todays news",
3737 "with links",
3738 "latest headlines",
3739 "current events",
3740 ]
3741 .iter()
3742 .any(|needle| lower.contains(needle))
3743}
3744
3745fn requires_email_delivery_prompt(input: &str) -> bool {
3746 let lower = input.to_ascii_lowercase();
3747 (lower.contains("send") && lower.contains("email"))
3748 || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
3749 || lower.contains("email to")
3750}
3751
3752fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
3753 schemas.iter().any(|schema| {
3754 let name = normalize_tool_name(&schema.name);
3755 name == "websearch" || name == "webfetch" || name == "webfetch_html"
3756 })
3757}
3758
3759fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
3760 schemas
3761 .iter()
3762 .map(|schema| normalize_tool_name(&schema.name))
3763 .any(|name| tool_name_looks_like_email_action(&name))
3764}
3765
3766fn tool_name_looks_like_email_action(name: &str) -> bool {
3767 let normalized = normalize_tool_name(name);
3768 if normalized.starts_with("mcp.") {
3769 return normalized.contains("gmail")
3770 || normalized.contains("mail")
3771 || normalized.contains("email");
3772 }
3773 normalized.contains("mail") || normalized.contains("email")
3774}
3775
3776fn completion_claims_email_sent(text: &str) -> bool {
3777 let lower = text.to_ascii_lowercase();
3778 let has_email_marker = lower.contains("email status")
3779 || lower.contains("emailed")
3780 || lower.contains("email sent")
3781 || lower.contains("sent to");
3782 has_email_marker
3783 && (lower.contains("sent")
3784 || lower.contains("delivered")
3785 || lower.contains("has been sent"))
3786}
3787
3788fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
3789 let Some(obj) = args.as_object() else {
3790 return Vec::new();
3791 };
3792 let mcp_path_keys: &[&str] = &[
3794 "path",
3795 "file_path",
3796 "filePath",
3797 "filepath",
3798 "filename",
3799 "directory",
3800 "dir",
3801 "cwd",
3802 "target",
3803 "source",
3804 "dest",
3805 "destination",
3806 ];
3807 let keys: &[&str] = if tool.starts_with("mcp.") {
3808 mcp_path_keys
3809 } else {
3810 match tool {
3811 "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
3812 "glob" => &["pattern"],
3813 "lsp" => &["filePath", "path"],
3814 "bash" => &["cwd"],
3815 "apply_patch" => &[],
3816 _ => &["path", "cwd"],
3817 }
3818 };
3819 keys.iter()
3820 .filter_map(|key| obj.get(*key))
3821 .filter_map(|value| value.as_str())
3822 .filter(|s| {
3823 let t = s.trim();
3824 !t.is_empty()
3826 && (t.starts_with('/')
3827 || t.starts_with('.')
3828 || t.starts_with('~')
3829 || t.contains('/'))
3830 })
3831 .map(ToString::to_string)
3832 .collect()
3833}
3834
3835fn is_mcp_sandbox_exempt_server(server_name: &str) -> bool {
3839 let Ok(raw) = std::env::var("TANDEM_MCP_SANDBOX_EXEMPT_SERVERS") else {
3840 return false;
3841 };
3842 raw.split(',')
3843 .any(|s| s.trim().eq_ignore_ascii_case(server_name))
3844}
3845
3846fn is_mcp_tool_name(tool_name: &str) -> bool {
3847 let normalized = normalize_tool_name(tool_name);
3848 normalized == "mcp_list" || normalized.starts_with("mcp.")
3849}
3850
3851fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
3852 let target = normalize_tool_name(tool_name);
3853 match agent.tools.as_ref() {
3854 None => true,
3855 Some(list) => {
3856 let normalized = list
3857 .iter()
3858 .map(|t| normalize_tool_name(t))
3859 .collect::<Vec<_>>();
3860 any_policy_matches(&normalized, &target)
3861 }
3862 }
3863}
3864
3865fn enforce_skill_scope(
3866 tool_name: &str,
3867 args: Value,
3868 equipped_skills: Option<&[String]>,
3869) -> Result<Value, String> {
3870 if normalize_tool_name(tool_name) != "skill" {
3871 return Ok(args);
3872 }
3873 let Some(configured) = equipped_skills else {
3874 return Ok(args);
3875 };
3876
3877 let mut allowed = configured
3878 .iter()
3879 .map(|s| s.trim().to_string())
3880 .filter(|s| !s.is_empty())
3881 .collect::<Vec<_>>();
3882 if allowed
3883 .iter()
3884 .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
3885 {
3886 return Ok(args);
3887 }
3888 allowed.sort();
3889 allowed.dedup();
3890 if allowed.is_empty() {
3891 return Err("No skills are equipped for this agent.".to_string());
3892 }
3893
3894 let requested = args
3895 .get("name")
3896 .and_then(|v| v.as_str())
3897 .map(|v| v.trim().to_string())
3898 .unwrap_or_default();
3899 if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
3900 return Err(format!(
3901 "Skill '{}' is not equipped for this agent. Equipped skills: {}",
3902 requested,
3903 allowed.join(", ")
3904 ));
3905 }
3906
3907 let mut out = if let Some(obj) = args.as_object() {
3908 Value::Object(obj.clone())
3909 } else {
3910 json!({})
3911 };
3912 if let Some(obj) = out.as_object_mut() {
3913 obj.insert("allowed_skills".to_string(), json!(allowed));
3914 }
3915 Ok(out)
3916}
3917
3918fn is_read_only_tool(tool_name: &str) -> bool {
3919 matches!(
3920 normalize_tool_name(tool_name).as_str(),
3921 "glob"
3922 | "read"
3923 | "grep"
3924 | "search"
3925 | "codesearch"
3926 | "list"
3927 | "ls"
3928 | "lsp"
3929 | "websearch"
3930 | "webfetch"
3931 | "webfetch_html"
3932 )
3933}
3934
3935fn is_workspace_write_tool(tool_name: &str) -> bool {
3936 matches!(
3937 normalize_tool_name(tool_name).as_str(),
3938 "write" | "edit" | "apply_patch"
3939 )
3940}
3941
3942fn should_start_prewrite_repair_before_first_write(
3943 repair_on_unmet_requirements: bool,
3944 productive_write_tool_calls_total: usize,
3945 prewrite_satisfied: bool,
3946 code_workflow_requested: bool,
3947) -> bool {
3948 (repair_on_unmet_requirements || code_workflow_requested)
3949 && productive_write_tool_calls_total == 0
3950 && !prewrite_satisfied
3951}
3952
3953fn is_batch_wrapper_tool_name(name: &str) -> bool {
3954 matches!(
3955 normalize_tool_name(name).as_str(),
3956 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
3957 )
3958}
3959
3960fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
3961 obj.get(key)
3962 .and_then(|v| v.as_str())
3963 .map(str::trim)
3964 .filter(|s| !s.is_empty())
3965}
3966
3967fn nested_non_empty_string_at<'a>(
3968 obj: &'a Map<String, Value>,
3969 parent: &str,
3970 key: &str,
3971) -> Option<&'a str> {
3972 obj.get(parent)
3973 .and_then(|v| v.as_object())
3974 .and_then(|nested| nested.get(key))
3975 .and_then(|v| v.as_str())
3976 .map(str::trim)
3977 .filter(|s| !s.is_empty())
3978}
3979
3980fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
3981 let calls = args
3982 .get("tool_calls")
3983 .and_then(|v| v.as_array())
3984 .cloned()
3985 .unwrap_or_default();
3986 calls
3987 .into_iter()
3988 .filter_map(|call| {
3989 let obj = call.as_object()?;
3990 let tool_raw = non_empty_string_at(obj, "tool")
3991 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3992 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
3993 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
3994 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
3995 let name_raw = non_empty_string_at(obj, "name")
3996 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3997 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
3998 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
3999 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
4000 let effective = match (tool_raw, name_raw) {
4001 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
4002 (Some(t), _) => t,
4003 (None, Some(n)) => n,
4004 (None, None) => return None,
4005 };
4006 let normalized = normalize_tool_name(effective);
4007 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
4008 Some((normalized, call_args))
4009 })
4010 .collect()
4011}
4012
4013fn is_read_only_batch_call(args: &Value) -> bool {
4014 let calls = extract_batch_calls(args);
4015 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
4016}
4017
4018fn batch_tool_signature(args: &Value) -> Option<String> {
4019 let calls = extract_batch_calls(args);
4020 if calls.is_empty() {
4021 return None;
4022 }
4023 let parts = calls
4024 .into_iter()
4025 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
4026 .collect::<Vec<_>>();
4027 Some(format!("batch:{}", parts.join("|")))
4028}
4029
4030fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
4031 let normalized_tool = normalize_tool_name(tool_name);
4032 if normalized_tool == "batch" && is_non_productive_batch_output(output) {
4033 return false;
4034 }
4035 if is_auth_required_tool_output(output) {
4036 return false;
4037 }
4038 if normalized_tool == "glob" {
4039 return true;
4040 }
4041 let Some(result_body) = extract_tool_result_body(output) else {
4042 return false;
4043 };
4044 !is_non_productive_tool_result_body(result_body)
4045}
4046
4047fn is_successful_web_research_output(tool_name: &str, output: &str) -> bool {
4048 if !is_web_research_tool(tool_name) {
4049 return false;
4050 }
4051 let Some(result_body) = extract_tool_result_body(output) else {
4052 return false;
4053 };
4054 if is_non_productive_tool_result_body(result_body) {
4055 return false;
4056 }
4057 let lower = result_body.to_ascii_lowercase();
4058 !(lower.contains("search timed out")
4059 || lower.contains("timed out")
4060 || lower.contains("no results received")
4061 || lower.contains("no search results")
4062 || lower.contains("no relevant results"))
4063}
4064
4065fn extract_tool_result_body(output: &str) -> Option<&str> {
4066 let trimmed = output.trim();
4067 let rest = trimmed.strip_prefix("Tool `")?;
4068 let (_, result_body) = rest.split_once("` result:")?;
4069 Some(result_body.trim())
4070}
4071
4072fn is_non_productive_tool_result_body(output: &str) -> bool {
4073 let trimmed = output.trim();
4074 if trimmed.is_empty() {
4075 return true;
4076 }
4077 let lower = trimmed.to_ascii_lowercase();
4078 lower.starts_with("unknown tool:")
4079 || lower.contains("call skipped")
4080 || lower.contains("guard budget exceeded")
4081 || lower.contains("invalid_function_parameters")
4082 || is_terminal_tool_error_reason(trimmed)
4083}
4084
4085fn is_terminal_tool_error_reason(output: &str) -> bool {
4086 let first_line = output.lines().next().unwrap_or_default().trim();
4087 if first_line.is_empty() {
4088 return false;
4089 }
4090 let normalized = first_line.to_ascii_uppercase();
4091 matches!(
4092 normalized.as_str(),
4093 "TOOL_ARGUMENTS_MISSING"
4094 | "WEBSEARCH_QUERY_MISSING"
4095 | "BASH_COMMAND_MISSING"
4096 | "FILE_PATH_MISSING"
4097 | "WRITE_CONTENT_MISSING"
4098 | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
4099 | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
4100 | "WEBFETCH_URL_MISSING"
4101 | "PACK_BUILDER_PLAN_ID_MISSING"
4102 | "PACK_BUILDER_GOAL_MISSING"
4103 | "PROVIDER_REQUEST_FAILED"
4104 | "AUTHENTICATION_ERROR"
4105 | "CONTEXT_LENGTH_EXCEEDED"
4106 | "RATE_LIMIT_EXCEEDED"
4107 ) || normalized.ends_with("_MISSING")
4108 || normalized.ends_with("_ERROR")
4109}
4110
4111fn is_non_productive_batch_output(output: &str) -> bool {
4112 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
4113 return false;
4114 };
4115 let Some(items) = value.as_array() else {
4116 return false;
4117 };
4118 if items.is_empty() {
4119 return true;
4120 }
4121 items.iter().all(|item| {
4122 let text = item
4123 .get("output")
4124 .and_then(|v| v.as_str())
4125 .map(str::trim)
4126 .unwrap_or_default()
4127 .to_ascii_lowercase();
4128 text.is_empty()
4129 || text.starts_with("unknown tool:")
4130 || text.contains("call skipped")
4131 || text.contains("guard budget exceeded")
4132 })
4133}
4134
4135fn is_auth_required_tool_output(output: &str) -> bool {
4136 let lower = output.to_ascii_lowercase();
4137 (lower.contains("authorization required")
4138 || lower.contains("requires authorization")
4139 || lower.contains("authorization pending"))
4140 && (lower.contains("authorize here") || lower.contains("http"))
4141}
4142
4143#[derive(Debug, Clone)]
4144struct McpAuthRequiredMetadata {
4145 challenge_id: String,
4146 authorization_url: String,
4147 message: String,
4148 server: Option<String>,
4149 pending: bool,
4150 blocked: bool,
4151 retry_after_ms: Option<u64>,
4152}
4153
4154fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
4155 let auth = metadata.get("mcpAuth")?;
4156 if !auth
4157 .get("required")
4158 .and_then(|v| v.as_bool())
4159 .unwrap_or(false)
4160 {
4161 return None;
4162 }
4163 let authorization_url = auth
4164 .get("authorizationUrl")
4165 .and_then(|v| v.as_str())
4166 .map(str::trim)
4167 .filter(|v| !v.is_empty())?
4168 .to_string();
4169 let message = auth
4170 .get("message")
4171 .and_then(|v| v.as_str())
4172 .map(str::trim)
4173 .filter(|v| !v.is_empty())
4174 .unwrap_or("This tool requires authorization before it can run.")
4175 .to_string();
4176 let challenge_id = auth
4177 .get("challengeId")
4178 .and_then(|v| v.as_str())
4179 .map(str::trim)
4180 .filter(|v| !v.is_empty())
4181 .unwrap_or("unknown")
4182 .to_string();
4183 let server = metadata
4184 .get("server")
4185 .and_then(|v| v.as_str())
4186 .map(str::trim)
4187 .filter(|v| !v.is_empty())
4188 .map(ToString::to_string);
4189 let pending = auth
4190 .get("pending")
4191 .and_then(|v| v.as_bool())
4192 .unwrap_or(false);
4193 let blocked = auth
4194 .get("blocked")
4195 .and_then(|v| v.as_bool())
4196 .unwrap_or(false);
4197 let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
4198 Some(McpAuthRequiredMetadata {
4199 challenge_id,
4200 authorization_url,
4201 message,
4202 server,
4203 pending,
4204 blocked,
4205 retry_after_ms,
4206 })
4207}
4208
4209fn extract_mcp_auth_required_from_error_text(
4210 tool_name: &str,
4211 error_text: &str,
4212) -> Option<McpAuthRequiredMetadata> {
4213 let lower = error_text.to_ascii_lowercase();
4214 let auth_hint = lower.contains("authorization")
4215 || lower.contains("oauth")
4216 || lower.contains("invalid oauth token")
4217 || lower.contains("requires authorization");
4218 if !auth_hint {
4219 return None;
4220 }
4221 let authorization_url = find_first_url(error_text)?;
4222 let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
4223 let server = tool_name
4224 .strip_prefix("mcp.")
4225 .and_then(|rest| rest.split('.').next())
4226 .filter(|s| !s.is_empty())
4227 .map(ToString::to_string);
4228 Some(McpAuthRequiredMetadata {
4229 challenge_id,
4230 authorization_url,
4231 message: "This integration requires authorization before this action can run.".to_string(),
4232 server,
4233 pending: false,
4234 blocked: false,
4235 retry_after_ms: None,
4236 })
4237}
4238
4239fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
4240 if outputs.is_empty()
4241 || !outputs
4242 .iter()
4243 .all(|output| is_auth_required_tool_output(output))
4244 {
4245 return None;
4246 }
4247 let mut auth_lines = outputs
4248 .iter()
4249 .filter_map(|output| {
4250 let trimmed = output.trim();
4251 if trimmed.is_empty() {
4252 None
4253 } else {
4254 Some(trimmed.to_string())
4255 }
4256 })
4257 .collect::<Vec<_>>();
4258 auth_lines.sort();
4259 auth_lines.dedup();
4260 if auth_lines.is_empty() {
4261 return None;
4262 }
4263 Some(format!(
4264 "Authorization is required before I can continue with this action.\n\n{}",
4265 auth_lines.join("\n\n")
4266 ))
4267}
4268
4269fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
4270 if outputs.is_empty()
4271 || !outputs
4272 .iter()
4273 .all(|output| is_guard_budget_tool_output(output))
4274 {
4275 return None;
4276 }
4277 let mut lines = outputs
4278 .iter()
4279 .filter_map(|output| {
4280 let trimmed = output.trim();
4281 if trimmed.is_empty() {
4282 None
4283 } else {
4284 Some(trimmed.to_string())
4285 }
4286 })
4287 .collect::<Vec<_>>();
4288 lines.sort();
4289 lines.dedup();
4290 if lines.is_empty() {
4291 return None;
4292 }
4293 Some(format!(
4294 "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.",
4295 lines.join("\n")
4296 ))
4297}
4298
4299fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
4300 if outputs.is_empty()
4301 || !outputs
4302 .iter()
4303 .all(|output| is_duplicate_signature_limit_output(output))
4304 {
4305 return None;
4306 }
4307 let mut lines = outputs
4308 .iter()
4309 .filter_map(|output| {
4310 let trimmed = output.trim();
4311 if trimmed.is_empty() {
4312 None
4313 } else {
4314 Some(trimmed.to_string())
4315 }
4316 })
4317 .collect::<Vec<_>>();
4318 lines.sort();
4319 lines.dedup();
4320 if lines.is_empty() {
4321 return None;
4322 }
4323 Some(format!(
4324 "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.",
4325 lines.join("\n")
4326 ))
4327}
4328
4329const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
4330
4331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4332enum RequiredToolFailureKind {
4333 NoToolCallEmitted,
4334 ToolCallParseFailed,
4335 ToolCallInvalidArgs,
4336 WriteArgsEmptyFromProvider,
4337 WriteArgsUnparseableFromProvider,
4338 ToolCallRejectedByPolicy,
4339 ToolCallExecutedNonProductive,
4340 WriteRequiredNotSatisfied,
4341 PrewriteRequirementsExhausted,
4342}
4343
4344impl RequiredToolFailureKind {
4345 fn code(self) -> &'static str {
4346 match self {
4347 Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
4348 Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
4349 Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
4350 Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
4351 Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
4352 Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
4353 Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
4354 Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
4355 Self::PrewriteRequirementsExhausted => "PREWRITE_REQUIREMENTS_EXHAUSTED",
4356 }
4357 }
4358}
4359
4360fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
4361 format!(
4362 "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
4363 reason.code()
4364 )
4365}
4366
4367#[allow(dead_code)]
4368fn prewrite_requirements_exhausted_completion(
4369 unmet_codes: &[&'static str],
4370 repair_attempt: usize,
4371 repair_attempts_remaining: usize,
4372) -> String {
4373 let unmet = if unmet_codes.is_empty() {
4374 "none".to_string()
4375 } else {
4376 unmet_codes.join(", ")
4377 };
4378 format!(
4379 "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\":{:?}}}",
4380 repair_attempt,
4381 repair_attempts_remaining,
4382 unmet_codes,
4383 )
4384}
4385
4386fn prewrite_repair_event_payload(
4387 repair_attempt: usize,
4388 repair_attempts_remaining: usize,
4389 unmet_codes: &[&'static str],
4390 repair_exhausted: bool,
4391) -> Value {
4392 json!({
4393 "repairAttempt": repair_attempt,
4394 "repairAttemptsRemaining": repair_attempts_remaining,
4395 "unmetRequirements": unmet_codes,
4396 "repairActive": repair_attempt > 0 && !repair_exhausted,
4397 "repairExhausted": repair_exhausted,
4398 })
4399}
4400
4401fn build_required_tool_retry_context(
4402 offered_tool_preview: &str,
4403 previous_reason: RequiredToolFailureKind,
4404) -> String {
4405 let offered = offered_tool_preview.trim();
4406 let available_tools = if offered.is_empty() {
4407 "Use one of the tools offered in this turn before you produce final text.".to_string()
4408 } else {
4409 format!("Use one of these offered tools before you produce final text: {offered}.")
4410 };
4411 let execution_instruction = if previous_reason
4412 == RequiredToolFailureKind::WriteRequiredNotSatisfied
4413 {
4414 "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
4415 } else if is_write_invalid_args_failure_kind(previous_reason) {
4416 "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()
4417 } else {
4418 available_tools
4419 };
4420 format!(
4421 "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
4422 previous_reason.code(),
4423 execution_instruction
4424 )
4425}
4426
4427fn looks_like_code_target_path(path: &str) -> bool {
4428 let trimmed = path.trim();
4429 if trimmed.is_empty() {
4430 return false;
4431 }
4432 let normalized = trimmed.replace('\\', "/");
4433 let file_name = normalized
4434 .rsplit('/')
4435 .next()
4436 .unwrap_or(normalized.as_str())
4437 .to_ascii_lowercase();
4438 if matches!(
4439 file_name.as_str(),
4440 "cargo.toml"
4441 | "cargo.lock"
4442 | "package.json"
4443 | "pnpm-lock.yaml"
4444 | "package-lock.json"
4445 | "yarn.lock"
4446 | "makefile"
4447 | "dockerfile"
4448 | ".gitignore"
4449 | ".editorconfig"
4450 | "tsconfig.json"
4451 | "pyproject.toml"
4452 | "requirements.txt"
4453 ) {
4454 return true;
4455 }
4456 let extension = file_name.rsplit('.').next().unwrap_or_default();
4457 matches!(
4458 extension,
4459 "rs" | "ts"
4460 | "tsx"
4461 | "js"
4462 | "jsx"
4463 | "py"
4464 | "go"
4465 | "java"
4466 | "kt"
4467 | "kts"
4468 | "c"
4469 | "cc"
4470 | "cpp"
4471 | "h"
4472 | "hpp"
4473 | "cs"
4474 | "rb"
4475 | "php"
4476 | "swift"
4477 | "scala"
4478 | "sh"
4479 | "bash"
4480 | "zsh"
4481 | "toml"
4482 | "yaml"
4483 | "yml"
4484 | "json"
4485 )
4486}
4487
4488fn infer_code_workflow_from_text(text: &str) -> bool {
4489 let lowered = text.to_ascii_lowercase();
4490 if lowered.contains("code agent contract")
4491 || lowered.contains("inspect -> patch -> apply -> test -> repair")
4492 || lowered.contains("task kind: `code_change`")
4493 || lowered.contains("task kind: code_change")
4494 || lowered.contains("output contract kind: code_patch")
4495 || lowered.contains("verification expectation:")
4496 || lowered.contains("verification command:")
4497 {
4498 return true;
4499 }
4500 infer_required_output_target_path_from_text(text)
4501 .is_some_and(|path| looks_like_code_target_path(&path))
4502}
4503
4504fn infer_verification_command_from_text(text: &str) -> Option<String> {
4505 for marker in ["Verification expectation:", "verification expectation:"] {
4506 let Some(start) = text.find(marker) else {
4507 continue;
4508 };
4509 let remainder = text[start + marker.len()..].trim_start();
4510 let line = remainder.lines().next().unwrap_or_default().trim();
4511 if line.is_empty() {
4512 continue;
4513 }
4514 let cleaned = line
4515 .trim_matches('`')
4516 .trim_end_matches('.')
4517 .trim()
4518 .to_string();
4519 if !cleaned.is_empty() {
4520 return Some(cleaned);
4521 }
4522 }
4523 None
4524}
4525
4526fn build_required_tool_retry_context_for_task(
4527 offered_tool_preview: &str,
4528 previous_reason: RequiredToolFailureKind,
4529 latest_user_text: &str,
4530) -> String {
4531 let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
4532 if !infer_code_workflow_from_text(latest_user_text) {
4533 return prompt;
4534 }
4535 let output_target = infer_required_output_target_path_from_text(latest_user_text)
4536 .unwrap_or_else(|| "the declared source target".to_string());
4537 let verification = infer_verification_command_from_text(latest_user_text)
4538 .unwrap_or_else(|| "run the declared verification command with `bash`".to_string());
4539 prompt.push(' ');
4540 prompt.push_str(
4541 "This is a code workflow: follow inspect -> patch -> apply -> test -> repair before finalizing.",
4542 );
4543 prompt.push(' ');
4544 prompt.push_str(&format!(
4545 "Patch `{output_target}` using `apply_patch` (or `edit` for local edits); use `write` only when creating a brand-new file."
4546 ));
4547 prompt.push(' ');
4548 prompt.push_str(&format!(
4549 "After patching, run verification with `bash` (`{verification}`). If verification fails, repair the smallest root cause and re-run verification."
4550 ));
4551 prompt
4552}
4553
4554fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
4555 matches!(
4556 reason,
4557 RequiredToolFailureKind::ToolCallInvalidArgs
4558 | RequiredToolFailureKind::WriteArgsEmptyFromProvider
4559 | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
4560 )
4561}
4562
4563fn should_retry_nonproductive_required_tool_cycle(
4564 requested_write_required: bool,
4565 write_tool_attempted_in_cycle: bool,
4566 progress_made_in_cycle: bool,
4567 required_tool_retry_count: usize,
4568) -> bool {
4569 if write_tool_attempted_in_cycle {
4570 return required_tool_retry_count == 0 && !requested_write_required;
4571 }
4572 if progress_made_in_cycle {
4573 return required_tool_retry_count < 2;
4574 }
4575 required_tool_retry_count == 0 && (!requested_write_required || !write_tool_attempted_in_cycle)
4576}
4577
4578fn build_write_required_retry_context(
4579 offered_tool_preview: &str,
4580 previous_reason: RequiredToolFailureKind,
4581 latest_user_text: &str,
4582 prewrite_requirements: &PrewriteRequirements,
4583 workspace_inspection_satisfied: bool,
4584 concrete_read_satisfied: bool,
4585 web_research_satisfied: bool,
4586 successful_web_research_satisfied: bool,
4587) -> String {
4588 let mut prompt = build_required_tool_retry_context_for_task(
4589 offered_tool_preview,
4590 previous_reason,
4591 latest_user_text,
4592 );
4593 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4594 prewrite_requirements,
4595 workspace_inspection_satisfied,
4596 concrete_read_satisfied,
4597 web_research_satisfied,
4598 successful_web_research_satisfied,
4599 );
4600 if !unmet.is_empty() {
4601 prompt.push(' ');
4602 prompt.push_str(&format!(
4603 "Before the final write, you still need to {}.",
4604 unmet.join(" and ")
4605 ));
4606 }
4607 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4608 prompt.push(' ');
4609 prompt.push_str(&format!(
4610 "The required output target for this task is `{path}`. Write or update that file now."
4611 ));
4612 prompt.push(' ');
4613 prompt.push_str(
4614 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4615 );
4616 prompt.push(' ');
4617 prompt.push_str(
4618 "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.",
4619 );
4620 }
4621 prompt
4622}
4623
4624fn build_prewrite_repair_retry_context(
4625 offered_tool_preview: &str,
4626 previous_reason: RequiredToolFailureKind,
4627 latest_user_text: &str,
4628 prewrite_requirements: &PrewriteRequirements,
4629 workspace_inspection_satisfied: bool,
4630 concrete_read_satisfied: bool,
4631 web_research_satisfied: bool,
4632 successful_web_research_satisfied: bool,
4633) -> String {
4634 let mut prompt = build_required_tool_retry_context_for_task(
4635 offered_tool_preview,
4636 previous_reason,
4637 latest_user_text,
4638 );
4639 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4640 prewrite_requirements,
4641 workspace_inspection_satisfied,
4642 concrete_read_satisfied,
4643 web_research_satisfied,
4644 successful_web_research_satisfied,
4645 );
4646 if !unmet.is_empty() {
4647 prompt.push(' ');
4648 prompt.push_str(&format!(
4649 "Before the final write, you still need to {}.",
4650 unmet.join(" and ")
4651 ));
4652 }
4653 let mut repair_notes = Vec::new();
4654 if prewrite_requirements.concrete_read_required && !concrete_read_satisfied {
4655 repair_notes.push(
4656 "This task requires concrete `read` calls on relevant workspace files before you can write the output. Call `read` now on the files you discovered.",
4657 );
4658 }
4659 if prewrite_requirements.successful_web_research_required && !successful_web_research_satisfied
4660 {
4661 repair_notes.push(
4662 "Timed out or empty websearch attempts do not satisfy external-research requirements; call `websearch` with a concrete query now.",
4663 );
4664 }
4665 if !matches!(
4666 prewrite_requirements.coverage_mode,
4667 PrewriteCoverageMode::None
4668 ) {
4669 repair_notes.push(
4670 "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.",
4671 );
4672 }
4673 if !repair_notes.is_empty() {
4674 prompt.push(' ');
4675 prompt.push_str("Do not skip this step. ");
4676 prompt.push_str(&repair_notes.join(" "));
4677 }
4678 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4679 if infer_code_workflow_from_text(latest_user_text) {
4680 prompt.push(' ');
4681 prompt.push_str(&format!(
4682 "Use `read` to confirm the concrete code context, then patch `{path}` with `apply_patch` or `edit` and run verification before finalizing."
4683 ));
4684 prompt.push(' ');
4685 prompt.push_str(
4686 "Do not return a prose-only completion before patch + verification steps run.",
4687 );
4688 } else {
4689 prompt.push(' ');
4690 prompt.push_str(&format!(
4691 "Use `read` and `websearch` now to gather evidence, then write the artifact to `{path}`."
4692 ));
4693 prompt.push(' ');
4694 prompt.push_str(&format!(
4695 "Do not declare the output blocked while `read` and `websearch` remain available. Call them now."
4696 ));
4697 }
4698 }
4699 prompt
4700}
4701
4702fn build_prewrite_waived_write_context(
4703 latest_user_text: &str,
4704 unmet_codes: &[&'static str],
4705) -> String {
4706 let mut prompt = String::from(
4707 "Research prerequisites could not be fully satisfied after multiple repair attempts. \
4708 You must still write the output file using whatever information you have gathered so far. \
4709 Do not write a blocked or placeholder file. Write the best possible output with the evidence available.",
4710 );
4711 if !unmet_codes.is_empty() {
4712 prompt.push_str(&format!(
4713 " (Unmet prerequisites waived: {}.)",
4714 unmet_codes.join(", ")
4715 ));
4716 }
4717 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4718 prompt.push_str(&format!(
4719 " The required output file is `{path}`. Call the `write` tool now to create it."
4720 ));
4721 }
4722 prompt
4723}
4724
4725fn build_empty_completion_retry_context(
4726 offered_tool_preview: &str,
4727 latest_user_text: &str,
4728 prewrite_requirements: &PrewriteRequirements,
4729 workspace_inspection_satisfied: bool,
4730 concrete_read_satisfied: bool,
4731 web_research_satisfied: bool,
4732 successful_web_research_satisfied: bool,
4733) -> String {
4734 let mut prompt = String::from(
4735 "You already used tools in this session, but returned no final output. Do not stop now.",
4736 );
4737 let unmet = describe_unmet_prewrite_requirements_for_prompt(
4738 prewrite_requirements,
4739 workspace_inspection_satisfied,
4740 concrete_read_satisfied,
4741 web_research_satisfied,
4742 successful_web_research_satisfied,
4743 );
4744 if !unmet.is_empty() {
4745 prompt.push(' ');
4746 prompt.push_str(&format!(
4747 "You still need to {} before the final write.",
4748 unmet.join(" and ")
4749 ));
4750 prompt.push(' ');
4751 prompt.push_str(&build_required_tool_retry_context_for_task(
4752 offered_tool_preview,
4753 RequiredToolFailureKind::WriteRequiredNotSatisfied,
4754 latest_user_text,
4755 ));
4756 }
4757 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4758 prompt.push(' ');
4759 prompt.push_str(&format!("The required output target is `{path}`."));
4760 if unmet.is_empty() {
4761 prompt.push(' ');
4762 prompt.push_str(
4763 "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4764 );
4765 } else {
4766 prompt.push(' ');
4767 prompt.push_str(
4768 "After completing the missing requirement, immediately write that file instead of ending with prose.",
4769 );
4770 }
4771 }
4772 prompt
4773}
4774
4775fn synthesize_artifact_write_completion_from_tool_state(
4776 latest_user_text: &str,
4777 prewrite_satisfied: bool,
4778 prewrite_gate_waived: bool,
4779) -> String {
4780 let target = infer_required_output_target_path_from_text(latest_user_text)
4781 .unwrap_or_else(|| "the declared output artifact".to_string());
4782 let mut completion = format!("Completed the requested tool actions and wrote `{target}`.");
4783 if prewrite_gate_waived && !prewrite_satisfied {
4784 completion.push_str(
4785 "\n\nRuntime validation will decide whether the artifact can be accepted because some evidence requirements were waived in-run."
4786 );
4787 } else {
4788 completion
4789 .push_str("\n\nRuntime validation will verify the artifact and finalize node status.");
4790 }
4791 completion.push_str("\n\n{\"status\":\"completed\"}");
4792 completion
4793}
4794
4795fn should_generate_post_tool_final_narrative(
4796 requested_tool_mode: ToolMode,
4797 productive_tool_calls_total: usize,
4798) -> bool {
4799 !matches!(requested_tool_mode, ToolMode::Required) || productive_tool_calls_total > 0
4800}
4801
4802fn is_workspace_inspection_tool(tool_name: &str) -> bool {
4803 matches!(
4804 normalize_tool_name(tool_name).as_str(),
4805 "glob" | "read" | "grep" | "search" | "codesearch" | "ls" | "list"
4806 )
4807}
4808
4809fn is_web_research_tool(tool_name: &str) -> bool {
4810 matches!(
4811 normalize_tool_name(tool_name).as_str(),
4812 "websearch" | "webfetch" | "webfetch_html"
4813 )
4814}
4815
4816fn tool_matches_unmet_prewrite_repair_requirement(tool_name: &str, unmet_codes: &[&str]) -> bool {
4817 if is_workspace_write_tool(tool_name) {
4818 return false;
4819 }
4820 let normalized = normalize_tool_name(tool_name);
4821 let needs_workspace_inspection = unmet_codes.contains(&"workspace_inspection_required");
4822 let needs_concrete_read =
4823 unmet_codes.contains(&"concrete_read_required") || unmet_codes.contains(&"coverage_mode");
4824 let needs_web_research = unmet_codes.iter().any(|code| {
4825 matches!(
4826 *code,
4827 "web_research_required" | "successful_web_research_required"
4828 )
4829 });
4830 (needs_concrete_read && (normalized == "read" || normalized == "glob"))
4831 || (needs_workspace_inspection && is_workspace_inspection_tool(&normalized))
4832 || (needs_web_research && is_web_research_tool(&normalized))
4833}
4834
4835fn invalid_tool_args_retry_max_attempts() -> usize {
4836 2
4837}
4838
4839pub fn prewrite_repair_retry_max_attempts() -> usize {
4840 5
4841}
4842
4843pub(super) fn prewrite_gate_strict_mode() -> bool {
4847 std::env::var("TANDEM_PREWRITE_GATE_STRICT")
4848 .ok()
4849 .map(|v| {
4850 matches!(
4851 v.trim().to_ascii_lowercase().as_str(),
4852 "1" | "true" | "yes" | "on"
4853 )
4854 })
4855 .unwrap_or(false)
4856}
4857
4858fn build_invalid_tool_args_retry_context_from_outputs(
4859 outputs: &[String],
4860 previous_attempts: usize,
4861) -> Option<String> {
4862 if outputs
4863 .iter()
4864 .any(|output| output.contains("BASH_COMMAND_MISSING"))
4865 {
4866 let emphasis = if previous_attempts > 0 {
4867 "You already tried `bash` without a valid command. Do not repeat an empty bash call."
4868 } else {
4869 "If you use `bash`, include a full non-empty command string."
4870 };
4871 return Some(format!(
4872 "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."
4873 ));
4874 }
4875 if outputs
4876 .iter()
4877 .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
4878 {
4879 return Some(
4880 "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(),
4881 );
4882 }
4883 if outputs
4884 .iter()
4885 .any(|output| output.contains("WEBFETCH_URL_MISSING"))
4886 {
4887 return Some(
4888 "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
4889 );
4890 }
4891 if outputs
4892 .iter()
4893 .any(|output| output.contains("FILE_PATH_MISSING"))
4894 {
4895 return Some(
4896 "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(),
4897 );
4898 }
4899 if outputs
4900 .iter()
4901 .any(|output| output.contains("WRITE_CONTENT_MISSING"))
4902 {
4903 return Some(
4904 "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(),
4905 );
4906 }
4907 None
4908}
4909
4910fn looks_like_unparsed_tool_payload(output: &str) -> bool {
4911 let trimmed = output.trim();
4912 if trimmed.is_empty() {
4913 return false;
4914 }
4915 let lower = trimmed.to_ascii_lowercase();
4916 lower.contains("\"tool_calls\"")
4917 || lower.contains("\"function_call\"")
4918 || lower.contains("\"function\":{")
4919 || lower.contains("\"type\":\"tool_call\"")
4920 || lower.contains("\"type\":\"function_call\"")
4921 || lower.contains("\"type\":\"tool_use\"")
4922}
4923
4924fn is_policy_rejection_output(output: &str) -> bool {
4925 let lower = output.trim().to_ascii_lowercase();
4926 lower.contains("call skipped")
4927 || lower.contains("authorization required")
4928 || lower.contains("not allowed")
4929 || lower.contains("permission denied")
4930}
4931
4932fn classify_required_tool_failure(
4933 outputs: &[String],
4934 saw_tool_call_candidate: bool,
4935 accepted_tool_calls: usize,
4936 parse_failed: bool,
4937 rejected_by_policy: bool,
4938) -> RequiredToolFailureKind {
4939 if parse_failed {
4940 return RequiredToolFailureKind::ToolCallParseFailed;
4941 }
4942 if !saw_tool_call_candidate {
4943 return RequiredToolFailureKind::NoToolCallEmitted;
4944 }
4945 if accepted_tool_calls == 0 || rejected_by_policy {
4946 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4947 }
4948 if outputs
4949 .iter()
4950 .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
4951 {
4952 return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
4953 }
4954 if outputs
4955 .iter()
4956 .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
4957 {
4958 return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
4959 }
4960 if outputs
4961 .iter()
4962 .any(|output| is_terminal_tool_error_reason(output))
4963 {
4964 return RequiredToolFailureKind::ToolCallInvalidArgs;
4965 }
4966 if outputs
4967 .iter()
4968 .any(|output| is_policy_rejection_output(output))
4969 {
4970 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4971 }
4972 RequiredToolFailureKind::ToolCallExecutedNonProductive
4973}
4974
4975fn find_first_url(text: &str) -> Option<String> {
4976 text.split_whitespace().find_map(|token| {
4977 if token.starts_with("https://") || token.starts_with("http://") {
4978 let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
4979 if cleaned.len() > "https://".len() {
4980 return Some(cleaned.to_string());
4981 }
4982 }
4983 None
4984 })
4985}
4986
4987fn max_tool_iterations() -> usize {
4988 let default_iterations = 25usize;
4989 std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
4990 .ok()
4991 .and_then(|raw| raw.trim().parse::<usize>().ok())
4992 .filter(|value| *value > 0)
4993 .unwrap_or(default_iterations)
4994}
4995
4996fn strict_write_retry_max_attempts() -> usize {
4997 std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
4998 .ok()
4999 .and_then(|raw| raw.trim().parse::<usize>().ok())
5000 .filter(|value| *value > 0)
5001 .unwrap_or(3)
5002}
5003
5004fn provider_stream_connect_timeout_ms() -> usize {
5005 std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
5006 .ok()
5007 .and_then(|raw| raw.trim().parse::<usize>().ok())
5008 .filter(|value| *value > 0)
5009 .unwrap_or(90_000)
5010}
5011
5012fn provider_stream_idle_timeout_ms() -> usize {
5013 std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
5014 .ok()
5015 .and_then(|raw| raw.trim().parse::<usize>().ok())
5016 .filter(|value| *value > 0)
5017 .unwrap_or(90_000)
5018}
5019
5020fn prompt_context_hook_timeout_ms() -> usize {
5021 std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
5022 .ok()
5023 .and_then(|raw| raw.trim().parse::<usize>().ok())
5024 .filter(|value| *value > 0)
5025 .unwrap_or(5_000)
5026}
5027
5028fn permission_wait_timeout_ms() -> usize {
5029 std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
5030 .ok()
5031 .and_then(|raw| raw.trim().parse::<usize>().ok())
5032 .filter(|value| *value > 0)
5033 .unwrap_or(15_000)
5034}
5035
5036fn tool_exec_timeout_ms() -> usize {
5037 std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
5038 .ok()
5039 .and_then(|raw| raw.trim().parse::<usize>().ok())
5040 .filter(|value| *value > 0)
5041 .unwrap_or(45_000)
5042}
5043
5044fn is_guard_budget_tool_output(output: &str) -> bool {
5045 output
5046 .to_ascii_lowercase()
5047 .contains("per-run guard budget exceeded")
5048}
5049
5050fn is_duplicate_signature_limit_output(output: &str) -> bool {
5051 output
5052 .to_ascii_lowercase()
5053 .contains("duplicate call signature retry limit reached")
5054}
5055
5056fn is_sensitive_path_candidate(path: &Path) -> bool {
5057 let lowered = path.to_string_lossy().to_ascii_lowercase();
5058
5059 if lowered.contains("/.ssh/") || lowered.ends_with("/.ssh") {
5061 return true;
5062 }
5063 if lowered.contains("/.gnupg/") || lowered.ends_with("/.gnupg") {
5064 return true;
5065 }
5066
5067 if lowered.contains("/.aws/credentials")
5069 || lowered.contains("/.config/gcloud/")
5070 || lowered.contains("/.docker/config.json")
5071 || lowered.contains("/.kube/config")
5072 || lowered.contains("/.git-credentials")
5073 {
5074 return true;
5075 }
5076
5077 if lowered.ends_with("/.npmrc") || lowered.ends_with("/.netrc") || lowered.ends_with("/.pypirc")
5079 {
5080 return true;
5081 }
5082
5083 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
5085 let n = name.to_ascii_lowercase();
5086 if n == ".env"
5088 || n.starts_with(".env.") && !n.ends_with(".example") && !n.ends_with(".sample")
5089 {
5090 return true;
5091 }
5092 if n.starts_with("id_rsa")
5094 || n.starts_with("id_ed25519")
5095 || n.starts_with("id_ecdsa")
5096 || n.starts_with("id_dsa")
5097 {
5098 return true;
5099 }
5100 }
5101
5102 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
5105 let ext_lower = ext.to_ascii_lowercase();
5106 if matches!(
5107 ext_lower.as_str(),
5108 "pem" | "p12" | "pfx" | "key" | "keystore" | "jks"
5109 ) {
5110 return true;
5111 }
5112 }
5113
5114 false
5115}
5116
5117fn shell_command_targets_sensitive_path(command: &str) -> bool {
5118 let lower = command.to_ascii_lowercase();
5119 let patterns = [
5120 "/.ssh/",
5121 "/.gnupg/",
5122 "/.aws/credentials",
5123 "/.config/gcloud/",
5124 "/.docker/config.json",
5125 "/.kube/config",
5126 "/.git-credentials",
5127 "id_rsa",
5128 "id_ed25519",
5129 "id_ecdsa",
5130 "id_dsa",
5131 ".npmrc",
5132 ".netrc",
5133 ".pypirc",
5134 ];
5135 if patterns.iter().any(|p| lower.contains(p)) {
5137 return true;
5138 }
5139 if let Some(pos) = lower.find(".env") {
5141 let after = &lower[pos + 4..];
5142 if after.is_empty() || after.starts_with(' ') || after.starts_with('/') {
5143 return true;
5144 }
5145 }
5146 false
5147}
5148
5149#[derive(Debug, Clone)]
5150struct NormalizedToolArgs {
5151 args: Value,
5152 args_source: String,
5153 args_integrity: String,
5154 raw_args_state: RawToolArgsState,
5155 query: Option<String>,
5156 missing_terminal: bool,
5157 missing_terminal_reason: Option<String>,
5158}
5159
5160#[derive(Debug, Clone)]
5161struct ParsedToolCall {
5162 tool: String,
5163 args: Value,
5164 call_id: Option<String>,
5165}
5166
5167#[cfg(test)]
5168fn normalize_tool_args(
5169 tool_name: &str,
5170 raw_args: Value,
5171 latest_user_text: &str,
5172 latest_assistant_context: &str,
5173) -> NormalizedToolArgs {
5174 normalize_tool_args_with_mode(
5175 tool_name,
5176 raw_args,
5177 latest_user_text,
5178 latest_assistant_context,
5179 WritePathRecoveryMode::Heuristic,
5180 )
5181}
5182
5183fn normalize_tool_args_with_mode(
5184 tool_name: &str,
5185 raw_args: Value,
5186 latest_user_text: &str,
5187 latest_assistant_context: &str,
5188 write_path_recovery_mode: WritePathRecoveryMode,
5189) -> NormalizedToolArgs {
5190 let normalized_tool = normalize_tool_name(tool_name);
5191 let original_args = raw_args.clone();
5192 let mut args = raw_args;
5193 let mut args_source = if args.is_string() {
5194 "provider_string".to_string()
5195 } else {
5196 "provider_json".to_string()
5197 };
5198 let mut args_integrity = "ok".to_string();
5199 let raw_args_state = classify_raw_tool_args_state(&args);
5200 let mut query = None;
5201 let mut missing_terminal = false;
5202 let mut missing_terminal_reason = None;
5203
5204 if normalized_tool == "websearch" {
5205 if let Some(found) = extract_websearch_query(&args) {
5206 query = Some(found);
5207 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
5208 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
5209 args_source = "inferred_from_user".to_string();
5210 args_integrity = "recovered".to_string();
5211 query = Some(inferred);
5212 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
5213 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
5214 args_source = "recovered_from_context".to_string();
5215 args_integrity = "recovered".to_string();
5216 query = Some(recovered);
5217 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
5218 } else {
5219 args_source = "missing".to_string();
5220 args_integrity = "empty".to_string();
5221 missing_terminal = true;
5222 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
5223 }
5224 } else if tool_name_requires_query_arg(&normalized_tool) {
5225 if let Some(found) = extract_query_arg(&args) {
5226 query = Some(found);
5227 args = set_query_arg(args, query.clone(), "tool_args");
5228 } else if let Some(inferred) = infer_query_from_text(latest_user_text) {
5229 args_source = "inferred_from_user".to_string();
5230 args_integrity = "recovered".to_string();
5231 query = Some(inferred);
5232 args = set_query_arg(args, query.clone(), "inferred_from_user");
5233 } else if let Some(recovered) = infer_query_from_text(latest_assistant_context) {
5234 args_source = "recovered_from_context".to_string();
5235 args_integrity = "recovered".to_string();
5236 query = Some(recovered);
5237 args = set_query_arg(args, query.clone(), "recovered_from_context");
5238 } else {
5239 args_source = "missing".to_string();
5240 args_integrity = "empty".to_string();
5241 missing_terminal = true;
5242 missing_terminal_reason = Some("QUERY_MISSING".to_string());
5243 }
5244 } else if tool_name_requires_doc_path_arg(&normalized_tool) {
5245 if let Some(path) = extract_doc_path_arg(&args) {
5246 args = set_doc_path_arg(args, path);
5247 } else if let Some(inferred) = infer_doc_path_from_text(latest_user_text) {
5248 args_source = "inferred_from_user".to_string();
5249 args_integrity = "recovered".to_string();
5250 args = set_doc_path_arg(args, inferred);
5251 } else if let Some(recovered) = infer_doc_path_from_text(latest_assistant_context) {
5252 args_source = "recovered_from_context".to_string();
5253 args_integrity = "recovered".to_string();
5254 args = set_doc_path_arg(args, recovered);
5255 } else {
5256 args_source = "missing".to_string();
5257 args_integrity = "empty".to_string();
5258 missing_terminal = true;
5259 missing_terminal_reason = Some("DOC_PATH_MISSING".to_string());
5260 }
5261 } else if is_shell_tool_name(&normalized_tool) {
5262 if let Some(command) = extract_shell_command(&args) {
5263 args = set_shell_command(args, command);
5264 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
5265 args_source = "inferred_from_context".to_string();
5266 args_integrity = "recovered".to_string();
5267 args = set_shell_command(args, inferred);
5268 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
5269 args_source = "inferred_from_user".to_string();
5270 args_integrity = "recovered".to_string();
5271 args = set_shell_command(args, inferred);
5272 } else {
5273 args_source = "missing".to_string();
5274 args_integrity = "empty".to_string();
5275 missing_terminal = true;
5276 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
5277 }
5278 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5279 if let Some(path) = extract_file_path_arg(&args) {
5280 args = set_file_path_arg(args, path);
5281 } else if normalized_tool == "write" || normalized_tool == "edit" {
5282 let model_explicit_path_value = args
5290 .as_object()
5291 .and_then(|obj| obj.get("path"))
5292 .and_then(Value::as_str)
5293 .map(str::trim)
5294 .filter(|p| !p.is_empty());
5295 let path_is_trivial_placeholder = model_explicit_path_value
5296 .is_some_and(|p| matches!(p, "./" | "." | ".." | "/" | "~"));
5297 let model_explicitly_set_nontrivial_path = model_explicit_path_value
5298 .is_some_and(|p| p.len() > 2)
5299 && !path_is_trivial_placeholder;
5300 if model_explicitly_set_nontrivial_path {
5301 args_source = "rejected".to_string();
5302 args_integrity = "rejected_path".to_string();
5303 missing_terminal = true;
5304 missing_terminal_reason = Some("WRITE_PATH_REJECTED".to_string());
5305 } else if let Some(inferred) =
5306 infer_required_output_target_path_from_text(latest_user_text).or_else(|| {
5307 infer_required_output_target_path_from_text(latest_assistant_context)
5308 })
5309 {
5310 args_source = "recovered_from_context".to_string();
5311 args_integrity = "recovered".to_string();
5312 args = set_file_path_arg(args, inferred);
5313 } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
5314 if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
5315 args_source = "inferred_from_user".to_string();
5316 args_integrity = "recovered".to_string();
5317 args = set_file_path_arg(args, inferred);
5318 } else {
5319 args_source = "missing".to_string();
5320 args_integrity = "empty".to_string();
5321 missing_terminal = true;
5322 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5323 }
5324 } else {
5325 args_source = "missing".to_string();
5326 args_integrity = "empty".to_string();
5327 missing_terminal = true;
5328 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5329 }
5330 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
5331 args_source = "inferred_from_user".to_string();
5332 args_integrity = "recovered".to_string();
5333 args = set_file_path_arg(args, inferred);
5334 } else {
5335 args_source = "missing".to_string();
5336 args_integrity = "empty".to_string();
5337 missing_terminal = true;
5338 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5339 }
5340
5341 if !missing_terminal && normalized_tool == "write" {
5342 if let Some(content) = extract_write_content_arg(&args) {
5343 args = set_write_content_arg(args, content);
5344 } else if let Some(recovered) =
5345 infer_write_content_from_assistant_context(latest_assistant_context)
5346 {
5347 args_source = "recovered_from_context".to_string();
5348 args_integrity = "recovered".to_string();
5349 args = set_write_content_arg(args, recovered);
5350 } else {
5351 args_source = "missing".to_string();
5352 args_integrity = "empty".to_string();
5353 missing_terminal = true;
5354 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
5355 }
5356 }
5357 } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
5358 if let Some(url) = extract_webfetch_url_arg(&args) {
5359 args = set_webfetch_url_arg(args, url);
5360 } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
5361 args_source = "inferred_from_context".to_string();
5362 args_integrity = "recovered".to_string();
5363 args = set_webfetch_url_arg(args, inferred);
5364 } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
5365 args_source = "inferred_from_user".to_string();
5366 args_integrity = "recovered".to_string();
5367 args = set_webfetch_url_arg(args, inferred);
5368 } else {
5369 args_source = "missing".to_string();
5370 args_integrity = "empty".to_string();
5371 missing_terminal = true;
5372 missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
5373 }
5374 } else if tool_name_requires_task_arg(&normalized_tool) {
5375 if let Some(task) = extract_task_arg(&args) {
5376 args = set_task_arg(args, task);
5377 } else if let Some(inferred) = infer_task_from_text(latest_user_text) {
5378 args_source = "inferred_from_user".to_string();
5379 args_integrity = "recovered".to_string();
5380 args = set_task_arg(args, inferred);
5381 } else if let Some(recovered) = infer_task_from_text(latest_assistant_context) {
5382 args_source = "recovered_from_context".to_string();
5383 args_integrity = "recovered".to_string();
5384 args = set_task_arg(args, recovered);
5385 } else {
5386 args_source = "missing".to_string();
5387 args_integrity = "empty".to_string();
5388 missing_terminal = true;
5389 missing_terminal_reason = Some("TASK_MISSING".to_string());
5390 }
5391 } else if normalized_tool == "pack_builder" {
5392 let mode = extract_pack_builder_mode_arg(&args);
5393 let plan_id = extract_pack_builder_plan_id_arg(&args);
5394 if mode.as_deref() == Some("apply") && plan_id.is_none() {
5395 if let Some(inferred_plan) =
5396 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5397 {
5398 args_source = "recovered_from_context".to_string();
5399 args_integrity = "recovered".to_string();
5400 args = set_pack_builder_apply_args(args, inferred_plan);
5401 } else {
5402 args_source = "missing".to_string();
5403 args_integrity = "empty".to_string();
5404 missing_terminal = true;
5405 missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
5406 }
5407 } else if mode.as_deref() == Some("apply") {
5408 args = ensure_pack_builder_default_mode(args);
5409 } else if let Some(inferred_plan) =
5410 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5411 {
5412 args_source = "recovered_from_context".to_string();
5413 args_integrity = "recovered".to_string();
5414 args = set_pack_builder_apply_args(args, inferred_plan);
5415 } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
5416 args = set_pack_builder_goal_arg(args, goal);
5417 } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
5418 args_source = "inferred_from_user".to_string();
5419 args_integrity = "recovered".to_string();
5420 args = set_pack_builder_goal_arg(args, inferred);
5421 } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
5422 {
5423 args_source = "recovered_from_context".to_string();
5424 args_integrity = "recovered".to_string();
5425 args = set_pack_builder_goal_arg(args, recovered);
5426 } else {
5427 args_source = "missing".to_string();
5428 args_integrity = "empty".to_string();
5429 missing_terminal = true;
5430 missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
5431 }
5432 args = ensure_pack_builder_default_mode(args);
5433 } else if is_email_delivery_tool_name(&normalized_tool) {
5434 let sanitized = sanitize_email_attachment_args(args);
5435 if sanitized != original_args {
5436 args_source = "sanitized_attachment".to_string();
5437 args_integrity = "recovered".to_string();
5438 }
5439 args = sanitized;
5440 }
5441
5442 NormalizedToolArgs {
5443 args,
5444 args_source,
5445 args_integrity,
5446 raw_args_state,
5447 query,
5448 missing_terminal,
5449 missing_terminal_reason,
5450 }
5451}
5452
5453fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
5454 match raw_args {
5455 Value::Null => RawToolArgsState::Empty,
5456 Value::Object(obj) => {
5457 if obj.is_empty() {
5458 RawToolArgsState::Empty
5459 } else {
5460 RawToolArgsState::Present
5461 }
5462 }
5463 Value::Array(items) => {
5464 if items.is_empty() {
5465 RawToolArgsState::Empty
5466 } else {
5467 RawToolArgsState::Present
5468 }
5469 }
5470 Value::String(raw) => {
5471 let trimmed = raw.trim();
5472 if trimmed.is_empty() {
5473 return RawToolArgsState::Empty;
5474 }
5475 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5476 return classify_raw_tool_args_state(&parsed);
5477 }
5478 if parse_function_style_args(trimmed).is_empty() {
5479 return RawToolArgsState::Unparseable;
5480 }
5481 RawToolArgsState::Present
5482 }
5483 _ => RawToolArgsState::Present,
5484 }
5485}
5486
5487fn args_missing_or_empty(args: &Value) -> bool {
5488 match args {
5489 Value::Null => true,
5490 Value::Object(obj) => obj.is_empty(),
5491 Value::Array(items) => items.is_empty(),
5492 Value::String(raw) => raw.trim().is_empty(),
5493 _ => false,
5494 }
5495}
5496
5497fn persisted_failed_tool_args(raw_args: &Value, normalized_args: &Value) -> Value {
5498 if args_missing_or_empty(raw_args) && !args_missing_or_empty(normalized_args) {
5499 normalized_args.clone()
5500 } else {
5501 raw_args.clone()
5502 }
5503}
5504
5505fn provider_specific_write_reason(
5506 tool: &str,
5507 missing_reason: &str,
5508 raw_args_state: RawToolArgsState,
5509) -> Option<String> {
5510 if tool != "write"
5511 || !matches!(
5512 missing_reason,
5513 "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
5514 )
5515 {
5516 return None;
5517 }
5518 match raw_args_state {
5519 RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
5520 RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
5521 RawToolArgsState::Present => None,
5522 }
5523}
5524
5525fn is_shell_tool_name(tool_name: &str) -> bool {
5526 matches!(
5527 tool_name.trim().to_ascii_lowercase().as_str(),
5528 "bash" | "shell" | "powershell" | "cmd"
5529 )
5530}
5531
5532fn email_tool_name_tokens(tool_name: &str) -> Vec<String> {
5533 tool_name
5534 .trim()
5535 .to_ascii_lowercase()
5536 .chars()
5537 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
5538 .collect::<String>()
5539 .split_whitespace()
5540 .map(str::to_string)
5541 .collect::<Vec<_>>()
5542}
5543
5544fn email_tool_name_compact(tool_name: &str) -> String {
5545 tool_name
5546 .trim()
5547 .to_ascii_lowercase()
5548 .chars()
5549 .filter(|ch| ch.is_ascii_alphanumeric())
5550 .collect::<String>()
5551}
5552
5553fn is_email_delivery_tool_name(tool_name: &str) -> bool {
5554 let tokens = email_tool_name_tokens(tool_name);
5555 let compact = email_tool_name_compact(tool_name);
5556 let looks_like_email_provider = tokens.iter().any(|token| {
5557 matches!(
5558 token.as_str(),
5559 "email"
5560 | "mail"
5561 | "gmail"
5562 | "outlook"
5563 | "smtp"
5564 | "imap"
5565 | "inbox"
5566 | "mailbox"
5567 | "mailer"
5568 | "exchange"
5569 | "sendgrid"
5570 | "mailgun"
5571 | "postmark"
5572 | "resend"
5573 | "ses"
5574 )
5575 });
5576 if !looks_like_email_provider {
5577 return false;
5578 }
5579 tokens.iter().any(|token| {
5580 matches!(
5581 token.as_str(),
5582 "send" | "deliver" | "reply" | "draft" | "compose" | "create"
5583 )
5584 }) || compact.contains("sendemail")
5585 || compact.contains("emailsend")
5586 || compact.contains("replyemail")
5587 || compact.contains("emailreply")
5588 || compact.contains("draftemail")
5589 || compact.contains("emaildraft")
5590 || compact.contains("composeemail")
5591 || compact.contains("emailcompose")
5592 || compact.contains("createemaildraft")
5593 || compact.contains("emailcreatedraft")
5594}
5595
5596fn sanitize_email_attachment_args(args: Value) -> Value {
5597 let mut obj = match args {
5598 Value::Object(map) => map,
5599 other => return other,
5600 };
5601 if let Some(Value::Object(attachment)) = obj.get("attachment") {
5602 let s3key = attachment
5603 .get("s3key")
5604 .and_then(Value::as_str)
5605 .map(str::trim)
5606 .unwrap_or("");
5607 if s3key.is_empty() {
5608 obj.remove("attachment");
5609 }
5610 } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
5611 obj.remove("attachment");
5612 }
5613 if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
5614 attachments.retain(|entry| {
5615 entry
5616 .get("s3key")
5617 .and_then(Value::as_str)
5618 .map(str::trim)
5619 .map(|value| !value.is_empty())
5620 .unwrap_or(false)
5621 });
5622 if attachments.is_empty() {
5623 obj.remove("attachments");
5624 }
5625 }
5626 Value::Object(obj)
5627}
5628
5629fn set_file_path_arg(args: Value, path: String) -> Value {
5630 let mut obj = args.as_object().cloned().unwrap_or_default();
5631 obj.insert("path".to_string(), Value::String(path));
5632 Value::Object(obj)
5633}
5634
5635fn normalize_workspace_alias_path(path: &str, workspace_root: &str) -> Option<String> {
5636 let trimmed = path.trim();
5637 if trimmed.is_empty() {
5638 return None;
5639 }
5640 let normalized = trimmed.replace('\\', "/");
5641 if normalized == "/workspace" {
5642 return Some(workspace_root.to_string());
5643 }
5644 if let Some(rest) = normalized.strip_prefix("/workspace/") {
5645 if rest.trim().is_empty() {
5646 return Some(workspace_root.to_string());
5647 }
5648 return Some(rest.trim().to_string());
5649 }
5650 None
5651}
5652
5653fn rewrite_workspace_alias_tool_args(tool: &str, args: Value, workspace_root: &str) -> Value {
5654 let normalized_tool = normalize_tool_name(tool);
5655 if !matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5656 return args;
5657 }
5658 let Some(path) = extract_file_path_arg(&args) else {
5659 return args;
5660 };
5661 let Some(rewritten) = normalize_workspace_alias_path(&path, workspace_root) else {
5662 return args;
5663 };
5664 set_file_path_arg(args, rewritten)
5665}
5666
5667fn set_write_content_arg(args: Value, content: String) -> Value {
5668 let mut obj = args.as_object().cloned().unwrap_or_default();
5669 obj.insert("content".to_string(), Value::String(content));
5670 Value::Object(obj)
5671}
5672
5673fn extract_file_path_arg(args: &Value) -> Option<String> {
5674 extract_file_path_arg_internal(args, 0)
5675}
5676
5677fn extract_write_content_arg(args: &Value) -> Option<String> {
5678 extract_write_content_arg_internal(args, 0)
5679}
5680
5681fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
5682 if depth > 5 {
5683 return None;
5684 }
5685
5686 match args {
5687 Value::String(raw) => {
5688 let trimmed = raw.trim();
5689 if trimmed.is_empty() {
5690 return None;
5691 }
5692 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5694 return sanitize_path_candidate(trimmed);
5695 }
5696 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5697 return extract_file_path_arg_internal(&parsed, depth + 1);
5698 }
5699 sanitize_path_candidate(trimmed)
5700 }
5701 Value::Array(items) => items
5702 .iter()
5703 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
5704 Value::Object(obj) => {
5705 for key in FILE_PATH_KEYS {
5706 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5707 if let Some(path) = sanitize_path_candidate(raw) {
5708 return Some(path);
5709 }
5710 }
5711 }
5712 for container in NESTED_ARGS_KEYS {
5713 if let Some(nested) = obj.get(container) {
5714 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
5715 return Some(path);
5716 }
5717 }
5718 }
5719 None
5720 }
5721 _ => None,
5722 }
5723}
5724
5725fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
5726 if depth > 5 {
5727 return None;
5728 }
5729
5730 match args {
5731 Value::String(raw) => {
5732 let trimmed = raw.trim();
5733 if trimmed.is_empty() {
5734 return None;
5735 }
5736 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5737 return extract_write_content_arg_internal(&parsed, depth + 1);
5738 }
5739 if sanitize_path_candidate(trimmed).is_some()
5742 && !trimmed.contains('\n')
5743 && trimmed.split_whitespace().count() <= 3
5744 {
5745 return None;
5746 }
5747 Some(trimmed.to_string())
5748 }
5749 Value::Array(items) => items
5750 .iter()
5751 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
5752 Value::Object(obj) => {
5753 for key in WRITE_CONTENT_KEYS {
5754 if let Some(value) = obj.get(key) {
5755 if let Some(raw) = value.as_str() {
5756 if !raw.is_empty() {
5757 return Some(raw.to_string());
5758 }
5759 } else if let Some(recovered) =
5760 extract_write_content_arg_internal(value, depth + 1)
5761 {
5762 return Some(recovered);
5763 }
5764 }
5765 }
5766 for container in NESTED_ARGS_KEYS {
5767 if let Some(nested) = obj.get(container) {
5768 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
5769 return Some(content);
5770 }
5771 }
5772 }
5773 None
5774 }
5775 _ => None,
5776 }
5777}
5778
5779fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
5780 let text = latest_assistant_context.trim();
5781 if text.len() < 32 {
5782 return None;
5783 }
5784 Some(text.to_string())
5785}
5786
5787fn set_shell_command(args: Value, command: String) -> Value {
5788 let mut obj = args.as_object().cloned().unwrap_or_default();
5789 obj.insert("command".to_string(), Value::String(command));
5790 Value::Object(obj)
5791}
5792
5793fn extract_shell_command(args: &Value) -> Option<String> {
5794 extract_shell_command_internal(args, 0)
5795}
5796
5797fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
5798 if depth > 5 {
5799 return None;
5800 }
5801
5802 match args {
5803 Value::String(raw) => {
5804 let trimmed = raw.trim();
5805 if trimmed.is_empty() {
5806 return None;
5807 }
5808 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5809 return sanitize_shell_command_candidate(trimmed);
5810 }
5811 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5812 return extract_shell_command_internal(&parsed, depth + 1);
5813 }
5814 sanitize_shell_command_candidate(trimmed)
5815 }
5816 Value::Array(items) => items
5817 .iter()
5818 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
5819 Value::Object(obj) => {
5820 for key in SHELL_COMMAND_KEYS {
5821 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5822 if let Some(command) = sanitize_shell_command_candidate(raw) {
5823 return Some(command);
5824 }
5825 }
5826 }
5827 for container in NESTED_ARGS_KEYS {
5828 if let Some(nested) = obj.get(container) {
5829 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
5830 return Some(command);
5831 }
5832 }
5833 }
5834 None
5835 }
5836 _ => None,
5837 }
5838}
5839
5840fn infer_shell_command_from_text(text: &str) -> Option<String> {
5841 let trimmed = text.trim();
5842 if trimmed.is_empty() {
5843 return None;
5844 }
5845
5846 let mut in_tick = false;
5848 let mut tick_buf = String::new();
5849 for ch in trimmed.chars() {
5850 if ch == '`' {
5851 if in_tick {
5852 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
5853 if looks_like_shell_command(&candidate) {
5854 return Some(candidate);
5855 }
5856 }
5857 tick_buf.clear();
5858 }
5859 in_tick = !in_tick;
5860 continue;
5861 }
5862 if in_tick {
5863 tick_buf.push(ch);
5864 }
5865 }
5866
5867 for line in trimmed.lines() {
5868 let line = line.trim();
5869 if line.is_empty() {
5870 continue;
5871 }
5872 let lower = line.to_ascii_lowercase();
5873 for prefix in [
5874 "run ",
5875 "execute ",
5876 "call ",
5877 "use bash ",
5878 "use shell ",
5879 "bash ",
5880 "shell ",
5881 "powershell ",
5882 "pwsh ",
5883 ] {
5884 if lower.starts_with(prefix) {
5885 let candidate = line[prefix.len()..].trim();
5886 if let Some(command) = sanitize_shell_command_candidate(candidate) {
5887 if looks_like_shell_command(&command) {
5888 return Some(command);
5889 }
5890 }
5891 }
5892 }
5893 }
5894
5895 None
5896}
5897
5898fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
5899 let mut obj = args.as_object().cloned().unwrap_or_default();
5900 if let Some(q) = query {
5901 obj.insert("query".to_string(), Value::String(q));
5902 }
5903 obj.insert(
5904 "__query_source".to_string(),
5905 Value::String(query_source.to_string()),
5906 );
5907 Value::Object(obj)
5908}
5909
5910fn set_webfetch_url_arg(args: Value, url: String) -> Value {
5911 let mut obj = args.as_object().cloned().unwrap_or_default();
5912 obj.insert("url".to_string(), Value::String(url));
5913 Value::Object(obj)
5914}
5915
5916fn set_query_arg(args: Value, query: Option<String>, _source: &str) -> Value {
5917 let mut obj = args.as_object().cloned().unwrap_or_default();
5918 if let Some(query) = query {
5919 obj.insert("query".to_string(), Value::String(query));
5920 }
5921 Value::Object(obj)
5922}
5923
5924fn set_doc_path_arg(args: Value, path: String) -> Value {
5925 let mut obj = args.as_object().cloned().unwrap_or_default();
5926 obj.insert("path".to_string(), Value::String(path));
5927 Value::Object(obj)
5928}
5929
5930fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
5931 let mut obj = args.as_object().cloned().unwrap_or_default();
5932 obj.insert("goal".to_string(), Value::String(goal));
5933 Value::Object(obj)
5934}
5935
5936fn set_task_arg(args: Value, task: String) -> Value {
5937 let mut obj = args.as_object().cloned().unwrap_or_default();
5938 obj.insert("task".to_string(), Value::String(task));
5939 Value::Object(obj)
5940}
5941
5942fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
5943 let mut obj = args.as_object().cloned().unwrap_or_default();
5944 obj.insert("mode".to_string(), Value::String("apply".to_string()));
5945 obj.insert("plan_id".to_string(), Value::String(plan_id));
5946 obj.insert(
5947 "approve_connector_registration".to_string(),
5948 Value::Bool(true),
5949 );
5950 obj.insert("approve_pack_install".to_string(), Value::Bool(true));
5951 obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
5952 Value::Object(obj)
5953}
5954
5955fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
5956 for key in ["mode"] {
5957 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5958 let mode = value.trim().to_ascii_lowercase();
5959 if !mode.is_empty() {
5960 return Some(mode);
5961 }
5962 }
5963 }
5964 for container in ["arguments", "args", "input", "params"] {
5965 if let Some(obj) = args.get(container) {
5966 if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
5967 let mode = value.trim().to_ascii_lowercase();
5968 if !mode.is_empty() {
5969 return Some(mode);
5970 }
5971 }
5972 }
5973 }
5974 None
5975}
5976
5977fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
5978 for key in ["plan_id", "planId"] {
5979 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5980 let plan_id = value.trim();
5981 if !plan_id.is_empty() {
5982 return Some(plan_id.to_string());
5983 }
5984 }
5985 }
5986 for container in ["arguments", "args", "input", "params"] {
5987 if let Some(obj) = args.get(container) {
5988 for key in ["plan_id", "planId"] {
5989 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5990 let plan_id = value.trim();
5991 if !plan_id.is_empty() {
5992 return Some(plan_id.to_string());
5993 }
5994 }
5995 }
5996 }
5997 }
5998 None
5999}
6000
6001fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
6002 if text.trim().is_empty() {
6003 return None;
6004 }
6005 let bytes = text.as_bytes();
6006 let mut idx = 0usize;
6007 while idx + 5 <= bytes.len() {
6008 if &bytes[idx..idx + 5] != b"plan-" {
6009 idx += 1;
6010 continue;
6011 }
6012 let mut end = idx + 5;
6013 while end < bytes.len() {
6014 let ch = bytes[end] as char;
6015 if ch.is_ascii_alphanumeric() || ch == '-' {
6016 end += 1;
6017 } else {
6018 break;
6019 }
6020 }
6021 if end > idx + 5 {
6022 let candidate = &text[idx..end];
6023 if candidate.len() >= 10 {
6024 return Some(candidate.to_string());
6025 }
6026 }
6027 idx = end.saturating_add(1);
6028 }
6029 None
6030}
6031
6032fn is_pack_builder_confirmation_text(text: &str) -> bool {
6033 let trimmed = text.trim();
6034 if trimmed.is_empty() {
6035 return false;
6036 }
6037 let lower = trimmed.to_ascii_lowercase();
6038 matches!(
6039 lower.as_str(),
6040 "confirm"
6041 | "confirmed"
6042 | "approve"
6043 | "approved"
6044 | "yes"
6045 | "y"
6046 | "ok"
6047 | "okay"
6048 | "go"
6049 | "go ahead"
6050 | "ship it"
6051 | "do it"
6052 | "apply"
6053 | "run it"
6054 | "✅"
6055 | "👍"
6056 )
6057}
6058
6059fn infer_pack_builder_apply_plan_id(
6060 latest_user_text: &str,
6061 latest_assistant_context: &str,
6062) -> Option<String> {
6063 if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
6064 return Some(plan_id);
6065 }
6066 if !is_pack_builder_confirmation_text(latest_user_text) {
6067 return None;
6068 }
6069 extract_pack_builder_plan_id_from_text(latest_assistant_context)
6070}
6071
6072fn ensure_pack_builder_default_mode(args: Value) -> Value {
6073 let mut obj = args.as_object().cloned().unwrap_or_default();
6074 let has_mode = obj
6075 .get("mode")
6076 .and_then(Value::as_str)
6077 .map(str::trim)
6078 .is_some_and(|v| !v.is_empty());
6079 if !has_mode {
6080 obj.insert("mode".to_string(), Value::String("preview".to_string()));
6081 }
6082 Value::Object(obj)
6083}
6084
6085fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
6086 const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
6087 for key in URL_KEYS {
6088 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6089 if let Some(url) = sanitize_url_candidate(value) {
6090 return Some(url);
6091 }
6092 }
6093 }
6094 for container in ["arguments", "args", "input", "params"] {
6095 if let Some(obj) = args.get(container) {
6096 for key in URL_KEYS {
6097 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6098 if let Some(url) = sanitize_url_candidate(value) {
6099 return Some(url);
6100 }
6101 }
6102 }
6103 }
6104 }
6105 args.as_str().and_then(sanitize_url_candidate)
6106}
6107
6108fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
6109 const GOAL_KEYS: [&str; 1] = ["goal"];
6110 for key in GOAL_KEYS {
6111 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6112 let trimmed = value.trim();
6113 if !trimmed.is_empty() {
6114 return Some(trimmed.to_string());
6115 }
6116 }
6117 }
6118 for container in ["arguments", "args", "input", "params"] {
6119 if let Some(obj) = args.get(container) {
6120 for key in GOAL_KEYS {
6121 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6122 let trimmed = value.trim();
6123 if !trimmed.is_empty() {
6124 return Some(trimmed.to_string());
6125 }
6126 }
6127 }
6128 }
6129 }
6130 args.as_str()
6131 .map(str::trim)
6132 .filter(|v| !v.is_empty())
6133 .map(ToString::to_string)
6134}
6135
6136fn extract_task_arg(args: &Value) -> Option<String> {
6137 const TASK_KEYS: [&str; 4] = ["task", "query", "question", "prompt"];
6138 for key in TASK_KEYS {
6139 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6140 let trimmed = value.trim();
6141 if !trimmed.is_empty() {
6142 return Some(trimmed.to_string());
6143 }
6144 }
6145 }
6146 for container in ["arguments", "args", "input", "params"] {
6147 if let Some(obj) = args.get(container) {
6148 for key in TASK_KEYS {
6149 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6150 let trimmed = value.trim();
6151 if !trimmed.is_empty() {
6152 return Some(trimmed.to_string());
6153 }
6154 }
6155 }
6156 }
6157 }
6158 args.as_str()
6159 .map(str::trim)
6160 .filter(|value| !value.is_empty())
6161 .map(ToString::to_string)
6162}
6163
6164fn extract_websearch_query(args: &Value) -> Option<String> {
6165 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6166 for key in QUERY_KEYS {
6167 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6168 if let Some(query) = sanitize_websearch_query_candidate(value) {
6169 return Some(query);
6170 }
6171 }
6172 }
6173 for container in ["arguments", "args", "input", "params"] {
6174 if let Some(obj) = args.get(container) {
6175 for key in QUERY_KEYS {
6176 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6177 if let Some(query) = sanitize_websearch_query_candidate(value) {
6178 return Some(query);
6179 }
6180 }
6181 }
6182 }
6183 }
6184 args.as_str().and_then(sanitize_websearch_query_candidate)
6185}
6186
6187fn extract_query_arg(args: &Value) -> Option<String> {
6188 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6189 for key in QUERY_KEYS {
6190 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6191 let trimmed = value.trim();
6192 if !trimmed.is_empty() {
6193 return Some(trimmed.to_string());
6194 }
6195 }
6196 }
6197 for container in ["arguments", "args", "input", "params"] {
6198 if let Some(obj) = args.get(container) {
6199 for key in QUERY_KEYS {
6200 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6201 let trimmed = value.trim();
6202 if !trimmed.is_empty() {
6203 return Some(trimmed.to_string());
6204 }
6205 }
6206 }
6207 }
6208 }
6209 args.as_str()
6210 .map(str::trim)
6211 .filter(|value| !value.is_empty())
6212 .map(ToString::to_string)
6213}
6214
6215fn extract_doc_path_arg(args: &Value) -> Option<String> {
6216 const PATH_KEYS: [&str; 4] = ["path", "url", "doc", "page"];
6217 for key in PATH_KEYS {
6218 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6219 if let Some(path) = sanitize_doc_path_candidate(value) {
6220 return Some(path);
6221 }
6222 }
6223 }
6224 for container in ["arguments", "args", "input", "params"] {
6225 if let Some(obj) = args.get(container) {
6226 for key in PATH_KEYS {
6227 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6228 if let Some(path) = sanitize_doc_path_candidate(value) {
6229 return Some(path);
6230 }
6231 }
6232 }
6233 }
6234 }
6235 args.as_str().and_then(sanitize_doc_path_candidate)
6236}
6237
6238fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
6239 let trimmed = raw.trim();
6240 if trimmed.is_empty() {
6241 return None;
6242 }
6243
6244 let lower = trimmed.to_ascii_lowercase();
6245 if let Some(start) = lower.find("<arg_value>") {
6246 let value_start = start + "<arg_value>".len();
6247 let tail = &trimmed[value_start..];
6248 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
6249 &tail[..end]
6250 } else {
6251 tail
6252 };
6253 let cleaned = value.trim();
6254 if !cleaned.is_empty() {
6255 return Some(cleaned.to_string());
6256 }
6257 }
6258
6259 let without_wrappers = trimmed
6260 .replace("<arg_key>", " ")
6261 .replace("</arg_key>", " ")
6262 .replace("<arg_value>", " ")
6263 .replace("</arg_value>", " ");
6264 let collapsed = without_wrappers
6265 .split_whitespace()
6266 .collect::<Vec<_>>()
6267 .join(" ");
6268 if collapsed.is_empty() {
6269 return None;
6270 }
6271
6272 let collapsed_lower = collapsed.to_ascii_lowercase();
6273 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
6274 let offset = collapsed.len() - rest.len();
6275 let q = collapsed[offset..].trim();
6276 if !q.is_empty() {
6277 return Some(q.to_string());
6278 }
6279 }
6280 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
6281 let offset = collapsed.len() - rest.len();
6282 let q = collapsed[offset..].trim();
6283 if !q.is_empty() {
6284 return Some(q.to_string());
6285 }
6286 }
6287
6288 Some(collapsed)
6289}
6290
6291fn infer_websearch_query_from_text(text: &str) -> Option<String> {
6292 let trimmed = text.trim();
6293 if trimmed.is_empty() {
6294 return None;
6295 }
6296
6297 let lower = trimmed.to_lowercase();
6298 const PREFIXES: [&str; 11] = [
6299 "web search",
6300 "websearch",
6301 "search web for",
6302 "search web",
6303 "search for",
6304 "search",
6305 "look up",
6306 "lookup",
6307 "find",
6308 "web lookup",
6309 "query",
6310 ];
6311
6312 let mut candidate = trimmed;
6313 for prefix in PREFIXES {
6314 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
6315 let remainder = trimmed[prefix.len()..]
6316 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
6317 candidate = remainder;
6318 break;
6319 }
6320 }
6321
6322 let normalized = candidate
6323 .trim()
6324 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
6325 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
6326 .trim()
6327 .to_string();
6328
6329 if normalized.split_whitespace().count() < 2 {
6330 return None;
6331 }
6332 Some(normalized)
6333}
6334
6335fn infer_file_path_from_text(text: &str) -> Option<String> {
6336 let trimmed = text.trim();
6337 if trimmed.is_empty() {
6338 return None;
6339 }
6340
6341 let mut candidates: Vec<String> = Vec::new();
6342
6343 let mut in_tick = false;
6345 let mut tick_buf = String::new();
6346 for ch in trimmed.chars() {
6347 if ch == '`' {
6348 if in_tick {
6349 let cand = sanitize_path_candidate(&tick_buf);
6350 if let Some(path) = cand {
6351 candidates.push(path);
6352 }
6353 tick_buf.clear();
6354 }
6355 in_tick = !in_tick;
6356 continue;
6357 }
6358 if in_tick {
6359 tick_buf.push(ch);
6360 }
6361 }
6362
6363 for raw in trimmed.split_whitespace() {
6365 if let Some(path) = sanitize_path_candidate(raw) {
6366 candidates.push(path);
6367 }
6368 }
6369
6370 let mut deduped = Vec::new();
6371 let mut seen = HashSet::new();
6372 for candidate in candidates {
6373 if seen.insert(candidate.clone()) {
6374 deduped.push(candidate);
6375 }
6376 }
6377
6378 deduped.into_iter().next()
6379}
6380
6381fn infer_workspace_root_from_text(text: &str) -> Option<String> {
6382 text.lines().find_map(|line| {
6383 let trimmed = line.trim();
6384 let value = trimmed.strip_prefix("Workspace:")?.trim();
6385 sanitize_path_candidate(value)
6386 })
6387}
6388
6389fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
6390 let marker = "Required output target:";
6393 if let Some(idx) = text.find(marker) {
6394 let tail = text[idx + marker.len()..].trim_start();
6395 if let Some(start) = tail.find('{') {
6396 let json_candidate = tail[start..]
6397 .lines()
6398 .take_while(|line| {
6399 let trimmed = line.trim();
6400 !(trimmed.is_empty() && !trimmed.starts_with('{'))
6401 })
6402 .collect::<Vec<_>>()
6403 .join("\n");
6404 if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
6405 if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
6406 if let Some(clean) = sanitize_explicit_output_target_path(path) {
6407 return Some(clean);
6408 }
6409 }
6410 }
6411 }
6412 }
6413 let auto_marker = "Create or update `";
6416 if let Some(idx) = text.find(auto_marker) {
6417 let after = &text[idx + auto_marker.len()..];
6418 if let Some(end) = after.find('`') {
6419 let path = after[..end].trim();
6420 if let Some(clean) = sanitize_explicit_output_target_path(path) {
6421 return Some(clean);
6422 }
6423 }
6424 }
6425 None
6426}
6427
6428fn infer_write_file_path_from_text(text: &str) -> Option<String> {
6429 let inferred = infer_file_path_from_text(text)?;
6430 let workspace_root = infer_workspace_root_from_text(text);
6431 if workspace_root
6432 .as_deref()
6433 .is_some_and(|root| root == inferred)
6434 {
6435 return None;
6436 }
6437 Some(inferred)
6438}
6439
6440fn infer_url_from_text(text: &str) -> Option<String> {
6441 let trimmed = text.trim();
6442 if trimmed.is_empty() {
6443 return None;
6444 }
6445
6446 let mut candidates: Vec<String> = Vec::new();
6447
6448 let mut in_tick = false;
6450 let mut tick_buf = String::new();
6451 for ch in trimmed.chars() {
6452 if ch == '`' {
6453 if in_tick {
6454 if let Some(url) = sanitize_url_candidate(&tick_buf) {
6455 candidates.push(url);
6456 }
6457 tick_buf.clear();
6458 }
6459 in_tick = !in_tick;
6460 continue;
6461 }
6462 if in_tick {
6463 tick_buf.push(ch);
6464 }
6465 }
6466
6467 for raw in trimmed.split_whitespace() {
6469 if let Some(url) = sanitize_url_candidate(raw) {
6470 candidates.push(url);
6471 }
6472 }
6473
6474 let mut seen = HashSet::new();
6475 candidates
6476 .into_iter()
6477 .find(|candidate| seen.insert(candidate.clone()))
6478}
6479
6480fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
6481 let trimmed = text.trim();
6482 if trimmed.is_empty() {
6483 None
6484 } else {
6485 Some(trimmed.to_string())
6486 }
6487}
6488
6489fn infer_task_from_text(text: &str) -> Option<String> {
6490 let trimmed = text.trim();
6491 if trimmed.is_empty() {
6492 None
6493 } else {
6494 Some(trimmed.to_string())
6495 }
6496}
6497
6498fn infer_query_from_text(text: &str) -> Option<String> {
6499 let trimmed = text.trim();
6500 if trimmed.is_empty() {
6501 None
6502 } else {
6503 Some(trimmed.to_string())
6504 }
6505}
6506
6507fn infer_doc_path_from_text(text: &str) -> Option<String> {
6508 if let Some(url) = infer_url_from_text(text) {
6509 return Some(url);
6510 }
6511
6512 let trimmed = text.trim();
6513 if trimmed.is_empty() {
6514 return None;
6515 }
6516
6517 let mut candidates: Vec<String> = Vec::new();
6518
6519 let mut in_tick = false;
6520 let mut tick_buf = String::new();
6521 for ch in trimmed.chars() {
6522 if ch == '`' {
6523 if in_tick {
6524 if let Some(path) = sanitize_doc_path_candidate(&tick_buf) {
6525 candidates.push(path);
6526 }
6527 tick_buf.clear();
6528 }
6529 in_tick = !in_tick;
6530 continue;
6531 }
6532 if in_tick {
6533 tick_buf.push(ch);
6534 }
6535 }
6536
6537 for raw in trimmed.split_whitespace() {
6538 if let Some(path) = sanitize_doc_path_candidate(raw) {
6539 candidates.push(path);
6540 }
6541 }
6542
6543 let mut seen = HashSet::new();
6544 candidates
6545 .into_iter()
6546 .find(|candidate| seen.insert(candidate.clone()))
6547}
6548
6549fn tool_name_requires_task_arg(tool_name: &str) -> bool {
6550 let normalized = normalize_tool_name(tool_name);
6551 normalized == "answer_how_to" || normalized.ends_with(".answer_how_to")
6552}
6553
6554fn tool_name_requires_query_arg(tool_name: &str) -> bool {
6555 let normalized = normalize_tool_name(tool_name);
6556 normalized == "search_docs" || normalized.ends_with(".search_docs")
6557}
6558
6559fn tool_name_requires_doc_path_arg(tool_name: &str) -> bool {
6560 let normalized = normalize_tool_name(tool_name);
6561 normalized == "get_doc" || normalized.ends_with(".get_doc")
6562}
6563
6564fn sanitize_url_candidate(raw: &str) -> Option<String> {
6565 let token = raw
6566 .trim()
6567 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6568 .trim_start_matches(['(', '[', '{', '<'])
6569 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6570 .trim_end_matches('.')
6571 .trim();
6572
6573 if token.is_empty() {
6574 return None;
6575 }
6576 let lower = token.to_ascii_lowercase();
6577 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
6578 return None;
6579 }
6580 Some(token.to_string())
6581}
6582
6583fn sanitize_doc_path_candidate(raw: &str) -> Option<String> {
6584 let token = raw
6585 .trim()
6586 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6587 .trim_start_matches(['(', '[', '{', '<'])
6588 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6589 .trim_end_matches('.')
6590 .trim();
6591
6592 if token.is_empty() {
6593 return None;
6594 }
6595
6596 if let Some(url) = sanitize_url_candidate(token) {
6597 return Some(url);
6598 }
6599
6600 let lower = token.to_ascii_lowercase();
6601 if token.starts_with('/')
6602 || token.starts_with("./")
6603 || token.starts_with("../")
6604 || lower.starts_with("start-here")
6605 || lower.starts_with("sdk/")
6606 || lower.starts_with("desktop/")
6607 || lower.starts_with("control-panel/")
6608 || lower.starts_with("reference/")
6609 {
6610 return Some(token.to_string());
6611 }
6612
6613 None
6614}
6615
6616fn clean_path_candidate_token(raw: &str) -> Option<String> {
6617 let token = raw.trim();
6618 let token = token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'));
6619 let token = token.trim_start_matches(['(', '[', '{', '<']);
6620 let token = token.trim_end_matches([',', ';', ':', ')', ']', '}', '>']);
6621 let token = token.trim_end_matches('.').trim();
6622
6623 if token.is_empty() {
6624 return None;
6625 }
6626 Some(token.to_string())
6627}
6628
6629fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
6630 let token = clean_path_candidate_token(raw)?;
6631 let lower = token.to_ascii_lowercase();
6632 if lower.starts_with("http://") || lower.starts_with("https://") {
6633 return None;
6634 }
6635 if is_malformed_tool_path_token(&token) {
6636 return None;
6637 }
6638 if is_root_only_path_token(&token) {
6639 return None;
6640 }
6641 if is_placeholder_path_token(&token) {
6642 return None;
6643 }
6644 if token.ends_with('/') || token.ends_with('\\') {
6645 return None;
6646 }
6647 Some(token.to_string())
6648}
6649
6650fn sanitize_path_candidate(raw: &str) -> Option<String> {
6651 let token = clean_path_candidate_token(raw)?;
6652 let lower = token.to_ascii_lowercase();
6653 if lower.starts_with("http://") || lower.starts_with("https://") {
6654 return None;
6655 }
6656 if is_malformed_tool_path_token(token.as_str()) {
6657 return None;
6658 }
6659 if is_root_only_path_token(token.as_str()) {
6660 return None;
6661 }
6662 if is_placeholder_path_token(token.as_str()) {
6663 return None;
6664 }
6665 if token.ends_with('/') || token.ends_with('\\') {
6666 return None;
6667 }
6668
6669 let looks_like_path = token.contains('/') || token.contains('\\');
6670 let has_file_ext = [
6671 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
6672 ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
6673 ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
6674 ]
6675 .iter()
6676 .any(|ext| lower.ends_with(ext));
6677
6678 if !looks_like_path && !has_file_ext {
6679 return None;
6680 }
6681
6682 Some(token)
6683}
6684
6685fn is_placeholder_path_token(token: &str) -> bool {
6686 let lowered = token.trim().to_ascii_lowercase();
6687 if lowered.is_empty() {
6688 return true;
6689 }
6690 matches!(
6691 lowered.as_str(),
6692 "files/directories"
6693 | "file/directory"
6694 | "relative/or/absolute/path"
6695 | "path/to/file"
6696 | "path/to/your/file"
6697 | "tool/policy"
6698 | "tools/policy"
6699 | "the expected artifact file"
6700 | "workspace/file"
6701 )
6702}
6703
6704fn is_malformed_tool_path_token(token: &str) -> bool {
6705 let lower = token.to_ascii_lowercase();
6706 if lower.contains("<tool_call")
6708 || lower.contains("</tool_call")
6709 || lower.contains("<function=")
6710 || lower.contains("<parameter=")
6711 || lower.contains("</function>")
6712 || lower.contains("</parameter>")
6713 {
6714 return true;
6715 }
6716 if token.contains('\n') || token.contains('\r') {
6718 return true;
6719 }
6720 if token.contains('*') || token.contains('?') {
6722 return true;
6723 }
6724 if lower.starts_with("ctx:") {
6728 return true;
6729 }
6730 if token.matches(':').count() >= 2 {
6733 return true;
6734 }
6735 false
6736}
6737
6738fn is_root_only_path_token(token: &str) -> bool {
6739 let trimmed = token.trim();
6740 if trimmed.is_empty() {
6741 return true;
6742 }
6743 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
6744 return true;
6745 }
6746 let bytes = trimmed.as_bytes();
6748 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
6749 return true;
6750 }
6751 if bytes.len() == 3
6752 && bytes[1] == b':'
6753 && (bytes[0] as char).is_ascii_alphabetic()
6754 && (bytes[2] == b'\\' || bytes[2] == b'/')
6755 {
6756 return true;
6757 }
6758 false
6759}
6760
6761fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
6762 let token = raw
6763 .trim()
6764 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
6765 .trim();
6766 if token.is_empty() {
6767 return None;
6768 }
6769 Some(token.to_string())
6770}
6771
6772fn looks_like_shell_command(candidate: &str) -> bool {
6773 let lower = candidate.to_ascii_lowercase();
6774 if lower.is_empty() {
6775 return false;
6776 }
6777 let first = lower.split_whitespace().next().unwrap_or_default();
6778 let common = [
6779 "rg",
6780 "git",
6781 "cargo",
6782 "pnpm",
6783 "npm",
6784 "node",
6785 "python",
6786 "pytest",
6787 "pwsh",
6788 "powershell",
6789 "cmd",
6790 "dir",
6791 "ls",
6792 "cat",
6793 "type",
6794 "echo",
6795 "cd",
6796 "mkdir",
6797 "cp",
6798 "copy",
6799 "move",
6800 "del",
6801 "rm",
6802 ];
6803 common.contains(&first)
6804 || first.starts_with("get-")
6805 || first.starts_with("./")
6806 || first.starts_with(".\\")
6807 || lower.contains(" | ")
6808 || lower.contains(" && ")
6809 || lower.contains(" ; ")
6810}
6811
6812const FILE_PATH_KEYS: [&str; 10] = [
6813 "path",
6814 "file_path",
6815 "filePath",
6816 "filepath",
6817 "filename",
6818 "file",
6819 "target",
6820 "targetFile",
6821 "absolutePath",
6822 "uri",
6823];
6824
6825const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
6826
6827const WRITE_CONTENT_KEYS: [&str; 8] = [
6828 "content",
6829 "text",
6830 "body",
6831 "value",
6832 "markdown",
6833 "document",
6834 "output",
6835 "file_content",
6836];
6837
6838const NESTED_ARGS_KEYS: [&str; 10] = [
6839 "arguments",
6840 "args",
6841 "input",
6842 "params",
6843 "payload",
6844 "data",
6845 "tool_input",
6846 "toolInput",
6847 "tool_args",
6848 "toolArgs",
6849];
6850
6851fn tool_signature(tool_name: &str, args: &Value) -> String {
6852 let normalized = normalize_tool_name(tool_name);
6853 if normalized == "websearch" {
6854 let query = extract_websearch_query(args)
6855 .unwrap_or_default()
6856 .to_lowercase();
6857 let limit = args
6858 .get("limit")
6859 .or_else(|| args.get("numResults"))
6860 .or_else(|| args.get("num_results"))
6861 .and_then(|v| v.as_u64())
6862 .unwrap_or(8);
6863 let domains = args
6864 .get("domains")
6865 .or_else(|| args.get("domain"))
6866 .map(|v| v.to_string())
6867 .unwrap_or_default();
6868 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
6869 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
6870 }
6871 format!("{}:{}", normalized, args)
6872}
6873
6874fn stable_hash(input: &str) -> String {
6875 let mut hasher = DefaultHasher::new();
6876 input.hash(&mut hasher);
6877 format!("{:016x}", hasher.finish())
6878}
6879
6880fn summarize_tool_outputs(outputs: &[String]) -> String {
6881 outputs
6882 .iter()
6883 .take(6)
6884 .map(|output| truncate_text(output, 600))
6885 .collect::<Vec<_>>()
6886 .join("\n\n")
6887}
6888
6889fn summarize_user_visible_tool_outputs(outputs: &[String]) -> String {
6890 let filtered = outputs
6891 .iter()
6892 .filter(|output| !should_hide_tool_output_from_user_fallback(output))
6893 .take(3)
6894 .map(|output| truncate_text(output, 240))
6895 .collect::<Vec<_>>();
6896 filtered.join("\n")
6897}
6898
6899fn should_hide_tool_output_from_user_fallback(output: &str) -> bool {
6900 let trimmed = output.trim();
6901 if trimmed.is_empty() {
6902 return true;
6903 }
6904 let lower = trimmed.to_ascii_lowercase();
6905 if lower.contains("call skipped")
6906 || lower.contains("it is not available in this turn")
6907 || is_terminal_tool_error_reason(trimmed)
6908 {
6909 return true;
6910 }
6911 extract_tool_result_body(trimmed).is_some_and(is_non_productive_tool_result_body)
6912}
6913
6914fn summarize_terminal_tool_failure_for_user(outputs: &[String]) -> Option<String> {
6915 let reasons = outputs
6916 .iter()
6917 .filter_map(|output| terminal_tool_error_reason(output))
6918 .collect::<Vec<_>>();
6919 if reasons.is_empty() {
6920 return None;
6921 }
6922 if reasons.iter().any(|reason| *reason == "DOC_PATH_MISSING") {
6923 return Some(
6924 "I couldn't tell which Tandem docs page to open. Please include a docs URL like `https://docs.tandem.ac/start-here/` or a docs path like `/start-here/` and try again."
6925 .to_string(),
6926 );
6927 }
6928 if reasons
6929 .iter()
6930 .any(|reason| *reason == "QUERY_MISSING" || *reason == "WEBSEARCH_QUERY_MISSING")
6931 {
6932 return Some(
6933 "I need a concrete search query or target URL to continue. Please include the exact thing you want searched and try again."
6934 .to_string(),
6935 );
6936 }
6937 if reasons.iter().any(|reason| *reason == "TASK_MISSING") {
6938 return Some(
6939 "I need the actual docs/help question in the prompt before I can answer it. Please resend the request with the question you want answered."
6940 .to_string(),
6941 );
6942 }
6943 None
6944}
6945
6946fn terminal_tool_error_reason(output: &str) -> Option<&str> {
6947 let trimmed = output.trim();
6948 if trimmed.is_empty() {
6949 return None;
6950 }
6951 let first_line = trimmed.lines().next().unwrap_or_default().trim();
6952 if first_line.is_empty() {
6953 return None;
6954 }
6955 let normalized = first_line.to_ascii_uppercase();
6956 if is_terminal_tool_error_reason(&normalized) {
6957 Some(first_line)
6958 } else {
6959 None
6960 }
6961}
6962
6963fn is_os_mismatch_tool_output(output: &str) -> bool {
6964 let lower = output.to_ascii_lowercase();
6965 lower.contains("os error 3")
6966 || lower.contains("system cannot find the path specified")
6967 || lower.contains("command not found")
6968 || lower.contains("is not recognized as an internal or external command")
6969 || lower.contains("shell command blocked on windows")
6970}
6971
6972fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
6973 match requested {
6974 ContextMode::Full => "full",
6975 ContextMode::Compact => "compact",
6976 ContextMode::Auto => {
6977 if auto_compact {
6978 "auto_compact"
6979 } else {
6980 "auto_standard"
6981 }
6982 }
6983 }
6984}
6985
6986fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
6987 let mut sections = Vec::new();
6988 if os_aware_prompts_enabled() {
6989 sections.push(format!(
6990 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
6991 host_os_label(host.os),
6992 shell_family_label(host.shell_family),
6993 path_style_label(host.path_style),
6994 host.arch
6995 ));
6996 }
6997 sections.push(
6998 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
6999Use tool calls to inspect and modify the workspace when needed instead of asking the user
7000to manually run basic discovery steps. Permission prompts may occur for some tools; if
7001a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
7002 .to_string(),
7003 );
7004 sections.push(
7005 "For greetings or simple conversational messages (for example: hi, hello, thanks),
7006respond directly without calling tools."
7007 .to_string(),
7008 );
7009 if host.os == HostOs::Windows {
7010 sections.push(
7011 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
7012Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
7013If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
7014 .to_string(),
7015 );
7016 } else {
7017 sections.push(
7018 "POSIX guidance: standard shell commands are available.
7019Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
7020 .to_string(),
7021 );
7022 }
7023 if !mcp_server_names.is_empty() {
7024 let cap = mcp_catalog_max_servers();
7025 let mut listed = mcp_server_names
7026 .iter()
7027 .take(cap)
7028 .cloned()
7029 .collect::<Vec<_>>();
7030 listed.sort();
7031 let mut catalog = listed
7032 .iter()
7033 .map(|name| format!("- {name}"))
7034 .collect::<Vec<_>>();
7035 if mcp_server_names.len() > cap {
7036 catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
7037 }
7038 sections.push(format!(
7039 "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
7040 catalog.join("\n")
7041 ));
7042 }
7043 sections.join("\n\n")
7044}
7045
7046fn os_aware_prompts_enabled() -> bool {
7047 std::env::var("TANDEM_OS_AWARE_PROMPTS")
7048 .ok()
7049 .map(|v| {
7050 let normalized = v.trim().to_ascii_lowercase();
7051 !(normalized == "0" || normalized == "false" || normalized == "off")
7052 })
7053 .unwrap_or(true)
7054}
7055
7056fn semantic_tool_retrieval_enabled() -> bool {
7057 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
7058 .ok()
7059 .map(|raw| {
7060 !matches!(
7061 raw.trim().to_ascii_lowercase().as_str(),
7062 "0" | "false" | "off" | "no"
7063 )
7064 })
7065 .unwrap_or(true)
7066}
7067
7068fn semantic_tool_retrieval_k() -> usize {
7069 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
7070 .ok()
7071 .and_then(|raw| raw.trim().parse::<usize>().ok())
7072 .filter(|value| *value > 0)
7073 .unwrap_or_else(max_tools_per_call_expanded)
7074}
7075
7076fn mcp_catalog_in_system_prompt_enabled() -> bool {
7077 std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
7078 .ok()
7079 .map(|raw| {
7080 !matches!(
7081 raw.trim().to_ascii_lowercase().as_str(),
7082 "0" | "false" | "off" | "no"
7083 )
7084 })
7085 .unwrap_or(true)
7086}
7087
7088fn mcp_catalog_max_servers() -> usize {
7089 std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
7090 .ok()
7091 .and_then(|raw| raw.trim().parse::<usize>().ok())
7092 .filter(|value| *value > 0)
7093 .unwrap_or(20)
7094}
7095
7096fn host_os_label(os: HostOs) -> &'static str {
7097 match os {
7098 HostOs::Windows => "windows",
7099 HostOs::Linux => "linux",
7100 HostOs::Macos => "macos",
7101 }
7102}
7103
7104fn shell_family_label(shell: ShellFamily) -> &'static str {
7105 match shell {
7106 ShellFamily::Powershell => "powershell",
7107 ShellFamily::Posix => "posix",
7108 }
7109}
7110
7111fn path_style_label(path_style: PathStyle) -> &'static str {
7112 match path_style {
7113 PathStyle::Windows => "windows",
7114 PathStyle::Posix => "posix",
7115 }
7116}
7117
7118fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
7119 let user = user_text.to_lowercase();
7120 let reply = completion.to_lowercase();
7121
7122 let asked_for_project_context = [
7123 "what is this project",
7124 "what's this project",
7125 "what project is this",
7126 "explain this project",
7127 "analyze this project",
7128 "inspect this project",
7129 "look at the project",
7130 "summarize this project",
7131 "show me this project",
7132 "what files are in",
7133 "show files",
7134 "list files",
7135 "read files",
7136 "browse files",
7137 "use glob",
7138 "run glob",
7139 ]
7140 .iter()
7141 .any(|needle| user.contains(needle));
7142
7143 if !asked_for_project_context {
7144 return false;
7145 }
7146
7147 let assistant_claimed_no_access = [
7148 "can't inspect",
7149 "cannot inspect",
7150 "unable to inspect",
7151 "unable to directly inspect",
7152 "can't access",
7153 "cannot access",
7154 "unable to access",
7155 "can't read files",
7156 "cannot read files",
7157 "unable to read files",
7158 "tool restriction",
7159 "tool restrictions",
7160 "don't have visibility",
7161 "no visibility",
7162 "haven't been able to inspect",
7163 "i don't know what this project is",
7164 "need your help to",
7165 "sandbox",
7166 "restriction",
7167 "system restriction",
7168 "permissions restrictions",
7169 ]
7170 .iter()
7171 .any(|needle| reply.contains(needle));
7172
7173 asked_for_project_context && assistant_claimed_no_access
7176}
7177
7178fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
7179 let raw = input.trim();
7180 if !raw.starts_with("/tool ") {
7181 return None;
7182 }
7183 let rest = raw.trim_start_matches("/tool ").trim();
7184 let mut split = rest.splitn(2, ' ');
7185 let tool = normalize_tool_name(split.next()?.trim());
7186 let args = split
7187 .next()
7188 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
7189 .unwrap_or_else(|| json!({}));
7190 Some((tool, args))
7191}
7192
7193fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
7194 let trimmed = input.trim();
7195 if trimmed.is_empty() {
7196 return Vec::new();
7197 }
7198
7199 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
7200 if let Some(found) = extract_tool_call_from_value(&parsed) {
7201 return vec![found];
7202 }
7203 }
7204
7205 if let Some(block) = extract_first_json_object(trimmed) {
7206 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
7207 if let Some(found) = extract_tool_call_from_value(&parsed) {
7208 return vec![found];
7209 }
7210 }
7211 }
7212
7213 parse_function_style_tool_calls(trimmed)
7214}
7215
7216#[cfg(test)]
7217fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
7218 parse_tool_invocations_from_response(input)
7219 .into_iter()
7220 .next()
7221}
7222
7223fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
7224 let mut calls = Vec::new();
7225 let lower = input.to_lowercase();
7226 let names = [
7227 "todo_write",
7228 "todowrite",
7229 "update_todo_list",
7230 "update_todos",
7231 ];
7232 let mut cursor = 0usize;
7233
7234 while cursor < lower.len() {
7235 let mut best: Option<(usize, &str)> = None;
7236 for name in names {
7237 let needle = format!("{name}(");
7238 if let Some(rel_idx) = lower[cursor..].find(&needle) {
7239 let idx = cursor + rel_idx;
7240 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
7241 best = Some((idx, name));
7242 }
7243 }
7244 }
7245
7246 let Some((tool_start, tool_name)) = best else {
7247 break;
7248 };
7249
7250 let open_paren = tool_start + tool_name.len();
7251 if let Some(close_paren) = find_matching_paren(input, open_paren) {
7252 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
7253 let args = parse_function_style_args(args_text.trim());
7254 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
7255 }
7256 cursor = close_paren.saturating_add(1);
7257 } else {
7258 cursor = tool_start.saturating_add(tool_name.len());
7259 }
7260 }
7261
7262 calls
7263}
7264
7265fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
7266 if input.as_bytes().get(open_paren).copied()? != b'(' {
7267 return None;
7268 }
7269
7270 let mut depth = 0usize;
7271 let mut in_single = false;
7272 let mut in_double = false;
7273 let mut escaped = false;
7274
7275 for (offset, ch) in input.get(open_paren..)?.char_indices() {
7276 if escaped {
7277 escaped = false;
7278 continue;
7279 }
7280 if ch == '\\' && (in_single || in_double) {
7281 escaped = true;
7282 continue;
7283 }
7284 if ch == '\'' && !in_double {
7285 in_single = !in_single;
7286 continue;
7287 }
7288 if ch == '"' && !in_single {
7289 in_double = !in_double;
7290 continue;
7291 }
7292 if in_single || in_double {
7293 continue;
7294 }
7295
7296 match ch {
7297 '(' => depth += 1,
7298 ')' => {
7299 depth = depth.saturating_sub(1);
7300 if depth == 0 {
7301 return Some(open_paren + offset);
7302 }
7303 }
7304 _ => {}
7305 }
7306 }
7307
7308 None
7309}
7310
7311fn parse_function_style_args(input: &str) -> Map<String, Value> {
7312 let mut args = Map::new();
7313 if input.trim().is_empty() {
7314 return args;
7315 }
7316
7317 let mut parts = Vec::<String>::new();
7318 let mut current = String::new();
7319 let mut in_single = false;
7320 let mut in_double = false;
7321 let mut escaped = false;
7322 let mut depth_paren = 0usize;
7323 let mut depth_bracket = 0usize;
7324 let mut depth_brace = 0usize;
7325
7326 for ch in input.chars() {
7327 if escaped {
7328 current.push(ch);
7329 escaped = false;
7330 continue;
7331 }
7332 if ch == '\\' && (in_single || in_double) {
7333 current.push(ch);
7334 escaped = true;
7335 continue;
7336 }
7337 if ch == '\'' && !in_double {
7338 in_single = !in_single;
7339 current.push(ch);
7340 continue;
7341 }
7342 if ch == '"' && !in_single {
7343 in_double = !in_double;
7344 current.push(ch);
7345 continue;
7346 }
7347 if in_single || in_double {
7348 current.push(ch);
7349 continue;
7350 }
7351
7352 match ch {
7353 '(' => depth_paren += 1,
7354 ')' => depth_paren = depth_paren.saturating_sub(1),
7355 '[' => depth_bracket += 1,
7356 ']' => depth_bracket = depth_bracket.saturating_sub(1),
7357 '{' => depth_brace += 1,
7358 '}' => depth_brace = depth_brace.saturating_sub(1),
7359 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
7360 let part = current.trim();
7361 if !part.is_empty() {
7362 parts.push(part.to_string());
7363 }
7364 current.clear();
7365 continue;
7366 }
7367 _ => {}
7368 }
7369 current.push(ch);
7370 }
7371 let tail = current.trim();
7372 if !tail.is_empty() {
7373 parts.push(tail.to_string());
7374 }
7375
7376 for part in parts {
7377 let Some((raw_key, raw_value)) = part
7378 .split_once('=')
7379 .or_else(|| part.split_once(':'))
7380 .map(|(k, v)| (k.trim(), v.trim()))
7381 else {
7382 continue;
7383 };
7384 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
7385 if key.is_empty() {
7386 continue;
7387 }
7388 if !is_valid_function_style_key(key) {
7389 continue;
7390 }
7391 let value = parse_scalar_like_value(raw_value);
7392 args.insert(key.to_string(), value);
7393 }
7394
7395 args
7396}
7397
7398fn is_valid_function_style_key(key: &str) -> bool {
7399 let mut chars = key.chars();
7403 let Some(first) = chars.next() else {
7404 return false;
7405 };
7406 if !(first.is_ascii_alphanumeric() || first == '_') {
7407 return false;
7408 }
7409 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
7410}
7411
7412fn parse_scalar_like_value(raw: &str) -> Value {
7413 let trimmed = raw.trim();
7414 if trimmed.is_empty() {
7415 return Value::Null;
7416 }
7417
7418 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
7419 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
7420 {
7421 if trimmed.len() < 2 {
7422 return Value::String(trimmed.to_string());
7423 }
7424 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
7425 }
7426
7427 if trimmed.eq_ignore_ascii_case("true") {
7428 return Value::Bool(true);
7429 }
7430 if trimmed.eq_ignore_ascii_case("false") {
7431 return Value::Bool(false);
7432 }
7433 if trimmed.eq_ignore_ascii_case("null") {
7434 return Value::Null;
7435 }
7436
7437 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
7438 return v;
7439 }
7440 if let Ok(v) = trimmed.parse::<i64>() {
7441 return Value::Number(Number::from(v));
7442 }
7443 if let Ok(v) = trimmed.parse::<f64>() {
7444 if let Some(n) = Number::from_f64(v) {
7445 return Value::Number(n);
7446 }
7447 }
7448
7449 Value::String(trimmed.to_string())
7450}
7451
7452fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
7453 let content = extract_loose_json_string_field(raw, "content")?;
7454 let mut obj = Map::new();
7455 if let Some(path) = extract_loose_json_string_field(raw, "path") {
7456 obj.insert("path".to_string(), Value::String(path));
7457 }
7458 obj.insert("content".to_string(), Value::String(content));
7459 Some(Value::Object(obj))
7460}
7461
7462fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
7463 let pattern = format!("\"{key}\"");
7464 let start = input.find(&pattern)?;
7465 let remainder = input.get(start + pattern.len()..)?;
7466 let colon = remainder.find(':')?;
7467 let value = remainder.get(colon + 1..)?.trim_start();
7468 let value = value.strip_prefix('"')?;
7469 Some(parse_loose_json_string_value(value))
7470}
7471
7472fn parse_loose_json_string_value(input: &str) -> String {
7473 let mut out = String::new();
7474 let mut chars = input.chars().peekable();
7475 let mut closed = false;
7476
7477 while let Some(ch) = chars.next() {
7478 if ch == '"' {
7479 closed = true;
7480 break;
7481 }
7482 if ch != '\\' {
7483 out.push(ch);
7484 continue;
7485 }
7486
7487 let Some(escaped) = chars.next() else {
7488 out.push('\\');
7489 break;
7490 };
7491 match escaped {
7492 '"' => out.push('"'),
7493 '\\' => out.push('\\'),
7494 '/' => out.push('/'),
7495 'b' => out.push('\u{0008}'),
7496 'f' => out.push('\u{000C}'),
7497 'n' => out.push('\n'),
7498 'r' => out.push('\r'),
7499 't' => out.push('\t'),
7500 'u' => {
7501 let mut hex = String::new();
7502 for _ in 0..4 {
7503 let Some(next) = chars.next() else {
7504 break;
7505 };
7506 hex.push(next);
7507 }
7508 if hex.len() == 4 {
7509 if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
7510 if let Some(decoded) = char::from_u32(codepoint as u32) {
7511 out.push(decoded);
7512 continue;
7513 }
7514 }
7515 }
7516 out.push('\\');
7517 out.push('u');
7518 out.push_str(&hex);
7519 }
7520 other => {
7521 out.push('\\');
7522 out.push(other);
7523 }
7524 }
7525 }
7526
7527 if !closed {
7528 return out;
7529 }
7530 out
7531}
7532
7533fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
7534 if is_todo_status_update_args(&args) {
7535 return args;
7536 }
7537
7538 let mut obj = match args {
7539 Value::Object(map) => map,
7540 Value::Array(items) => {
7541 return json!({ "todos": normalize_todo_arg_items(items) });
7542 }
7543 Value::String(text) => {
7544 let derived = extract_todo_candidates_from_text(&text);
7545 if !derived.is_empty() {
7546 return json!({ "todos": derived });
7547 }
7548 return json!({});
7549 }
7550 _ => return json!({}),
7551 };
7552
7553 if obj
7554 .get("todos")
7555 .and_then(|v| v.as_array())
7556 .map(|arr| !arr.is_empty())
7557 .unwrap_or(false)
7558 {
7559 return Value::Object(obj);
7560 }
7561
7562 for alias in ["tasks", "items", "list", "checklist"] {
7563 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
7564 let normalized = normalize_todo_arg_items(items.clone());
7565 if !normalized.is_empty() {
7566 obj.insert("todos".to_string(), Value::Array(normalized));
7567 return Value::Object(obj);
7568 }
7569 }
7570 }
7571
7572 let derived = extract_todo_candidates_from_text(completion);
7573 if !derived.is_empty() {
7574 obj.insert("todos".to_string(), Value::Array(derived));
7575 }
7576 Value::Object(obj)
7577}
7578
7579fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
7580 items
7581 .into_iter()
7582 .filter_map(|item| match item {
7583 Value::String(text) => {
7584 let content = text.trim();
7585 if content.is_empty() {
7586 None
7587 } else {
7588 Some(json!({"content": content}))
7589 }
7590 }
7591 Value::Object(mut obj) => {
7592 if !obj.contains_key("content") {
7593 if let Some(text) = obj.get("text").cloned() {
7594 obj.insert("content".to_string(), text);
7595 } else if let Some(title) = obj.get("title").cloned() {
7596 obj.insert("content".to_string(), title);
7597 } else if let Some(name) = obj.get("name").cloned() {
7598 obj.insert("content".to_string(), name);
7599 }
7600 }
7601 let content = obj
7602 .get("content")
7603 .and_then(|v| v.as_str())
7604 .map(str::trim)
7605 .unwrap_or("");
7606 if content.is_empty() {
7607 None
7608 } else {
7609 Some(Value::Object(obj))
7610 }
7611 }
7612 _ => None,
7613 })
7614 .collect()
7615}
7616
7617fn is_todo_status_update_args(args: &Value) -> bool {
7618 let Some(obj) = args.as_object() else {
7619 return false;
7620 };
7621 let has_status = obj
7622 .get("status")
7623 .and_then(|v| v.as_str())
7624 .map(|s| !s.trim().is_empty())
7625 .unwrap_or(false);
7626 let has_target =
7627 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
7628 has_status && has_target
7629}
7630
7631fn is_empty_todo_write_args(args: &Value) -> bool {
7632 if is_todo_status_update_args(args) {
7633 return false;
7634 }
7635 let Some(obj) = args.as_object() else {
7636 return true;
7637 };
7638 !obj.get("todos")
7639 .and_then(|v| v.as_array())
7640 .map(|arr| !arr.is_empty())
7641 .unwrap_or(false)
7642}
7643
7644fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
7645 let trimmed = raw_args.trim();
7646 if trimmed.is_empty() {
7647 return json!({});
7648 }
7649
7650 let normalized_tool = normalize_tool_name(tool_name);
7651 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
7652 return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
7653 }
7654
7655 if normalized_tool == "write" {
7656 if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
7657 return recovered;
7658 }
7659 }
7660
7661 let kv_args = parse_function_style_args(trimmed);
7664 if !kv_args.is_empty() {
7665 return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
7666 }
7667
7668 if normalized_tool == "websearch" {
7669 if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
7670 return json!({ "query": query });
7671 }
7672 return json!({});
7673 }
7674
7675 Value::String(trimmed.to_string())
7676}
7677
7678fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
7679 let normalized_tool = normalize_tool_name(tool_name);
7680 if normalized_tool != "websearch" {
7681 return parsed;
7682 }
7683
7684 match parsed {
7685 Value::Object(mut obj) => {
7686 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
7687 if let Some(query) = sanitize_websearch_query_candidate(raw) {
7688 obj.insert("query".to_string(), Value::String(query));
7689 }
7690 }
7691 Value::Object(obj)
7692 }
7693 Value::String(s) => match sanitize_websearch_query_candidate(&s) {
7694 Some(query) => json!({ "query": query }),
7695 None => json!({}),
7696 },
7697 other => other,
7698 }
7699}
7700
7701fn has_websearch_query(obj: &Map<String, Value>) -> bool {
7702 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
7703 QUERY_KEYS.iter().any(|key| {
7704 obj.get(*key)
7705 .and_then(|v| v.as_str())
7706 .map(|s| !s.trim().is_empty())
7707 .unwrap_or(false)
7708 })
7709}
7710
7711fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
7712 if let Some(obj) = value.as_object() {
7713 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
7714 return Some((
7715 normalize_tool_name(tool),
7716 obj.get("args").cloned().unwrap_or_else(|| json!({})),
7717 ));
7718 }
7719
7720 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
7721 let args = obj
7722 .get("args")
7723 .cloned()
7724 .or_else(|| obj.get("arguments").cloned())
7725 .unwrap_or_else(|| json!({}));
7726 let normalized_tool = normalize_tool_name(tool);
7727 let args = if let Some(raw) = args.as_str() {
7728 parse_streamed_tool_args(&normalized_tool, raw)
7729 } else {
7730 args
7731 };
7732 return Some((normalized_tool, args));
7733 }
7734
7735 for key in [
7736 "tool_call",
7737 "toolCall",
7738 "call",
7739 "function_call",
7740 "functionCall",
7741 ] {
7742 if let Some(nested) = obj.get(key) {
7743 if let Some(found) = extract_tool_call_from_value(nested) {
7744 return Some(found);
7745 }
7746 }
7747 }
7748
7749 if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
7750 for call in calls {
7751 if let Some(found) = extract_tool_call_from_value(call) {
7752 return Some(found);
7753 }
7754 }
7755 }
7756 }
7757
7758 if let Some(items) = value.as_array() {
7759 for item in items {
7760 if let Some(found) = extract_tool_call_from_value(item) {
7761 return Some(found);
7762 }
7763 }
7764 }
7765
7766 None
7767}
7768
7769fn extract_first_json_object(input: &str) -> Option<String> {
7770 let mut start = None;
7771 let mut depth = 0usize;
7772 for (idx, ch) in input.char_indices() {
7773 if ch == '{' {
7774 if start.is_none() {
7775 start = Some(idx);
7776 }
7777 depth += 1;
7778 } else if ch == '}' {
7779 if depth == 0 {
7780 continue;
7781 }
7782 depth -= 1;
7783 if depth == 0 {
7784 let begin = start?;
7785 let block = input.get(begin..=idx)?;
7786 return Some(block.to_string());
7787 }
7788 }
7789 }
7790 None
7791}
7792
7793fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
7794 let mut seen = HashSet::<String>::new();
7795 let mut todos = Vec::new();
7796
7797 for raw_line in input.lines() {
7798 let mut line = raw_line.trim();
7799 let mut structured_line = false;
7800 if line.is_empty() {
7801 continue;
7802 }
7803 if line.starts_with("```") {
7804 continue;
7805 }
7806 if line.ends_with(':') {
7807 continue;
7808 }
7809 if let Some(rest) = line
7810 .strip_prefix("- [ ]")
7811 .or_else(|| line.strip_prefix("* [ ]"))
7812 .or_else(|| line.strip_prefix("- [x]"))
7813 .or_else(|| line.strip_prefix("* [x]"))
7814 {
7815 line = rest.trim();
7816 structured_line = true;
7817 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
7818 line = rest.trim();
7819 structured_line = true;
7820 } else {
7821 let bytes = line.as_bytes();
7822 let mut i = 0usize;
7823 while i < bytes.len() && bytes[i].is_ascii_digit() {
7824 i += 1;
7825 }
7826 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
7827 line = line[i + 1..].trim();
7828 structured_line = true;
7829 }
7830 }
7831 if !structured_line {
7832 continue;
7833 }
7834
7835 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
7836 if content.len() < 5 || content.len() > 180 {
7837 continue;
7838 }
7839 let key = content.to_lowercase();
7840 if seen.contains(&key) {
7841 continue;
7842 }
7843 seen.insert(key);
7844 todos.push(json!({ "content": content }));
7845 if todos.len() >= 25 {
7846 break;
7847 }
7848 }
7849
7850 todos
7851}
7852
7853async fn emit_plan_todo_fallback(
7854 storage: std::sync::Arc<Storage>,
7855 bus: &EventBus,
7856 session_id: &str,
7857 message_id: &str,
7858 completion: &str,
7859) {
7860 let todos = extract_todo_candidates_from_text(completion);
7861 if todos.is_empty() {
7862 return;
7863 }
7864
7865 let invoke_part = WireMessagePart::tool_invocation(
7866 session_id,
7867 message_id,
7868 "todo_write",
7869 json!({"todos": todos.clone()}),
7870 );
7871 let call_id = invoke_part.id.clone();
7872 bus.publish(EngineEvent::new(
7873 "message.part.updated",
7874 json!({"part": invoke_part}),
7875 ));
7876
7877 if storage.set_todos(session_id, todos.clone()).await.is_err() {
7878 let mut failed_part = WireMessagePart::tool_result(
7879 session_id,
7880 message_id,
7881 "todo_write",
7882 Some(json!({"todos": todos.clone()})),
7883 json!(null),
7884 );
7885 failed_part.id = call_id;
7886 failed_part.state = Some("failed".to_string());
7887 failed_part.error = Some("failed to persist plan todos".to_string());
7888 bus.publish(EngineEvent::new(
7889 "message.part.updated",
7890 json!({"part": failed_part}),
7891 ));
7892 return;
7893 }
7894
7895 let normalized = storage.get_todos(session_id).await;
7896 let mut result_part = WireMessagePart::tool_result(
7897 session_id,
7898 message_id,
7899 "todo_write",
7900 Some(json!({"todos": todos.clone()})),
7901 json!({ "todos": normalized }),
7902 );
7903 result_part.id = call_id;
7904 bus.publish(EngineEvent::new(
7905 "message.part.updated",
7906 json!({"part": result_part}),
7907 ));
7908 bus.publish(EngineEvent::new(
7909 "todo.updated",
7910 json!({
7911 "sessionID": session_id,
7912 "todos": normalized
7913 }),
7914 ));
7915}
7916
7917async fn emit_plan_question_fallback(
7918 storage: std::sync::Arc<Storage>,
7919 bus: &EventBus,
7920 session_id: &str,
7921 message_id: &str,
7922 completion: &str,
7923) {
7924 let trimmed = completion.trim();
7925 if trimmed.is_empty() {
7926 return;
7927 }
7928
7929 let hints = extract_todo_candidates_from_text(trimmed)
7930 .into_iter()
7931 .take(6)
7932 .filter_map(|v| {
7933 v.get("content")
7934 .and_then(|c| c.as_str())
7935 .map(ToString::to_string)
7936 })
7937 .collect::<Vec<_>>();
7938
7939 let mut options = hints
7940 .iter()
7941 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
7942 .collect::<Vec<_>>();
7943 if options.is_empty() {
7944 options = vec![
7945 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
7946 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
7947 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
7948 ];
7949 }
7950
7951 let question_payload = vec![json!({
7952 "header":"Planning Input",
7953 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
7954 "options": options,
7955 "multiple": true,
7956 "custom": true
7957 })];
7958
7959 let request = storage
7960 .add_question_request(session_id, message_id, question_payload.clone())
7961 .await
7962 .ok();
7963 bus.publish(EngineEvent::new(
7964 "question.asked",
7965 json!({
7966 "id": request
7967 .as_ref()
7968 .map(|req| req.id.clone())
7969 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7970 "sessionID": session_id,
7971 "messageID": message_id,
7972 "questions": question_payload,
7973 "tool": request.and_then(|req| {
7974 req.tool.map(|tool| {
7975 json!({
7976 "callID": tool.call_id,
7977 "messageID": tool.message_id
7978 })
7979 })
7980 })
7981 }),
7982 ));
7983}
7984
7985#[derive(Debug, Clone, Copy)]
7986enum ChatHistoryProfile {
7987 Full,
7988 Standard,
7989 Compact,
7990}
7991
7992async fn load_chat_history(
7993 storage: std::sync::Arc<Storage>,
7994 session_id: &str,
7995 profile: ChatHistoryProfile,
7996) -> Vec<ChatMessage> {
7997 let Some(session) = storage.get_session(session_id).await else {
7998 return Vec::new();
7999 };
8000 let messages = session
8001 .messages
8002 .into_iter()
8003 .map(|m| {
8004 let role = format!("{:?}", m.role).to_lowercase();
8005 let content = m
8006 .parts
8007 .into_iter()
8008 .map(|part| match part {
8009 MessagePart::Text { text } => text,
8010 MessagePart::Reasoning { text } => text,
8011 MessagePart::ToolInvocation {
8012 tool,
8013 args,
8014 result,
8015 error,
8016 } => summarize_tool_invocation_for_history(
8017 &tool,
8018 &args,
8019 result.as_ref(),
8020 error.as_deref(),
8021 ),
8022 })
8023 .collect::<Vec<_>>()
8024 .join("\n");
8025 ChatMessage {
8026 role,
8027 content,
8028 attachments: Vec::new(),
8029 }
8030 })
8031 .collect::<Vec<_>>();
8032 compact_chat_history(messages, profile)
8033}
8034
8035fn summarize_tool_invocation_for_history(
8036 tool: &str,
8037 args: &Value,
8038 result: Option<&Value>,
8039 error: Option<&str>,
8040) -> String {
8041 let mut segments = vec![format!("Tool {tool}")];
8042 if !args.is_null()
8043 && !args.as_object().is_some_and(|value| value.is_empty())
8044 && !args
8045 .as_str()
8046 .map(|value| value.trim().is_empty())
8047 .unwrap_or(false)
8048 {
8049 segments.push(format!("args={args}"));
8050 }
8051 if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
8052 segments.push(format!("error={error}"));
8053 }
8054 if let Some(result) = result.filter(|value| !value.is_null()) {
8055 segments.push(format!("result={result}"));
8056 }
8057 if segments.len() == 1 {
8058 segments.push("result={}".to_string());
8059 }
8060 segments.join(" ")
8061}
8062
8063fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
8064 if attachments.is_empty() {
8065 return;
8066 }
8067 if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
8068 message.attachments = attachments.to_vec();
8069 }
8070}
8071
8072async fn build_runtime_attachments(
8073 provider_id: &str,
8074 parts: &[MessagePartInput],
8075) -> Vec<ChatAttachment> {
8076 if !supports_image_attachments(provider_id) {
8077 return Vec::new();
8078 }
8079
8080 let mut attachments = Vec::new();
8081 for part in parts {
8082 let MessagePartInput::File { mime, url, .. } = part else {
8083 continue;
8084 };
8085 if !mime.to_ascii_lowercase().starts_with("image/") {
8086 continue;
8087 }
8088 if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
8089 attachments.push(ChatAttachment::ImageUrl { url: source_url });
8090 }
8091 }
8092
8093 attachments
8094}
8095
8096fn supports_image_attachments(provider_id: &str) -> bool {
8097 matches!(
8098 provider_id,
8099 "openai"
8100 | "openai-codex"
8101 | "openrouter"
8102 | "ollama"
8103 | "groq"
8104 | "mistral"
8105 | "together"
8106 | "azure"
8107 | "bedrock"
8108 | "vertex"
8109 | "copilot"
8110 )
8111}
8112
8113async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
8114 let trimmed = url.trim();
8115 if trimmed.is_empty() {
8116 return None;
8117 }
8118 if trimmed.starts_with("http://")
8119 || trimmed.starts_with("https://")
8120 || trimmed.starts_with("data:")
8121 {
8122 return Some(trimmed.to_string());
8123 }
8124
8125 let file_path = trimmed
8126 .strip_prefix("file://")
8127 .map(PathBuf::from)
8128 .unwrap_or_else(|| PathBuf::from(trimmed));
8129 if !file_path.exists() {
8130 return None;
8131 }
8132
8133 let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
8134 .ok()
8135 .and_then(|v| v.parse::<usize>().ok())
8136 .unwrap_or(20 * 1024 * 1024);
8137
8138 let bytes = match tokio::fs::read(&file_path).await {
8139 Ok(bytes) => bytes,
8140 Err(err) => {
8141 tracing::warn!(
8142 "failed reading local attachment '{}': {}",
8143 file_path.to_string_lossy(),
8144 err
8145 );
8146 return None;
8147 }
8148 };
8149 if bytes.len() > max_bytes {
8150 tracing::warn!(
8151 "local attachment '{}' exceeds max bytes ({} > {})",
8152 file_path.to_string_lossy(),
8153 bytes.len(),
8154 max_bytes
8155 );
8156 return None;
8157 }
8158
8159 use base64::Engine as _;
8160 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
8161 Some(format!("data:{mime};base64,{b64}"))
8162}
8163
8164struct ToolSideEventContext<'a> {
8165 session_id: &'a str,
8166 message_id: &'a str,
8167 tool: &'a str,
8168 args: &'a serde_json::Value,
8169 metadata: &'a serde_json::Value,
8170 workspace_root: Option<&'a str>,
8171 effective_cwd: Option<&'a str>,
8172}
8173
8174async fn emit_tool_side_events(
8175 storage: std::sync::Arc<Storage>,
8176 bus: &EventBus,
8177 ctx: ToolSideEventContext<'_>,
8178) {
8179 let ToolSideEventContext {
8180 session_id,
8181 message_id,
8182 tool,
8183 args,
8184 metadata,
8185 workspace_root,
8186 effective_cwd,
8187 } = ctx;
8188 if tool == "todo_write" {
8189 let todos_from_metadata = metadata
8190 .get("todos")
8191 .and_then(|v| v.as_array())
8192 .cloned()
8193 .unwrap_or_default();
8194
8195 if !todos_from_metadata.is_empty() {
8196 let _ = storage.set_todos(session_id, todos_from_metadata).await;
8197 } else {
8198 let current = storage.get_todos(session_id).await;
8199 if let Some(updated) = apply_todo_updates_from_args(current, args) {
8200 let _ = storage.set_todos(session_id, updated).await;
8201 }
8202 }
8203
8204 let normalized = storage.get_todos(session_id).await;
8205 bus.publish(EngineEvent::new(
8206 "todo.updated",
8207 json!({
8208 "sessionID": session_id,
8209 "todos": normalized,
8210 "workspaceRoot": workspace_root,
8211 "effectiveCwd": effective_cwd
8212 }),
8213 ));
8214 }
8215 if tool == "question" {
8216 let questions = metadata
8217 .get("questions")
8218 .and_then(|v| v.as_array())
8219 .cloned()
8220 .unwrap_or_default();
8221 if questions.is_empty() {
8222 tracing::warn!(
8223 "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
8224 session_id,
8225 message_id
8226 );
8227 } else {
8228 let request = storage
8229 .add_question_request(session_id, message_id, questions.clone())
8230 .await
8231 .ok();
8232 bus.publish(EngineEvent::new(
8233 "question.asked",
8234 json!({
8235 "id": request
8236 .as_ref()
8237 .map(|req| req.id.clone())
8238 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
8239 "sessionID": session_id,
8240 "messageID": message_id,
8241 "questions": questions,
8242 "tool": request.and_then(|req| {
8243 req.tool.map(|tool| {
8244 json!({
8245 "callID": tool.call_id,
8246 "messageID": tool.message_id
8247 })
8248 })
8249 }),
8250 "workspaceRoot": workspace_root,
8251 "effectiveCwd": effective_cwd
8252 }),
8253 ));
8254 }
8255 }
8256 if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
8257 for event in events {
8258 let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
8259 continue;
8260 };
8261 if !event_type.starts_with("agent_team.") {
8262 continue;
8263 }
8264 let mut properties = event
8265 .get("properties")
8266 .and_then(|v| v.as_object())
8267 .cloned()
8268 .unwrap_or_default();
8269 properties
8270 .entry("sessionID".to_string())
8271 .or_insert(json!(session_id));
8272 properties
8273 .entry("messageID".to_string())
8274 .or_insert(json!(message_id));
8275 properties
8276 .entry("workspaceRoot".to_string())
8277 .or_insert(json!(workspace_root));
8278 properties
8279 .entry("effectiveCwd".to_string())
8280 .or_insert(json!(effective_cwd));
8281 bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
8282 }
8283 }
8284}
8285
8286fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
8287 let obj = args.as_object()?;
8288 let mut todos = current;
8289 let mut changed = false;
8290
8291 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
8292 for item in items {
8293 let Some(item_obj) = item.as_object() else {
8294 continue;
8295 };
8296 let status = item_obj
8297 .get("status")
8298 .and_then(|v| v.as_str())
8299 .map(normalize_todo_status);
8300 let target = item_obj
8301 .get("task_id")
8302 .or_else(|| item_obj.get("todo_id"))
8303 .or_else(|| item_obj.get("id"));
8304
8305 if let (Some(status), Some(target)) = (status, target) {
8306 changed |= apply_single_todo_status_update(&mut todos, target, &status);
8307 }
8308 }
8309 }
8310
8311 let status = obj
8312 .get("status")
8313 .and_then(|v| v.as_str())
8314 .map(normalize_todo_status);
8315 let target = obj
8316 .get("task_id")
8317 .or_else(|| obj.get("todo_id"))
8318 .or_else(|| obj.get("id"));
8319 if let (Some(status), Some(target)) = (status, target) {
8320 changed |= apply_single_todo_status_update(&mut todos, target, &status);
8321 }
8322
8323 if changed {
8324 Some(todos)
8325 } else {
8326 None
8327 }
8328}
8329
8330fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
8331 let idx_from_value = match target {
8332 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
8333 Value::String(s) => {
8334 let trimmed = s.trim();
8335 trimmed
8336 .parse::<usize>()
8337 .ok()
8338 .map(|v| v.saturating_sub(1))
8339 .or_else(|| {
8340 let digits = trimmed
8341 .chars()
8342 .rev()
8343 .take_while(|c| c.is_ascii_digit())
8344 .collect::<String>()
8345 .chars()
8346 .rev()
8347 .collect::<String>();
8348 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
8349 })
8350 }
8351 _ => None,
8352 };
8353
8354 if let Some(idx) = idx_from_value {
8355 if idx < todos.len() {
8356 if let Some(obj) = todos[idx].as_object_mut() {
8357 obj.insert("status".to_string(), Value::String(status.to_string()));
8358 return true;
8359 }
8360 }
8361 }
8362
8363 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
8364 if let Some(id_target) = id_target {
8365 for todo in todos.iter_mut() {
8366 if let Some(obj) = todo.as_object_mut() {
8367 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
8368 obj.insert("status".to_string(), Value::String(status.to_string()));
8369 return true;
8370 }
8371 }
8372 }
8373 }
8374
8375 false
8376}
8377
8378fn normalize_todo_status(raw: &str) -> String {
8379 match raw.trim().to_lowercase().as_str() {
8380 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
8381 "done" | "complete" | "completed" => "completed".to_string(),
8382 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
8383 "open" | "todo" | "pending" => "pending".to_string(),
8384 other => other.to_string(),
8385 }
8386}
8387
8388fn compact_chat_history(
8389 messages: Vec<ChatMessage>,
8390 profile: ChatHistoryProfile,
8391) -> Vec<ChatMessage> {
8392 let (max_context_chars, keep_recent_messages) = match profile {
8393 ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
8394 ChatHistoryProfile::Standard => (80_000usize, 40usize),
8395 ChatHistoryProfile::Compact => (12_000usize, 12usize),
8396 };
8397
8398 if messages.len() <= keep_recent_messages {
8399 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
8400 if total_chars <= max_context_chars {
8401 return messages;
8402 }
8403 }
8404
8405 let mut kept = messages;
8406 let mut dropped_count = 0usize;
8407 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
8408
8409 while kept.len() > keep_recent_messages || total_chars > max_context_chars {
8410 if kept.is_empty() {
8411 break;
8412 }
8413 let removed = kept.remove(0);
8414 total_chars = total_chars.saturating_sub(removed.content.len());
8415 dropped_count += 1;
8416 }
8417
8418 if dropped_count > 0 {
8419 kept.insert(
8420 0,
8421 ChatMessage {
8422 role: "system".to_string(),
8423 content: format!(
8424 "[history compacted: omitted {} older messages to fit context window]",
8425 dropped_count
8426 ),
8427 attachments: Vec::new(),
8428 },
8429 );
8430 }
8431 kept
8432}
8433
8434#[cfg(test)]
8435mod tests {
8436 use super::*;
8437 use crate::{EventBus, Storage};
8438 use std::sync::{Mutex, OnceLock};
8439 use tandem_types::Session;
8440 use uuid::Uuid;
8441
8442 fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
8443 static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
8444 ENV_TEST_LOCK
8445 .get_or_init(|| Mutex::new(()))
8446 .lock()
8447 .expect("env test lock")
8448 }
8449
8450 #[tokio::test]
8451 async fn todo_updated_event_is_normalized() {
8452 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8453 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8454 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8455 let session_id = session.id.clone();
8456 storage.save_session(session).await.expect("save session");
8457
8458 let bus = EventBus::new();
8459 let mut rx = bus.subscribe();
8460 emit_tool_side_events(
8461 storage.clone(),
8462 &bus,
8463 ToolSideEventContext {
8464 session_id: &session_id,
8465 message_id: "m1",
8466 tool: "todo_write",
8467 args: &json!({"todos":[{"content":"ship parity"}]}),
8468 metadata: &json!({"todos":[{"content":"ship parity"}]}),
8469 workspace_root: Some("."),
8470 effective_cwd: Some("."),
8471 },
8472 )
8473 .await;
8474
8475 let event = rx.recv().await.expect("event");
8476 assert_eq!(event.event_type, "todo.updated");
8477 let todos = event
8478 .properties
8479 .get("todos")
8480 .and_then(|v| v.as_array())
8481 .cloned()
8482 .unwrap_or_default();
8483 assert_eq!(todos.len(), 1);
8484 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
8485 assert_eq!(
8486 todos[0].get("content").and_then(|v| v.as_str()),
8487 Some("ship parity")
8488 );
8489 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
8490 }
8491
8492 #[tokio::test]
8493 async fn question_asked_event_contains_tool_reference() {
8494 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8495 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8496 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8497 let session_id = session.id.clone();
8498 storage.save_session(session).await.expect("save session");
8499
8500 let bus = EventBus::new();
8501 let mut rx = bus.subscribe();
8502 emit_tool_side_events(
8503 storage,
8504 &bus,
8505 ToolSideEventContext {
8506 session_id: &session_id,
8507 message_id: "msg-1",
8508 tool: "question",
8509 args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8510 metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8511 workspace_root: Some("."),
8512 effective_cwd: Some("."),
8513 },
8514 )
8515 .await;
8516
8517 let event = rx.recv().await.expect("event");
8518 assert_eq!(event.event_type, "question.asked");
8519 assert_eq!(
8520 event
8521 .properties
8522 .get("sessionID")
8523 .and_then(|v| v.as_str())
8524 .unwrap_or(""),
8525 session_id
8526 );
8527 let tool = event
8528 .properties
8529 .get("tool")
8530 .cloned()
8531 .unwrap_or_else(|| json!({}));
8532 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
8533 assert_eq!(
8534 tool.get("messageID").and_then(|v| v.as_str()),
8535 Some("msg-1")
8536 );
8537 }
8538
8539 #[test]
8540 fn compact_chat_history_keeps_recent_and_inserts_summary() {
8541 let mut messages = Vec::new();
8542 for i in 0..60 {
8543 messages.push(ChatMessage {
8544 role: "user".to_string(),
8545 content: format!("message-{i}"),
8546 attachments: Vec::new(),
8547 });
8548 }
8549 let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
8550 assert!(compacted.len() <= 41);
8551 assert_eq!(compacted[0].role, "system");
8552 assert!(compacted[0].content.contains("history compacted"));
8553 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
8554 }
8555
8556 #[tokio::test]
8557 async fn load_chat_history_preserves_tool_args_and_error_context() {
8558 let base = std::env::temp_dir().join(format!(
8559 "tandem-core-load-chat-history-error-{}",
8560 uuid::Uuid::new_v4()
8561 ));
8562 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8563 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8564 let session_id = session.id.clone();
8565 storage.save_session(session).await.expect("save session");
8566
8567 let message = Message::new(
8568 MessageRole::User,
8569 vec![
8570 MessagePart::Text {
8571 text: "build the page".to_string(),
8572 },
8573 MessagePart::ToolInvocation {
8574 tool: "write".to_string(),
8575 args: json!({"path":"game.html","content":"<html>draft</html>"}),
8576 result: None,
8577 error: Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
8578 },
8579 ],
8580 );
8581 storage
8582 .append_message(&session_id, message)
8583 .await
8584 .expect("append message");
8585
8586 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8587 let content = history
8588 .iter()
8589 .find(|message| message.role == "user")
8590 .map(|message| message.content.clone())
8591 .unwrap_or_default();
8592 assert!(content.contains("build the page"));
8593 assert!(content.contains("Tool write"));
8594 assert!(content.contains(r#"args={"content":"<html>draft</html>","path":"game.html"}"#));
8595 assert!(content.contains("error=WRITE_ARGS_EMPTY_FROM_PROVIDER"));
8596 }
8597
8598 #[tokio::test]
8599 async fn load_chat_history_preserves_tool_args_and_result_context() {
8600 let base = std::env::temp_dir().join(format!(
8601 "tandem-core-load-chat-history-result-{}",
8602 uuid::Uuid::new_v4()
8603 ));
8604 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8605 let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8606 let session_id = session.id.clone();
8607 storage.save_session(session).await.expect("save session");
8608
8609 let message = Message::new(
8610 MessageRole::Assistant,
8611 vec![MessagePart::ToolInvocation {
8612 tool: "glob".to_string(),
8613 args: json!({"pattern":"src/**/*.rs"}),
8614 result: Some(json!({"output":"src/lib.rs\nsrc/main.rs"})),
8615 error: None,
8616 }],
8617 );
8618 storage
8619 .append_message(&session_id, message)
8620 .await
8621 .expect("append message");
8622
8623 let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8624 let content = history
8625 .iter()
8626 .find(|message| message.role == "assistant")
8627 .map(|message| message.content.clone())
8628 .unwrap_or_default();
8629 assert!(content.contains("Tool glob"));
8630 assert!(content.contains(r#"args={"pattern":"src/**/*.rs"}"#));
8631 assert!(content.contains(r#"result={"output":"src/lib.rs\nsrc/main.rs"}"#));
8632 }
8633
8634 #[test]
8635 fn extracts_todos_from_checklist_and_numbered_lines() {
8636 let input = r#"
8637Plan:
8638- [ ] Audit current implementation
8639- [ ] Add planner fallback
86401. Add regression test coverage
8641"#;
8642 let todos = extract_todo_candidates_from_text(input);
8643 assert_eq!(todos.len(), 3);
8644 assert_eq!(
8645 todos[0].get("content").and_then(|v| v.as_str()),
8646 Some("Audit current implementation")
8647 );
8648 }
8649
8650 #[test]
8651 fn does_not_extract_todos_from_plain_prose_lines() {
8652 let input = r#"
8653I need more information to proceed.
8654Can you tell me the event size and budget?
8655Once I have that, I can provide a detailed plan.
8656"#;
8657 let todos = extract_todo_candidates_from_text(input);
8658 assert!(todos.is_empty());
8659 }
8660
8661 #[test]
8662 fn parses_wrapped_tool_call_from_markdown_response() {
8663 let input = r#"
8664Here is the tool call:
8665```json
8666{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
8667```
8668"#;
8669 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
8670 assert_eq!(parsed.0, "todo_write");
8671 assert!(parsed.1.get("todos").is_some());
8672 }
8673
8674 #[test]
8675 fn parses_top_level_name_args_tool_call() {
8676 let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
8677 let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
8678 assert_eq!(parsed.0, "bash");
8679 assert_eq!(
8680 parsed.1.get("command").and_then(|v| v.as_str()),
8681 Some("echo hi")
8682 );
8683 }
8684
8685 #[test]
8686 fn parses_function_style_todowrite_call() {
8687 let input = r#"Status: Completed
8688Call: todowrite(task_id=2, status="completed")"#;
8689 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
8690 assert_eq!(parsed.0, "todo_write");
8691 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8692 assert_eq!(
8693 parsed.1.get("status").and_then(|v| v.as_str()),
8694 Some("completed")
8695 );
8696 }
8697
8698 #[test]
8699 fn parses_multiple_function_style_todowrite_calls() {
8700 let input = r#"
8701Call: todowrite(task_id=2, status="completed")
8702Call: todowrite(task_id=3, status="in_progress")
8703"#;
8704 let parsed = parse_tool_invocations_from_response(input);
8705 assert_eq!(parsed.len(), 2);
8706 assert_eq!(parsed[0].0, "todo_write");
8707 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8708 assert_eq!(
8709 parsed[0].1.get("status").and_then(|v| v.as_str()),
8710 Some("completed")
8711 );
8712 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
8713 assert_eq!(
8714 parsed[1].1.get("status").and_then(|v| v.as_str()),
8715 Some("in_progress")
8716 );
8717 }
8718
8719 #[test]
8720 fn applies_todo_status_update_from_task_id_args() {
8721 let current = vec![
8722 json!({"id":"todo-1","content":"a","status":"pending"}),
8723 json!({"id":"todo-2","content":"b","status":"pending"}),
8724 json!({"id":"todo-3","content":"c","status":"pending"}),
8725 ];
8726 let updated =
8727 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
8728 .expect("status update");
8729 assert_eq!(
8730 updated[1].get("status").and_then(|v| v.as_str()),
8731 Some("completed")
8732 );
8733 }
8734
8735 #[test]
8736 fn normalizes_todo_write_tasks_alias() {
8737 let normalized = normalize_todo_write_args(
8738 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
8739 "",
8740 );
8741 let todos = normalized
8742 .get("todos")
8743 .and_then(|v| v.as_array())
8744 .cloned()
8745 .unwrap_or_default();
8746 assert_eq!(todos.len(), 2);
8747 assert_eq!(
8748 todos[0].get("content").and_then(|v| v.as_str()),
8749 Some("Book venue")
8750 );
8751 assert_eq!(
8752 todos[1].get("content").and_then(|v| v.as_str()),
8753 Some("Send invites")
8754 );
8755 }
8756
8757 #[test]
8758 fn normalizes_todo_write_from_completion_when_args_empty() {
8759 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
8760 let normalized = normalize_todo_write_args(json!({}), completion);
8761 let todos = normalized
8762 .get("todos")
8763 .and_then(|v| v.as_array())
8764 .cloned()
8765 .unwrap_or_default();
8766 assert_eq!(todos.len(), 3);
8767 assert!(!is_empty_todo_write_args(&normalized));
8768 }
8769
8770 #[test]
8771 fn empty_todo_write_args_allows_status_updates() {
8772 let args = json!({"task_id": 2, "status":"completed"});
8773 assert!(!is_empty_todo_write_args(&args));
8774 }
8775
8776 #[test]
8777 fn streamed_websearch_args_fallback_to_query_string() {
8778 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
8779 assert_eq!(
8780 parsed.get("query").and_then(|v| v.as_str()),
8781 Some("meaning of life")
8782 );
8783 }
8784
8785 #[test]
8786 fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
8787 assert_eq!(
8788 parse_scalar_like_value("\""),
8789 Value::String("\"".to_string())
8790 );
8791 assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
8792 }
8793
8794 #[test]
8795 fn streamed_websearch_stringified_json_args_are_unwrapped() {
8796 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
8797 assert_eq!(
8798 parsed.get("query").and_then(|v| v.as_str()),
8799 Some("donkey gestation period")
8800 );
8801 }
8802
8803 #[test]
8804 fn streamed_websearch_args_strip_arg_key_value_wrappers() {
8805 let parsed = parse_streamed_tool_args(
8806 "websearch",
8807 "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
8808 );
8809 assert_eq!(
8810 parsed.get("query").and_then(|v| v.as_str()),
8811 Some("taj card what is it benefits how to apply")
8812 );
8813 }
8814
8815 #[test]
8816 fn normalize_tool_args_websearch_infers_from_user_text() {
8817 let normalized =
8818 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
8819 assert_eq!(
8820 normalized.args.get("query").and_then(|v| v.as_str()),
8821 Some("meaning of life")
8822 );
8823 assert_eq!(normalized.args_source, "inferred_from_user");
8824 assert_eq!(normalized.args_integrity, "recovered");
8825 }
8826
8827 #[test]
8828 fn normalize_tool_args_websearch_keeps_existing_query() {
8829 let normalized = normalize_tool_args(
8830 "websearch",
8831 json!({"query":"already set"}),
8832 "web search should not override",
8833 "",
8834 );
8835 assert_eq!(
8836 normalized.args.get("query").and_then(|v| v.as_str()),
8837 Some("already set")
8838 );
8839 assert_eq!(normalized.args_source, "provider_json");
8840 assert_eq!(normalized.args_integrity, "ok");
8841 }
8842
8843 #[test]
8844 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
8845 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
8846 assert!(normalized.query.is_none());
8847 assert!(normalized.missing_terminal);
8848 assert_eq!(normalized.args_source, "missing");
8849 assert_eq!(normalized.args_integrity, "empty");
8850 }
8851
8852 #[test]
8853 fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
8854 let normalized = normalize_tool_args(
8855 "webfetch",
8856 json!({}),
8857 "Please fetch `https://docs.tandem.ac/` in markdown mode",
8858 "",
8859 );
8860 assert!(!normalized.missing_terminal);
8861 assert_eq!(
8862 normalized.args.get("url").and_then(|v| v.as_str()),
8863 Some("https://docs.tandem.ac/")
8864 );
8865 assert_eq!(normalized.args_source, "inferred_from_user");
8866 assert_eq!(normalized.args_integrity, "recovered");
8867 }
8868
8869 #[test]
8870 fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
8871 let normalized = normalize_tool_args(
8872 "webfetch",
8873 json!({"args":{"uri":"https://example.com/page"}}),
8874 "",
8875 "",
8876 );
8877 assert!(!normalized.missing_terminal);
8878 assert_eq!(
8879 normalized.args.get("url").and_then(|v| v.as_str()),
8880 Some("https://example.com/page")
8881 );
8882 assert_eq!(normalized.args_source, "provider_json");
8883 }
8884
8885 #[test]
8886 fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
8887 let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
8888 assert!(normalized.missing_terminal);
8889 assert_eq!(
8890 normalized.missing_terminal_reason.as_deref(),
8891 Some("WEBFETCH_URL_MISSING")
8892 );
8893 }
8894
8895 #[test]
8896 fn normalize_tool_args_answer_how_to_infers_task_from_user_prompt() {
8897 let user_text = "what is tandem and how do i use it?";
8898 let normalized =
8899 normalize_tool_args("mcp.tandem_mcp.answer_how_to", json!({}), user_text, "");
8900 assert!(!normalized.missing_terminal);
8901 assert_eq!(
8902 normalized.args.get("task").and_then(|v| v.as_str()),
8903 Some(user_text)
8904 );
8905 assert_eq!(normalized.args_source, "inferred_from_user");
8906 assert_eq!(normalized.args_integrity, "recovered");
8907 }
8908
8909 #[test]
8910 fn normalize_tool_args_answer_how_to_keeps_existing_task() {
8911 let normalized = normalize_tool_args(
8912 "mcp.tandem_mcp.answer_how_to",
8913 json!({"task":"install tandem locally"}),
8914 "different user prompt",
8915 "",
8916 );
8917 assert!(!normalized.missing_terminal);
8918 assert_eq!(
8919 normalized.args.get("task").and_then(|v| v.as_str()),
8920 Some("install tandem locally")
8921 );
8922 assert_eq!(normalized.args_source, "provider_json");
8923 assert_eq!(normalized.args_integrity, "ok");
8924 }
8925
8926 #[test]
8927 fn normalize_tool_args_search_docs_infers_query_from_user_prompt() {
8928 let user_text = "https://docs.tandem.ac/start-here/";
8929 let normalized =
8930 normalize_tool_args("mcp.tandem_mcp.search_docs", json!({}), user_text, "");
8931 assert!(!normalized.missing_terminal);
8932 assert_eq!(
8933 normalized.args.get("query").and_then(|v| v.as_str()),
8934 Some(user_text)
8935 );
8936 assert_eq!(normalized.args_source, "inferred_from_user");
8937 assert_eq!(normalized.args_integrity, "recovered");
8938 }
8939
8940 #[test]
8941 fn normalize_tool_args_search_docs_keeps_existing_query() {
8942 let normalized = normalize_tool_args(
8943 "mcp.tandem_mcp.search_docs",
8944 json!({"query":"oauth setup"}),
8945 "different user prompt",
8946 "",
8947 );
8948 assert!(!normalized.missing_terminal);
8949 assert_eq!(
8950 normalized.args.get("query").and_then(|v| v.as_str()),
8951 Some("oauth setup")
8952 );
8953 assert_eq!(normalized.args_source, "provider_json");
8954 assert_eq!(normalized.args_integrity, "ok");
8955 }
8956
8957 #[test]
8958 fn normalize_tool_args_get_doc_infers_path_from_user_url() {
8959 let user_text = "https://docs.tandem.ac/start-here/";
8960 let normalized = normalize_tool_args("mcp.tandem_mcp.get_doc", json!({}), user_text, "");
8961 assert!(!normalized.missing_terminal);
8962 assert_eq!(
8963 normalized.args.get("path").and_then(|v| v.as_str()),
8964 Some(user_text)
8965 );
8966 assert_eq!(normalized.args_source, "inferred_from_user");
8967 assert_eq!(normalized.args_integrity, "recovered");
8968 }
8969
8970 #[test]
8971 fn normalize_tool_args_get_doc_keeps_existing_path() {
8972 let normalized = normalize_tool_args(
8973 "mcp.tandem_mcp.get_doc",
8974 json!({"path":"/start-here/"}),
8975 "different user prompt",
8976 "",
8977 );
8978 assert!(!normalized.missing_terminal);
8979 assert_eq!(
8980 normalized.args.get("path").and_then(|v| v.as_str()),
8981 Some("/start-here/")
8982 );
8983 assert_eq!(normalized.args_source, "provider_json");
8984 assert_eq!(normalized.args_integrity, "ok");
8985 }
8986
8987 #[test]
8988 fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
8989 let user_text =
8990 "Create a pack that checks latest headline news every day at 8 AM and emails me.";
8991 let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
8992 assert!(!normalized.missing_terminal);
8993 assert_eq!(
8994 normalized.args.get("goal").and_then(|v| v.as_str()),
8995 Some(user_text)
8996 );
8997 assert_eq!(
8998 normalized.args.get("mode").and_then(|v| v.as_str()),
8999 Some("preview")
9000 );
9001 assert_eq!(normalized.args_source, "inferred_from_user");
9002 assert_eq!(normalized.args_integrity, "recovered");
9003 }
9004
9005 #[test]
9006 fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
9007 let normalized = normalize_tool_args(
9008 "pack_builder",
9009 json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
9010 "new goal should not override",
9011 "",
9012 );
9013 assert!(!normalized.missing_terminal);
9014 assert_eq!(
9015 normalized.args.get("goal").and_then(|v| v.as_str()),
9016 Some("existing goal")
9017 );
9018 assert_eq!(
9019 normalized.args.get("mode").and_then(|v| v.as_str()),
9020 Some("apply")
9021 );
9022 assert_eq!(normalized.args_source, "provider_json");
9023 assert_eq!(normalized.args_integrity, "ok");
9024 }
9025
9026 #[test]
9027 fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
9028 let assistant_context =
9029 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
9030 let normalized =
9031 normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
9032 assert!(!normalized.missing_terminal);
9033 assert_eq!(
9034 normalized.args.get("mode").and_then(|v| v.as_str()),
9035 Some("apply")
9036 );
9037 assert_eq!(
9038 normalized.args.get("plan_id").and_then(|v| v.as_str()),
9039 Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
9040 );
9041 assert_eq!(
9042 normalized
9043 .args
9044 .get("approve_pack_install")
9045 .and_then(|v| v.as_bool()),
9046 Some(true)
9047 );
9048 assert_eq!(normalized.args_source, "recovered_from_context");
9049 }
9050
9051 #[test]
9052 fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
9053 let assistant_context =
9054 "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
9055 let normalized = normalize_tool_args(
9056 "pack_builder",
9057 json!({"mode":"apply"}),
9058 "yes",
9059 assistant_context,
9060 );
9061 assert!(!normalized.missing_terminal);
9062 assert_eq!(
9063 normalized.args.get("mode").and_then(|v| v.as_str()),
9064 Some("apply")
9065 );
9066 assert_eq!(
9067 normalized.args.get("plan_id").and_then(|v| v.as_str()),
9068 Some("plan-11111111-2222-3333-4444-555555555555")
9069 );
9070 }
9071
9072 #[test]
9073 fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
9074 let assistant_context =
9075 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
9076 let normalized = normalize_tool_args(
9077 "pack_builder",
9078 json!({}),
9079 "create jira sync",
9080 assistant_context,
9081 );
9082 assert!(!normalized.missing_terminal);
9083 assert_eq!(
9084 normalized.args.get("mode").and_then(|v| v.as_str()),
9085 Some("preview")
9086 );
9087 assert_eq!(
9088 normalized.args.get("goal").and_then(|v| v.as_str()),
9089 Some("create jira sync")
9090 );
9091 }
9092
9093 #[test]
9094 fn normalize_tool_args_write_requires_path() {
9095 let normalized = normalize_tool_args("write", json!({}), "", "");
9096 assert!(normalized.missing_terminal);
9097 assert_eq!(
9098 normalized.missing_terminal_reason.as_deref(),
9099 Some("FILE_PATH_MISSING")
9100 );
9101 }
9102
9103 #[test]
9104 fn persisted_failed_tool_args_prefers_normalized_when_raw_is_empty() {
9105 let args = persisted_failed_tool_args(
9106 &json!({}),
9107 &json!({"path":"game.html","content":"<html></html>"}),
9108 );
9109 assert_eq!(args["path"], "game.html");
9110 assert_eq!(args["content"], "<html></html>");
9111 }
9112
9113 #[test]
9114 fn persisted_failed_tool_args_keeps_non_empty_raw_payload() {
9115 let args = persisted_failed_tool_args(
9116 &json!("path=game.html content"),
9117 &json!({"path":"game.html"}),
9118 );
9119 assert_eq!(args, json!("path=game.html content"));
9120 }
9121
9122 #[test]
9123 fn normalize_tool_args_write_recovers_alias_path_key() {
9124 let normalized = normalize_tool_args(
9125 "write",
9126 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
9127 "",
9128 "",
9129 );
9130 assert!(!normalized.missing_terminal);
9131 assert_eq!(
9132 normalized.args.get("path").and_then(|v| v.as_str()),
9133 Some("docs/CONCEPT.md")
9134 );
9135 assert_eq!(
9136 normalized.args.get("content").and_then(|v| v.as_str()),
9137 Some("hello")
9138 );
9139 }
9140
9141 #[test]
9142 fn normalize_tool_args_write_recovers_html_output_target_path() {
9143 let normalized = normalize_tool_args_with_mode(
9144 "write",
9145 json!({"content":"<html></html>"}),
9146 "Execute task.\n\nRequired output target:\n{\n \"path\": \"game.html\",\n \"kind\": \"source\",\n \"operation\": \"create_or_update\"\n}\n",
9147 "",
9148 WritePathRecoveryMode::OutputTargetOnly,
9149 );
9150 assert!(!normalized.missing_terminal);
9151 assert_eq!(
9152 normalized.args.get("path").and_then(|v| v.as_str()),
9153 Some("game.html")
9154 );
9155 }
9156
9157 #[test]
9158 fn normalize_tool_args_read_infers_path_from_user_prompt() {
9159 let normalized = normalize_tool_args(
9160 "read",
9161 json!({}),
9162 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
9163 "",
9164 );
9165 assert!(!normalized.missing_terminal);
9166 assert_eq!(
9167 normalized.args.get("path").and_then(|v| v.as_str()),
9168 Some("FEATURE_LIST.md")
9169 );
9170 assert_eq!(normalized.args_source, "inferred_from_user");
9171 assert_eq!(normalized.args_integrity, "recovered");
9172 }
9173
9174 #[test]
9175 fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
9176 let normalized = normalize_tool_args(
9177 "read",
9178 json!({}),
9179 "generic instruction",
9180 "I will read src-tauri/src/orchestrator/engine.rs first.",
9181 );
9182 assert!(normalized.missing_terminal);
9183 assert_eq!(
9184 normalized.missing_terminal_reason.as_deref(),
9185 Some("FILE_PATH_MISSING")
9186 );
9187 }
9188
9189 #[test]
9190 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
9191 let normalized = normalize_tool_args(
9192 "write",
9193 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
9194 "",
9195 "",
9196 );
9197 assert!(!normalized.missing_terminal);
9198 assert_eq!(
9199 normalized.args.get("path").and_then(|v| v.as_str()),
9200 Some("docs/CONCEPT.md")
9201 );
9202 }
9203
9204 #[test]
9205 fn normalize_tool_args_write_recovers_content_alias() {
9206 let normalized = normalize_tool_args(
9207 "write",
9208 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
9209 "",
9210 "",
9211 );
9212 assert!(!normalized.missing_terminal);
9213 assert_eq!(
9214 normalized.args.get("content").and_then(|v| v.as_str()),
9215 Some("feature notes")
9216 );
9217 }
9218
9219 #[test]
9220 fn normalize_tool_args_write_fails_when_content_missing() {
9221 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
9222 assert!(normalized.missing_terminal);
9223 assert_eq!(
9224 normalized.missing_terminal_reason.as_deref(),
9225 Some("WRITE_CONTENT_MISSING")
9226 );
9227 }
9228
9229 #[test]
9230 fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
9231 let normalized = normalize_tool_args_with_mode(
9232 "write",
9233 json!({}),
9234 "Please implement the screen/state structure in the workspace.",
9235 "",
9236 WritePathRecoveryMode::OutputTargetOnly,
9237 );
9238 assert!(normalized.missing_terminal);
9239 assert_eq!(
9240 normalized.missing_terminal_reason.as_deref(),
9241 Some("FILE_PATH_MISSING")
9242 );
9243 }
9244
9245 #[test]
9246 fn normalize_tool_args_write_output_target_only_recovers_from_dot_slash_path() {
9247 let normalized = normalize_tool_args_with_mode(
9248 "write",
9249 json!({"path":"./","content":"{}"}),
9250 "Required Workspace Output:\n- Create or update `.tandem/runs/automation-v2-run-123/artifacts/research-sources.json` relative to the workspace root.",
9251 "",
9252 WritePathRecoveryMode::OutputTargetOnly,
9253 );
9254 assert!(!normalized.missing_terminal);
9255 assert_eq!(
9256 normalized.args.get("path").and_then(|v| v.as_str()),
9257 Some(".tandem/runs/automation-v2-run-123/artifacts/research-sources.json")
9258 );
9259 }
9260
9261 #[test]
9262 fn normalize_tool_args_write_recovers_content_from_assistant_context() {
9263 let normalized = normalize_tool_args(
9264 "write",
9265 json!({"path":"docs/FEATURES.md"}),
9266 "",
9267 "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
9268 );
9269 assert!(!normalized.missing_terminal);
9270 assert_eq!(
9271 normalized.args.get("path").and_then(|v| v.as_str()),
9272 Some("docs/FEATURES.md")
9273 );
9274 assert_eq!(
9275 normalized.args.get("content").and_then(|v| v.as_str()),
9276 Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
9277 );
9278 assert_eq!(normalized.args_source, "recovered_from_context");
9279 assert_eq!(normalized.args_integrity, "recovered");
9280 }
9281
9282 #[test]
9283 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
9284 let normalized = normalize_tool_args(
9285 "write",
9286 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
9287 "",
9288 "",
9289 );
9290 assert!(!normalized.missing_terminal);
9291 assert_eq!(
9292 normalized.args.get("path").and_then(|v| v.as_str()),
9293 Some("docs/FEATURES.md")
9294 );
9295 assert_eq!(
9296 normalized.args.get("content").and_then(|v| v.as_str()),
9297 Some("Line 1\nLine 2")
9298 );
9299 }
9300
9301 #[test]
9302 fn normalize_tool_args_write_does_not_treat_path_as_content() {
9303 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
9304 assert!(normalized.missing_terminal);
9305 assert_eq!(
9306 normalized.missing_terminal_reason.as_deref(),
9307 Some("WRITE_CONTENT_MISSING")
9308 );
9309 }
9310
9311 #[test]
9312 fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
9313 let normalized = normalize_tool_args(
9314 "mcp.composio_1.gmail_send_email",
9315 json!({
9316 "to": "user123@example.com",
9317 "subject": "Test",
9318 "body": "Hello",
9319 "attachment": {
9320 "s3key": ""
9321 }
9322 }),
9323 "",
9324 "",
9325 );
9326 assert!(normalized.args.get("attachment").is_none());
9327 assert_eq!(normalized.args_source, "sanitized_attachment");
9328 }
9329
9330 #[test]
9331 fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
9332 let normalized = normalize_tool_args(
9333 "mcp.composio_1.gmail_send_email",
9334 json!({
9335 "to": "user123@example.com",
9336 "subject": "Test",
9337 "body": "Hello",
9338 "attachment": {
9339 "s3key": "file_123"
9340 }
9341 }),
9342 "",
9343 "",
9344 );
9345 assert_eq!(
9346 normalized
9347 .args
9348 .get("attachment")
9349 .and_then(|value| value.get("s3key"))
9350 .and_then(|value| value.as_str()),
9351 Some("file_123")
9352 );
9353 }
9354
9355 #[test]
9356 fn classify_required_tool_failure_detects_empty_provider_write_args() {
9357 let reason = classify_required_tool_failure(
9358 &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
9359 true,
9360 1,
9361 false,
9362 false,
9363 );
9364 assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
9365 }
9366
9367 #[test]
9368 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
9369 let normalized = normalize_tool_args(
9370 "read",
9371 json!({}),
9372 "Please read **FEATURE_LIST.md** and summarize.",
9373 "",
9374 );
9375 assert!(!normalized.missing_terminal);
9376 assert_eq!(
9377 normalized.args.get("path").and_then(|v| v.as_str()),
9378 Some("FEATURE_LIST.md")
9379 );
9380 }
9381
9382 #[test]
9383 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
9384 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
9385 assert!(!normalized.missing_terminal);
9386 assert_eq!(
9387 normalized.args.get("command").and_then(|v| v.as_str()),
9388 Some("rg -n \"TODO\" .")
9389 );
9390 assert_eq!(normalized.args_source, "inferred_from_user");
9391 assert_eq!(normalized.args_integrity, "recovered");
9392 }
9393
9394 #[test]
9395 fn normalize_tool_args_read_rejects_root_only_path() {
9396 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
9397 assert!(normalized.missing_terminal);
9398 assert_eq!(
9399 normalized.missing_terminal_reason.as_deref(),
9400 Some("FILE_PATH_MISSING")
9401 );
9402 }
9403
9404 #[test]
9405 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
9406 let normalized =
9407 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
9408 assert!(!normalized.missing_terminal);
9409 assert_eq!(
9410 normalized.args.get("path").and_then(|v| v.as_str()),
9411 Some("CONCEPT.md")
9412 );
9413 assert_eq!(normalized.args_source, "inferred_from_user");
9414 assert_eq!(normalized.args_integrity, "recovered");
9415 }
9416
9417 #[test]
9418 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
9419 let normalized = normalize_tool_args(
9420 "read",
9421 json!({
9422 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
9423 }),
9424 "",
9425 "",
9426 );
9427 assert!(normalized.missing_terminal);
9428 assert_eq!(
9429 normalized.missing_terminal_reason.as_deref(),
9430 Some("FILE_PATH_MISSING")
9431 );
9432 }
9433
9434 #[test]
9435 fn normalize_tool_args_read_rejects_glob_pattern_path() {
9436 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
9437 assert!(normalized.missing_terminal);
9438 assert_eq!(
9439 normalized.missing_terminal_reason.as_deref(),
9440 Some("FILE_PATH_MISSING")
9441 );
9442 }
9443
9444 #[test]
9445 fn normalize_tool_args_read_rejects_placeholder_path() {
9446 let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
9447 assert!(normalized.missing_terminal);
9448 assert_eq!(
9449 normalized.missing_terminal_reason.as_deref(),
9450 Some("FILE_PATH_MISSING")
9451 );
9452 }
9453
9454 #[test]
9455 fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
9456 let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
9457 assert!(normalized.missing_terminal);
9458 assert_eq!(
9459 normalized.missing_terminal_reason.as_deref(),
9460 Some("FILE_PATH_MISSING")
9461 );
9462 }
9463
9464 #[test]
9465 fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
9466 let normalized = normalize_tool_args(
9467 "read",
9468 json!({"path":"tool/policy"}),
9469 "Read `T1011U kitöltési útmutató.pdf` and summarize.",
9470 "",
9471 );
9472 assert!(!normalized.missing_terminal);
9473 assert_eq!(
9474 normalized.args.get("path").and_then(|v| v.as_str()),
9475 Some("T1011U kitöltési útmutató.pdf")
9476 );
9477 assert_eq!(normalized.args_source, "inferred_from_user");
9478 assert_eq!(normalized.args_integrity, "recovered");
9479 }
9480
9481 #[test]
9482 fn normalize_tool_name_strips_default_api_namespace() {
9483 assert_eq!(normalize_tool_name("default_api:read"), "read");
9484 assert_eq!(normalize_tool_name("functions.shell"), "bash");
9485 }
9486
9487 #[test]
9488 fn mcp_server_from_tool_name_parses_server_segment() {
9489 assert_eq!(
9490 mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
9491 Some("arcade")
9492 );
9493 assert_eq!(mcp_server_from_tool_name("read"), None);
9494 assert_eq!(mcp_server_from_tool_name("mcp"), None);
9495 }
9496
9497 #[test]
9498 fn mcp_tools_are_exempt_from_workspace_sandbox_path_checks() {
9499 assert!(is_mcp_tool_name("mcp_list"));
9500 assert!(is_mcp_tool_name("mcp.tandem_mcp.get_doc"));
9501 assert!(is_mcp_tool_name("MCP.TANDEM_MCP.GET_DOC"));
9502 assert!(!is_mcp_tool_name("read"));
9503 assert!(!is_mcp_tool_name("glob"));
9504 }
9505
9506 #[test]
9507 fn batch_helpers_use_name_when_tool_is_wrapper() {
9508 let args = json!({
9509 "tool_calls":[
9510 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
9511 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
9512 ]
9513 });
9514 let calls = extract_batch_calls(&args);
9515 assert_eq!(calls.len(), 2);
9516 assert_eq!(calls[0].0, "read");
9517 assert_eq!(calls[1].0, "glob");
9518 assert!(is_read_only_batch_call(&args));
9519 let sig = batch_tool_signature(&args).unwrap_or_default();
9520 assert!(sig.contains("read:"));
9521 assert!(sig.contains("glob:"));
9522 }
9523
9524 #[test]
9525 fn batch_helpers_resolve_nested_function_name() {
9526 let args = json!({
9527 "tool_calls":[
9528 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
9529 ]
9530 });
9531 let calls = extract_batch_calls(&args);
9532 assert_eq!(calls.len(), 1);
9533 assert_eq!(calls[0].0, "read");
9534 assert!(is_read_only_batch_call(&args));
9535 }
9536
9537 #[test]
9538 fn batch_output_classifier_detects_non_productive_unknown_results() {
9539 let output = r#"
9540[
9541 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
9542 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
9543]
9544"#;
9545 assert!(is_non_productive_batch_output(output));
9546 }
9547
9548 #[test]
9549 fn runtime_prompt_includes_execution_environment_block() {
9550 let prompt = tandem_runtime_system_prompt(
9551 &HostRuntimeContext {
9552 os: HostOs::Windows,
9553 arch: "x86_64".to_string(),
9554 shell_family: ShellFamily::Powershell,
9555 path_style: PathStyle::Windows,
9556 },
9557 &[],
9558 );
9559 assert!(prompt.contains("[Execution Environment]"));
9560 assert!(prompt.contains("Host OS: windows"));
9561 assert!(prompt.contains("Shell: powershell"));
9562 assert!(prompt.contains("Path style: windows"));
9563 }
9564
9565 #[test]
9566 fn runtime_prompt_includes_connected_integrations_block() {
9567 let prompt = tandem_runtime_system_prompt(
9568 &HostRuntimeContext {
9569 os: HostOs::Linux,
9570 arch: "x86_64".to_string(),
9571 shell_family: ShellFamily::Posix,
9572 path_style: PathStyle::Posix,
9573 },
9574 &["notion".to_string(), "github".to_string()],
9575 );
9576 assert!(prompt.contains("[Connected Integrations]"));
9577 assert!(prompt.contains("- notion"));
9578 assert!(prompt.contains("- github"));
9579 }
9580
9581 #[test]
9582 fn detects_web_research_prompt_keywords() {
9583 assert!(requires_web_research_prompt(
9584 "research todays top news stories and include links"
9585 ));
9586 assert!(!requires_web_research_prompt(
9587 "say hello and summarize this text"
9588 ));
9589 }
9590
9591 #[test]
9592 fn detects_email_delivery_prompt_keywords() {
9593 assert!(requires_email_delivery_prompt(
9594 "send a full report with links to user123@example.com"
9595 ));
9596 assert!(!requires_email_delivery_prompt("draft a summary for later"));
9597 }
9598
9599 #[test]
9600 fn completion_claim_detector_flags_sent_language() {
9601 assert!(completion_claims_email_sent(
9602 "Email Status: Sent to user123@example.com."
9603 ));
9604 assert!(!completion_claims_email_sent(
9605 "I could not send email in this run."
9606 ));
9607 }
9608
9609 #[test]
9610 fn email_tool_detector_finds_mcp_gmail_tools() {
9611 let schemas = vec![
9612 ToolSchema::new("read", "", json!({})),
9613 ToolSchema::new("mcp.composio.gmail_send_email", "", json!({})),
9614 ];
9615 assert!(has_email_action_tools(&schemas));
9616 }
9617
9618 #[test]
9619 fn extract_mcp_auth_required_metadata_parses_expected_shape() {
9620 let metadata = json!({
9621 "server": "arcade",
9622 "mcpAuth": {
9623 "required": true,
9624 "challengeId": "abc123",
9625 "authorizationUrl": "https://example.com/oauth",
9626 "message": "Authorize first",
9627 "pending": true,
9628 "blocked": true,
9629 "retryAfterMs": 8000
9630 }
9631 });
9632 let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
9633 assert_eq!(parsed.challenge_id, "abc123");
9634 assert_eq!(parsed.authorization_url, "https://example.com/oauth");
9635 assert_eq!(parsed.message, "Authorize first");
9636 assert_eq!(parsed.server.as_deref(), Some("arcade"));
9637 assert!(parsed.pending);
9638 assert!(parsed.blocked);
9639 assert_eq!(parsed.retry_after_ms, Some(8000));
9640 }
9641
9642 #[test]
9643 fn auth_required_output_detector_matches_auth_text() {
9644 assert!(is_auth_required_tool_output(
9645 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
9646 ));
9647 assert!(is_auth_required_tool_output(
9648 "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
9649 ));
9650 assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
9651 }
9652
9653 #[test]
9654 fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
9655 assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
9656 assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
9657 assert!(!is_productive_tool_output(
9658 "write",
9659 "Tool `write` result:\nWRITE_CONTENT_MISSING"
9660 ));
9661 assert!(!is_productive_tool_output(
9662 "edit",
9663 "Tool `edit` result:\nFILE_PATH_MISSING"
9664 ));
9665 assert!(!is_productive_tool_output(
9666 "write",
9667 "Tool `write` result:\ninvalid_function_parameters"
9668 ));
9669 }
9670
9671 #[test]
9672 fn productive_tool_output_detector_accepts_real_tool_results() {
9673 assert!(is_productive_tool_output(
9674 "write",
9675 "Tool `write` result:\nWrote /tmp/probe.html"
9676 ));
9677 assert!(!is_productive_tool_output(
9678 "write",
9679 "Authorization required for `write`.\nAuthorize here: https://example.com"
9680 ));
9681 }
9682
9683 #[test]
9684 fn glob_empty_result_is_productive() {
9685 assert!(is_productive_tool_output("glob", "Tool `glob` result:\n"));
9686 assert!(is_productive_tool_output("glob", ""));
9687 }
9688
9689 #[test]
9690 fn write_required_node_retries_after_empty_glob() {
9691 assert!(should_retry_nonproductive_required_tool_cycle(
9692 true, false, true, 0
9693 ));
9694 assert!(should_retry_nonproductive_required_tool_cycle(
9695 true, false, true, 1
9696 ));
9697 assert!(!should_retry_nonproductive_required_tool_cycle(
9698 true, false, true, 2
9699 ));
9700 }
9701
9702 #[test]
9703 fn write_required_node_does_not_take_preparatory_retry_after_write_attempt() {
9704 assert!(!should_retry_nonproductive_required_tool_cycle(
9705 true, true, true, 0
9706 ));
9707 assert!(should_retry_nonproductive_required_tool_cycle(
9708 false, true, false, 0
9709 ));
9710 }
9711
9712 #[test]
9713 fn guard_budget_output_detector_matches_expected_text() {
9714 assert!(is_guard_budget_tool_output(
9715 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9716 ));
9717 assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
9718 }
9719
9720 #[test]
9721 fn summarize_guard_budget_outputs_returns_run_scoped_message() {
9722 let outputs = vec![
9723 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9724 .to_string(),
9725 "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
9726 .to_string(),
9727 ];
9728 let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
9729 assert!(summary.contains("per-run tool guard budget"));
9730 assert!(summary.contains("fresh run"));
9731 }
9732
9733 #[test]
9734 fn duplicate_signature_output_detector_matches_expected_text() {
9735 assert!(is_duplicate_signature_limit_output(
9736 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9737 ));
9738 assert!(!is_duplicate_signature_limit_output(
9739 "Tool `read` result: ok"
9740 ));
9741 }
9742
9743 #[test]
9744 fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
9745 let outputs = vec![
9746 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9747 .to_string(),
9748 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9749 .to_string(),
9750 ];
9751 let summary =
9752 summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
9753 assert!(summary.contains("same tool call kept repeating"));
9754 assert!(summary.contains("clearer command target"));
9755 }
9756
9757 #[test]
9758 fn required_tool_mode_unsatisfied_completion_includes_marker() {
9759 let message =
9760 required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
9761 assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
9762 assert!(message.contains("NO_TOOL_CALL_EMITTED"));
9763 assert!(message.contains("tool_mode=required"));
9764 }
9765
9766 #[test]
9767 fn post_tool_final_narrative_generation_is_allowed_after_required_tools_succeed() {
9768 assert!(should_generate_post_tool_final_narrative(
9769 ToolMode::Required,
9770 1
9771 ));
9772 assert!(!should_generate_post_tool_final_narrative(
9773 ToolMode::Required,
9774 0
9775 ));
9776 assert!(should_generate_post_tool_final_narrative(ToolMode::Auto, 0));
9777 }
9778
9779 #[test]
9780 fn post_tool_final_narrative_prompt_preserves_structured_response_requirements() {
9781 let prompt = build_post_tool_final_narrative_prompt(&[String::from(
9782 "Tool `glob` result:\n/home/user123/marketing-tandem/tandem-reference/SOURCES.md",
9783 )]);
9784 assert!(prompt.contains("Preserve any requested output contract"));
9785 assert!(prompt.contains("required JSON structure"));
9786 assert!(prompt.contains("required handoff fields"));
9787 assert!(prompt.contains("required final status object"));
9788 assert!(prompt.contains("Do not stop at a tool summary"));
9789 }
9790
9791 #[test]
9792 fn summarize_terminal_tool_failure_for_user_maps_doc_path_missing() {
9793 let summary = summarize_terminal_tool_failure_for_user(&[String::from("DOC_PATH_MISSING")]);
9794 assert!(summary.as_deref().unwrap_or_default().contains("docs page"));
9795 assert!(summary
9796 .as_deref()
9797 .unwrap_or_default()
9798 .contains("https://docs.tandem.ac/start-here/"));
9799 }
9800
9801 #[test]
9802 fn summarize_user_visible_tool_outputs_hides_internal_skipped_and_error_lines() {
9803 let summary = summarize_user_visible_tool_outputs(&[
9804 String::from(
9805 "Tool `read` result:\n# Start Here\nTandem is an engine-owned workflow runtime.",
9806 ),
9807 String::from(
9808 "Tool `tool` call skipped: it is not available in this turn. Available tools: mcp.tandem_mcp.get_doc.",
9809 ),
9810 String::from("DOC_PATH_MISSING"),
9811 ]);
9812 assert!(summary.contains("Tool `read` result:"));
9813 assert!(!summary.contains("call skipped"));
9814 assert!(!summary.contains("DOC_PATH_MISSING"));
9815 }
9816
9817 #[test]
9818 fn required_tool_retry_context_mentions_offered_tools() {
9819 let prompt = build_required_tool_retry_context(
9820 "read, write, apply_patch",
9821 RequiredToolFailureKind::ToolCallInvalidArgs,
9822 );
9823 assert!(prompt.contains("Tool access is mandatory"));
9824 assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
9825 assert!(prompt.contains("full `content`"));
9826 assert!(prompt.contains("write, edit, or apply_patch"));
9827 }
9828
9829 #[test]
9830 fn required_tool_retry_context_requires_write_after_read_only_pass() {
9831 let prompt = build_required_tool_retry_context(
9832 "glob, read, write, edit, apply_patch",
9833 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9834 );
9835 assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
9836 assert!(prompt.contains("Inspection is complete"));
9837 assert!(prompt.contains("write, edit, or apply_patch"));
9838 }
9839
9840 #[test]
9841 fn classify_required_tool_failure_detects_invalid_args() {
9842 let reason = classify_required_tool_failure(
9843 &[String::from("WRITE_CONTENT_MISSING")],
9844 true,
9845 1,
9846 false,
9847 false,
9848 );
9849 assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
9850 }
9851
9852 #[test]
9853 fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
9854 assert!(looks_like_unparsed_tool_payload(
9855 r#"{"content":[{"type":"tool_call","name":"write"}]}"#
9856 ));
9857 assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
9858 }
9859
9860 #[test]
9861 fn workspace_write_tool_detection_is_limited_to_mutations() {
9862 assert!(is_workspace_write_tool("write"));
9863 assert!(is_workspace_write_tool("edit"));
9864 assert!(is_workspace_write_tool("apply_patch"));
9865 assert!(!is_workspace_write_tool("read"));
9866 assert!(!is_workspace_write_tool("glob"));
9867 }
9868
9869 #[test]
9870 fn proactive_write_gate_applies_only_before_prewrite_is_satisfied() {
9871 let decision = evaluate_prewrite_gate(
9872 true,
9873 &PrewriteRequirements {
9874 workspace_inspection_required: true,
9875 web_research_required: false,
9876 concrete_read_required: true,
9877 successful_web_research_required: false,
9878 repair_on_unmet_requirements: true,
9879 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9880 },
9881 PrewriteProgress {
9882 productive_write_tool_calls_total: 0,
9883 productive_workspace_inspection_total: 0,
9884 productive_concrete_read_total: 0,
9885 productive_web_research_total: 0,
9886 successful_web_research_total: 0,
9887 required_write_retry_count: 0,
9888 unmet_prewrite_repair_retry_count: 0,
9889 prewrite_gate_waived: false,
9890 },
9891 );
9892 assert!(decision.gate_write);
9893 }
9894
9895 #[test]
9896 fn prewrite_repair_can_start_before_any_write_attempt() {
9897 assert!(should_start_prewrite_repair_before_first_write(
9898 true, 0, false, false
9899 ));
9900 assert!(!should_start_prewrite_repair_before_first_write(
9901 true, 0, true, false
9902 ));
9903 assert!(!should_start_prewrite_repair_before_first_write(
9904 false, 0, false, false
9905 ));
9906 assert!(should_start_prewrite_repair_before_first_write(
9907 false, 0, false, true
9908 ));
9909 }
9910
9911 #[test]
9912 fn prewrite_repair_does_not_fire_after_first_write() {
9913 assert!(!should_start_prewrite_repair_before_first_write(
9914 true, 1, false, false
9915 ));
9916 assert!(!should_start_prewrite_repair_before_first_write(
9917 true, 2, false, true
9918 ));
9919 }
9920
9921 #[test]
9922 fn infer_code_workflow_from_text_detects_code_agent_contract() {
9923 let prompt = "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test";
9924 assert!(infer_code_workflow_from_text(prompt));
9925 }
9926
9927 #[test]
9928 fn infer_code_workflow_from_text_detects_source_target_path() {
9929 let prompt = "Required Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.";
9930 assert!(infer_code_workflow_from_text(prompt));
9931 }
9932
9933 #[test]
9934 fn required_tool_retry_context_for_task_adds_code_loop_guidance() {
9935 let prompt = build_required_tool_retry_context_for_task(
9936 "read, edit, apply_patch, bash",
9937 RequiredToolFailureKind::WriteRequiredNotSatisfied,
9938 "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.",
9939 );
9940 assert!(prompt.contains("inspect -> patch -> apply -> test -> repair"));
9941 assert!(prompt.contains("apply_patch"));
9942 assert!(prompt.contains("cargo test"));
9943 assert!(prompt.contains("src/lib.rs"));
9944 }
9945
9946 #[test]
9947 fn write_tool_removed_after_first_productive_write() {
9948 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9949 let repair_on_unmet_requirements = true;
9950 let productive_write_tool_calls_total = 1usize;
9951 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9952 offered.retain(|tool| !is_workspace_write_tool(tool));
9953 }
9954 assert_eq!(offered, vec!["glob", "read", "websearch", "write", "edit"]);
9955 }
9956
9957 #[test]
9958 fn write_tool_removed_after_third_productive_write() {
9959 let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9960 let repair_on_unmet_requirements = true;
9961 let productive_write_tool_calls_total = 3usize;
9962 if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9963 offered.retain(|tool| !is_workspace_write_tool(tool));
9964 }
9965 assert_eq!(offered, vec!["glob", "read", "websearch"]);
9966 }
9967
9968 #[test]
9969 fn force_write_only_retry_disabled_for_prewrite_repair_nodes() {
9970 let requested_write_required = true;
9971 let required_write_retry_count = 1usize;
9972 let productive_write_tool_calls_total = 0usize;
9973 let prewrite_satisfied = true;
9974 let prewrite_gate_write = false;
9975 let repair_on_unmet_requirements = true;
9976
9977 let force_write_only_retry = requested_write_required
9978 && required_write_retry_count > 0
9979 && (productive_write_tool_calls_total == 0 || prewrite_satisfied)
9980 && !prewrite_gate_write
9981 && !repair_on_unmet_requirements;
9982
9983 assert!(!force_write_only_retry);
9984 }
9985
9986 #[test]
9987 fn infer_required_output_target_path_reads_prompt_json_block() {
9988 let prompt = r#"Execute task.
9989
9990Required output target:
9991{
9992 "path": "src/game.html",
9993 "kind": "source",
9994 "operation": "create"
9995}
9996"#;
9997 assert_eq!(
9998 infer_required_output_target_path_from_text(prompt).as_deref(),
9999 Some("src/game.html")
10000 );
10001 }
10002
10003 #[test]
10004 fn infer_required_output_target_path_accepts_extensionless_target() {
10005 let prompt = r#"Execute task.
10006
10007Required output target:
10008{
10009 "path": "Dockerfile",
10010 "kind": "source",
10011 "operation": "create"
10012}
10013"#;
10014 assert_eq!(
10015 infer_required_output_target_path_from_text(prompt).as_deref(),
10016 Some("Dockerfile")
10017 );
10018 }
10019
10020 #[test]
10021 fn infer_write_file_path_from_text_rejects_workspace_root() {
10022 let prompt = "Workspace: /home/user123/game\nCreate the scaffold in the workspace now.";
10023 assert_eq!(infer_write_file_path_from_text(prompt), None);
10024 }
10025
10026 #[test]
10027 fn duplicate_signature_limit_defaults_to_200_for_general_tools_and_1_for_email_delivery() {
10028 let _guard = env_test_lock();
10029 unsafe {
10030 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
10031 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10032 }
10033 assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
10034 assert_eq!(duplicate_signature_limit_for("bash"), 200);
10035 assert_eq!(duplicate_signature_limit_for("write"), 200);
10036 assert_eq!(
10037 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10038 1
10039 );
10040 assert_eq!(
10041 duplicate_signature_limit_for("mcp.composio_1.gmail_create_email_draft"),
10042 1
10043 );
10044 }
10045
10046 #[test]
10047 fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
10048 let parsed = parse_streamed_tool_args("write", "path=game.html content");
10049 assert_ne!(parsed, json!({}));
10050 }
10051
10052 #[test]
10053 fn parse_streamed_tool_args_rejects_malformed_json_fragment_as_function_style() {
10054 let parsed = parse_streamed_tool_args("write", r#"{"allow_empty": null"#);
10055 assert_eq!(parsed, json!(r#"{"allow_empty": null"#));
10056 }
10057
10058 #[test]
10059 fn parse_streamed_tool_args_preserves_large_write_payload() {
10060 let content = "x".repeat(4096);
10061 let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
10062 let parsed = parse_streamed_tool_args("write", &raw_args);
10063 assert_eq!(
10064 parsed.get("path").and_then(|value| value.as_str()),
10065 Some("game.html")
10066 );
10067 assert_eq!(
10068 parsed.get("content").and_then(|value| value.as_str()),
10069 Some(content.as_str())
10070 );
10071 }
10072
10073 #[test]
10074 fn parse_streamed_tool_args_recovers_truncated_write_json() {
10075 let raw_args = concat!(
10076 r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
10077 r#"<html lang=\"en\"><body>Neon Drift"#
10078 );
10079 let parsed = parse_streamed_tool_args("write", raw_args);
10080 assert_eq!(
10081 parsed,
10082 json!({
10083 "path": "game.html",
10084 "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
10085 })
10086 );
10087 }
10088
10089 #[test]
10090 fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
10091 let raw_args = concat!(
10092 r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
10093 r#"<html lang=\"en\"><body>Neon Drift"#
10094 );
10095 let parsed = parse_streamed_tool_args("write", raw_args);
10096 assert_eq!(parsed.get("path"), None);
10097 assert_eq!(
10098 parsed.get("content").and_then(|value| value.as_str()),
10099 Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
10100 );
10101 }
10102
10103 #[test]
10104 fn duplicate_signature_limit_env_override_respects_minimum_floor() {
10105 let _guard = env_test_lock();
10106 unsafe {
10107 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
10108 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10109 }
10110 assert_eq!(duplicate_signature_limit_for("write"), 200);
10111 assert_eq!(duplicate_signature_limit_for("bash"), 200);
10112 unsafe {
10113 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
10114 }
10115 assert_eq!(duplicate_signature_limit_for("bash"), 250);
10116 unsafe {
10117 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
10118 }
10119 }
10120
10121 #[test]
10122 fn email_delivery_duplicate_signature_limit_env_override_respects_floor_of_one() {
10123 let _guard = env_test_lock();
10124 unsafe {
10125 std::env::set_var(
10126 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
10127 "1",
10128 );
10129 }
10130 assert_eq!(
10131 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10132 1
10133 );
10134 unsafe {
10135 std::env::set_var(
10136 "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
10137 "3",
10138 );
10139 }
10140 assert_eq!(
10141 duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10142 3
10143 );
10144 unsafe {
10145 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10146 }
10147 }
10148
10149 #[test]
10150 fn email_delivery_detection_is_provider_agnostic() {
10151 assert!(is_email_delivery_tool_name(
10152 "mcp.composio_1.gmail_send_email"
10153 ));
10154 assert!(is_email_delivery_tool_name("mcp.sendgrid.send_email"));
10155 assert!(is_email_delivery_tool_name("mcp.resend.create_email_draft"));
10156 assert!(is_email_delivery_tool_name("mcp.outlook.reply_email"));
10157 assert!(!is_email_delivery_tool_name("mcp.reddit.send_message"));
10158 assert!(!is_email_delivery_tool_name("mcp.github.create_issue"));
10159 }
10160
10161 #[test]
10162 fn websearch_duplicate_signature_limit_is_unset_by_default() {
10163 let _guard = env_test_lock();
10164 unsafe {
10165 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
10166 }
10167 assert_eq!(websearch_duplicate_signature_limit(), None);
10168 }
10169
10170 #[test]
10171 fn websearch_duplicate_signature_limit_reads_env() {
10172 let _guard = env_test_lock();
10173 unsafe {
10174 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
10175 }
10176 assert_eq!(websearch_duplicate_signature_limit(), Some(200));
10177 unsafe {
10178 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
10179 }
10180 assert_eq!(websearch_duplicate_signature_limit(), Some(300));
10181 unsafe {
10182 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
10183 }
10184 }
10185
10186 #[test]
10187 fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
10188 let outputs = vec![
10189 "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
10190 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
10191 ];
10192 let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
10193 assert!(summary.contains("Authorization is required before I can continue"));
10194 assert!(summary.contains("gmail_sendemail"));
10195 assert!(summary.contains("gmail_whoami"));
10196 }
10197
10198 #[test]
10199 fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
10200 let outputs = vec![
10201 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
10202 "Tool `read` result:\nok".to_string(),
10203 ];
10204 assert!(summarize_auth_pending_outputs(&outputs).is_none());
10205 }
10206
10207 #[test]
10208 fn invalid_tool_args_retry_context_handles_missing_bash_command() {
10209 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
10210 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
10211 .expect("retry expected");
10212 assert!(message.contains("required `command` field"));
10213 assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
10214 }
10215
10216 #[test]
10217 fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
10218 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
10219 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
10220 .expect("retry expected");
10221 assert!(message.contains("Do not repeat an empty bash call"));
10222 }
10223
10224 #[test]
10225 fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
10226 let outputs = vec!["Tool `read` result:\nok".to_string()];
10227 assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
10228 }
10229
10230 #[test]
10231 fn prewrite_repair_retry_context_prioritizes_research_tools_before_write() {
10232 let requirements = PrewriteRequirements {
10233 workspace_inspection_required: true,
10234 web_research_required: true,
10235 concrete_read_required: true,
10236 successful_web_research_required: true,
10237 repair_on_unmet_requirements: true,
10238 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10239 };
10240 let prompt = build_prewrite_repair_retry_context(
10241 "glob, read, websearch, write",
10242 RequiredToolFailureKind::WriteRequiredNotSatisfied,
10243 r#"Required output target:
10244{
10245 "path": "marketing-brief.md",
10246 "kind": "artifact"
10247}"#,
10248 &requirements,
10249 true,
10250 false,
10251 false,
10252 false,
10253 );
10254 assert!(prompt.contains("requires concrete `read` calls"));
10255 assert!(prompt.contains("call `websearch` with a concrete query now"));
10256 assert!(prompt.contains("Use `read` and `websearch` now to gather evidence"));
10257 assert!(prompt.contains("Do not declare the output blocked"));
10258 assert!(!prompt.contains("blocked-but-substantive artifact"));
10259 assert!(!prompt.contains("Your next response must be a `write` tool call"));
10260 assert!(!prompt.contains("Do not call `glob`, `read`, or `websearch` again"));
10261 }
10262
10263 #[test]
10264 fn empty_completion_retry_context_requires_write_when_prewrite_is_satisfied() {
10265 let requirements = PrewriteRequirements {
10266 workspace_inspection_required: true,
10267 web_research_required: false,
10268 concrete_read_required: true,
10269 successful_web_research_required: false,
10270 repair_on_unmet_requirements: true,
10271 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10272 };
10273 let prompt = build_empty_completion_retry_context(
10274 "glob, read, write",
10275 "Create or update `marketing-brief.md` relative to the workspace root.",
10276 &requirements,
10277 true,
10278 true,
10279 false,
10280 false,
10281 );
10282 assert!(prompt.contains("returned no final output"));
10283 assert!(prompt.contains("marketing-brief.md"));
10284 assert!(prompt.contains("must be a `write` tool call"));
10285 }
10286
10287 #[test]
10288 fn empty_completion_retry_context_mentions_missing_prewrite_work() {
10289 let requirements = PrewriteRequirements {
10290 workspace_inspection_required: true,
10291 web_research_required: true,
10292 concrete_read_required: true,
10293 successful_web_research_required: true,
10294 repair_on_unmet_requirements: true,
10295 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10296 };
10297 let prompt = build_empty_completion_retry_context(
10298 "glob, read, websearch, write",
10299 "Create or update `marketing-brief.md` relative to the workspace root.",
10300 &requirements,
10301 true,
10302 false,
10303 false,
10304 false,
10305 );
10306 assert!(prompt.contains("still need to use `read`"));
10307 assert!(prompt.contains("use `websearch`"));
10308 assert!(prompt.contains("After completing the missing requirement"));
10309 }
10310
10311 #[test]
10312 fn synthesize_artifact_write_completion_from_tool_state_marks_completed() {
10313 let completion = synthesize_artifact_write_completion_from_tool_state(
10314 "Create or update `marketing-brief.md` relative to the workspace root.",
10315 true,
10316 false,
10317 );
10318 assert!(completion.contains("wrote `marketing-brief.md`"));
10319 assert!(completion.contains("\"status\":\"completed\""));
10320 assert!(completion.contains("Runtime validation will verify"));
10321 }
10322
10323 #[test]
10324 fn synthesize_artifact_write_completion_from_tool_state_mentions_waived_evidence() {
10325 let completion = synthesize_artifact_write_completion_from_tool_state(
10326 "Create or update `marketing-brief.md` relative to the workspace root.",
10327 false,
10328 true,
10329 );
10330 assert!(completion.contains("waived in-run"));
10331 assert!(completion.contains("\"status\":\"completed\""));
10332 }
10333
10334 #[test]
10335 fn prewrite_repair_retry_budget_allows_five_repair_attempts() {
10336 assert_eq!(prewrite_repair_retry_max_attempts(), 5);
10337 }
10338
10339 #[test]
10340 fn prewrite_repair_tool_filter_removes_write_until_evidence_is_satisfied() {
10341 let offered = ["glob", "read", "websearch", "write", "edit"];
10342 let filtered = offered
10343 .iter()
10344 .copied()
10345 .filter(|tool| {
10346 tool_matches_unmet_prewrite_repair_requirement(
10347 tool,
10348 &[
10349 "workspace_inspection_required",
10350 "concrete_read_required",
10351 "successful_web_research_required",
10352 ],
10353 )
10354 })
10355 .collect::<Vec<_>>();
10356 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
10357 }
10358
10359 #[test]
10360 fn prewrite_repair_tool_filter_restricts_to_glob_and_read_for_concrete_reads() {
10361 let offered = ["glob", "read", "search", "write"];
10362 let filtered = offered
10363 .iter()
10364 .copied()
10365 .filter(|tool| {
10366 tool_matches_unmet_prewrite_repair_requirement(tool, &["concrete_read_required"])
10367 })
10368 .collect::<Vec<_>>();
10369 assert_eq!(filtered, vec!["glob", "read"]);
10370 }
10371
10372 #[test]
10373 fn prewrite_repair_tool_filter_allows_glob_only_for_workspace_inspection() {
10374 let offered = ["glob", "read", "websearch", "write"];
10375 let with_inspection_unmet = offered
10376 .iter()
10377 .copied()
10378 .filter(|tool| {
10379 tool_matches_unmet_prewrite_repair_requirement(
10380 tool,
10381 &["workspace_inspection_required", "concrete_read_required"],
10382 )
10383 })
10384 .collect::<Vec<_>>();
10385 assert_eq!(with_inspection_unmet, vec!["glob", "read"]);
10386
10387 let without_inspection_unmet = offered
10388 .iter()
10389 .copied()
10390 .filter(|tool| {
10391 tool_matches_unmet_prewrite_repair_requirement(
10392 tool,
10393 &["concrete_read_required", "web_research_required"],
10394 )
10395 })
10396 .collect::<Vec<_>>();
10397 assert_eq!(without_inspection_unmet, vec!["glob", "read", "websearch"]);
10398 }
10399
10400 #[test]
10401 fn prewrite_repair_after_glob_restricts_to_glob_read_and_websearch() {
10402 let offered = ["glob", "read", "websearch", "write", "edit"];
10403 let filtered = offered
10404 .iter()
10405 .copied()
10406 .filter(|tool| {
10407 tool_matches_unmet_prewrite_repair_requirement(
10408 tool,
10409 &[
10410 "concrete_read_required",
10411 "successful_web_research_required",
10412 "coverage_mode",
10413 ],
10414 )
10415 })
10416 .collect::<Vec<_>>();
10417 assert_eq!(filtered, vec!["glob", "read", "websearch"]);
10418 }
10419
10420 #[test]
10421 fn prewrite_requirements_exhausted_completion_reports_structured_repair_state() {
10422 let message = prewrite_requirements_exhausted_completion(
10423 &["concrete_read_required", "successful_web_research_required"],
10424 2,
10425 0,
10426 );
10427 assert!(message.contains("PREWRITE_REQUIREMENTS_EXHAUSTED"));
10428 assert!(message.contains("\"status\":\"blocked\""));
10429 assert!(message.contains("\"repairAttempt\":2"));
10430 assert!(message.contains("\"repairAttemptsRemaining\":0"));
10431 assert!(message.contains("\"repairExhausted\":true"));
10432 assert!(message.contains("\"unmetRequirements\":[\"concrete_read_required\", \"successful_web_research_required\"]"));
10433 }
10434
10435 #[test]
10436 fn prewrite_waived_write_context_includes_unmet_codes() {
10437 let user_text = "Some task text without output target marker.";
10438 let unmet = vec!["concrete_read_required", "coverage_mode"];
10439 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
10440 assert!(ctx.contains("could not be fully satisfied"));
10441 assert!(ctx.contains("concrete_read_required"));
10442 assert!(ctx.contains("coverage_mode"));
10443 assert!(ctx.contains("write"));
10444 assert!(ctx.contains("Do not write a blocked or placeholder file"));
10445 }
10446
10447 #[test]
10448 fn prewrite_waived_write_context_includes_output_path_when_present() {
10449 let user_text = "Required output target: {\"path\": \"marketing-brief.md\"}";
10450 let unmet = vec!["concrete_read_required"];
10451 let ctx = build_prewrite_waived_write_context(user_text, &unmet);
10452 assert!(ctx.contains("marketing-brief.md"));
10453 assert!(ctx.contains("`write`"));
10454 }
10455
10456 #[test]
10457 fn prewrite_gate_waived_disables_prewrite_gate_write() {
10458 let requirements = PrewriteRequirements {
10459 workspace_inspection_required: true,
10460 web_research_required: false,
10461 concrete_read_required: true,
10462 successful_web_research_required: false,
10463 repair_on_unmet_requirements: true,
10464 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10465 };
10466 let before = evaluate_prewrite_gate(
10467 true,
10468 &requirements,
10469 PrewriteProgress {
10470 productive_write_tool_calls_total: 0,
10471 productive_workspace_inspection_total: 0,
10472 productive_concrete_read_total: 0,
10473 productive_web_research_total: 0,
10474 successful_web_research_total: 0,
10475 required_write_retry_count: 0,
10476 unmet_prewrite_repair_retry_count: 0,
10477 prewrite_gate_waived: false,
10478 },
10479 );
10480 assert!(before.gate_write, "gate should be active before waiver");
10481 let after = evaluate_prewrite_gate(
10482 true,
10483 &requirements,
10484 PrewriteProgress {
10485 productive_write_tool_calls_total: 0,
10486 productive_workspace_inspection_total: 0,
10487 productive_concrete_read_total: 0,
10488 productive_web_research_total: 0,
10489 successful_web_research_total: 0,
10490 required_write_retry_count: 0,
10491 unmet_prewrite_repair_retry_count: 0,
10492 prewrite_gate_waived: true,
10493 },
10494 );
10495 assert!(!after.gate_write, "gate should be off after waiver");
10496 }
10497
10498 #[test]
10499 fn prewrite_gate_waived_disables_allow_repair_tools() {
10500 let requirements = PrewriteRequirements {
10501 workspace_inspection_required: true,
10502 web_research_required: true,
10503 concrete_read_required: true,
10504 successful_web_research_required: true,
10505 repair_on_unmet_requirements: true,
10506 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10507 };
10508 let before = evaluate_prewrite_gate(
10509 true,
10510 &requirements,
10511 PrewriteProgress {
10512 productive_write_tool_calls_total: 0,
10513 productive_workspace_inspection_total: 0,
10514 productive_concrete_read_total: 0,
10515 productive_web_research_total: 0,
10516 successful_web_research_total: 0,
10517 required_write_retry_count: 0,
10518 unmet_prewrite_repair_retry_count: 1,
10519 prewrite_gate_waived: false,
10520 },
10521 );
10522 assert!(
10523 before.allow_repair_tools,
10524 "repair tools should be active before waiver"
10525 );
10526 let after = evaluate_prewrite_gate(
10527 true,
10528 &requirements,
10529 PrewriteProgress {
10530 productive_write_tool_calls_total: 0,
10531 productive_workspace_inspection_total: 0,
10532 productive_concrete_read_total: 0,
10533 productive_web_research_total: 0,
10534 successful_web_research_total: 0,
10535 required_write_retry_count: 0,
10536 unmet_prewrite_repair_retry_count: 1,
10537 prewrite_gate_waived: true,
10538 },
10539 );
10540 assert!(
10541 !after.allow_repair_tools,
10542 "repair tools should be disabled after waiver"
10543 );
10544 }
10545
10546 #[test]
10547 fn force_write_only_enabled_after_prewrite_waiver() {
10548 let requirements = PrewriteRequirements {
10549 workspace_inspection_required: true,
10550 web_research_required: true,
10551 concrete_read_required: true,
10552 successful_web_research_required: true,
10553 repair_on_unmet_requirements: true,
10554 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10555 };
10556 let decision = evaluate_prewrite_gate(
10557 true,
10558 &requirements,
10559 PrewriteProgress {
10560 productive_write_tool_calls_total: 0,
10561 productive_workspace_inspection_total: 0,
10562 productive_concrete_read_total: 0,
10563 productive_web_research_total: 0,
10564 successful_web_research_total: 0,
10565 required_write_retry_count: 1,
10566 unmet_prewrite_repair_retry_count: 1,
10567 prewrite_gate_waived: true,
10568 },
10569 );
10570 assert!(
10571 decision.force_write_only_retry,
10572 "force_write_only should be active after prewrite waiver + write retry"
10573 );
10574 }
10575
10576 #[test]
10577 fn force_write_only_disabled_before_prewrite_waiver() {
10578 let requirements = PrewriteRequirements {
10579 workspace_inspection_required: true,
10580 web_research_required: true,
10581 concrete_read_required: true,
10582 successful_web_research_required: true,
10583 repair_on_unmet_requirements: true,
10584 coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10585 };
10586 let decision = evaluate_prewrite_gate(
10587 true,
10588 &requirements,
10589 PrewriteProgress {
10590 productive_write_tool_calls_total: 0,
10591 productive_workspace_inspection_total: 0,
10592 productive_concrete_read_total: 0,
10593 productive_web_research_total: 0,
10594 successful_web_research_total: 0,
10595 required_write_retry_count: 1,
10596 unmet_prewrite_repair_retry_count: 1,
10597 prewrite_gate_waived: false,
10598 },
10599 );
10600 assert!(
10601 !decision.force_write_only_retry,
10602 "force_write_only should be disabled before waiver for prewrite nodes"
10603 );
10604 }
10605
10606 #[test]
10607 fn parse_budget_override_zero_disables_budget() {
10608 unsafe {
10609 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
10610 }
10611 assert_eq!(
10612 parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
10613 Some(usize::MAX)
10614 );
10615 unsafe {
10616 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10617 }
10618 }
10619
10620 #[test]
10621 fn disable_tool_guard_budgets_env_overrides_all_budgets() {
10622 unsafe {
10623 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10624 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10625 }
10626 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), 1);
10627 assert_eq!(tool_budget_for("websearch"), HARD_TOOL_CALL_CEILING);
10630 unsafe {
10631 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10632 }
10633 }
10634
10635 #[test]
10636 fn email_delivery_budget_can_still_be_explicitly_overridden_when_global_budgets_are_disabled() {
10637 let _guard = env_test_lock();
10638 unsafe {
10639 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10640 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "0");
10641 }
10642 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
10643 unsafe {
10644 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10645 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10646 }
10647 }
10648
10649 #[test]
10650 fn tool_budget_defaults_to_200_calls_and_1_for_email_delivery() {
10651 let _guard = env_test_lock();
10652 unsafe {
10653 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10654 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10655 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10656 std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
10657 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10658 }
10659 assert_eq!(tool_budget_for("bash"), 200);
10660 assert_eq!(tool_budget_for("websearch"), 200);
10661 assert_eq!(tool_budget_for("read"), 200);
10662 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10663 assert_eq!(
10664 tool_budget_for("mcp.composio_1.gmail_create_email_draft"),
10665 1
10666 );
10667 }
10668
10669 #[test]
10670 fn tool_budget_env_override_respects_minimum_floor() {
10671 let _guard = env_test_lock();
10672 unsafe {
10673 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10674 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
10675 std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
10676 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10677 }
10678 assert_eq!(tool_budget_for("bash"), 200);
10679 assert_eq!(tool_budget_for("websearch"), 250);
10680 unsafe {
10681 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10682 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10683 }
10684 }
10685
10686 #[test]
10687 fn email_delivery_tool_budget_env_override_respects_floor_of_one() {
10688 let _guard = env_test_lock();
10689 unsafe {
10690 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10691 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "1");
10692 }
10693 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10694 unsafe {
10695 std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "5");
10696 }
10697 assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 5);
10698 unsafe {
10699 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10700 }
10701 }
10702
10703 #[test]
10704 fn provider_agnostic_email_tools_share_single_send_budget() {
10705 let _guard = env_test_lock();
10706 unsafe {
10707 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10708 std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10709 }
10710 assert_eq!(tool_budget_for("mcp.sendgrid.send_email"), 1);
10711 assert_eq!(tool_budget_for("mcp.resend.create_email_draft"), 1);
10712 assert_eq!(duplicate_signature_limit_for("mcp.outlook.reply_email"), 1);
10713 }
10714}