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, SendMessageRequest, ShellFamily, ToolMode, ToolSchema,
15};
16use tandem_wire::WireMessagePart;
17use tokio_util::sync::CancellationToken;
18use tracing::Level;
19
20mod loop_guards;
21
22#[cfg(test)]
23use loop_guards::parse_budget_override;
24use loop_guards::{
25 duplicate_signature_limit_for, tool_budget_for, websearch_duplicate_signature_limit,
26};
27
28use crate::tool_router::{
29 classify_intent, default_mode_name, is_short_simple_prompt, max_tools_per_call_expanded,
30 select_tool_subset, should_escalate_auto_tools, tool_router_enabled, ToolIntent,
31 ToolRoutingDecision,
32};
33use crate::{
34 any_policy_matches, derive_session_title_from_prompt, title_needs_repair,
35 tool_name_matches_policy, AgentDefinition, AgentRegistry, CancellationRegistry, EventBus,
36 PermissionAction, PermissionManager, PluginRegistry, Storage,
37};
38use tokio::sync::RwLock;
39
40#[derive(Default)]
41struct StreamedToolCall {
42 name: String,
43 args: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47enum RawToolArgsState {
48 Present,
49 Empty,
50 Unparseable,
51}
52
53impl RawToolArgsState {
54 fn as_str(self) -> &'static str {
55 match self {
56 Self::Present => "present",
57 Self::Empty => "empty",
58 Self::Unparseable => "unparseable",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64enum WritePathRecoveryMode {
65 Heuristic,
66 OutputTargetOnly,
67}
68
69#[derive(Debug, Clone)]
70pub struct SpawnAgentToolContext {
71 pub session_id: String,
72 pub message_id: String,
73 pub tool_call_id: Option<String>,
74 pub args: Value,
75}
76
77#[derive(Debug, Clone)]
78pub struct SpawnAgentToolResult {
79 pub output: String,
80 pub metadata: Value,
81}
82
83#[derive(Debug, Clone)]
84pub struct ToolPolicyContext {
85 pub session_id: String,
86 pub message_id: String,
87 pub tool: String,
88 pub args: Value,
89}
90
91#[derive(Debug, Clone)]
92pub struct ToolPolicyDecision {
93 pub allowed: bool,
94 pub reason: Option<String>,
95}
96
97pub trait SpawnAgentHook: Send + Sync {
98 fn spawn_agent(
99 &self,
100 ctx: SpawnAgentToolContext,
101 ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
102}
103
104pub trait ToolPolicyHook: Send + Sync {
105 fn evaluate_tool(
106 &self,
107 ctx: ToolPolicyContext,
108 ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
109}
110
111#[derive(Debug, Clone)]
112pub struct PromptContextHookContext {
113 pub session_id: String,
114 pub message_id: String,
115 pub provider_id: String,
116 pub model_id: String,
117 pub iteration: usize,
118}
119
120pub trait PromptContextHook: Send + Sync {
121 fn augment_provider_messages(
122 &self,
123 ctx: PromptContextHookContext,
124 messages: Vec<ChatMessage>,
125 ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
126}
127
128#[derive(Clone)]
129pub struct EngineLoop {
130 storage: std::sync::Arc<Storage>,
131 event_bus: EventBus,
132 providers: ProviderRegistry,
133 plugins: PluginRegistry,
134 agents: AgentRegistry,
135 permissions: PermissionManager,
136 tools: ToolRegistry,
137 cancellations: CancellationRegistry,
138 host_runtime_context: HostRuntimeContext,
139 workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
140 session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
141 session_auto_approve_permissions: std::sync::Arc<RwLock<HashMap<String, bool>>>,
142 spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
143 tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
144 prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
145}
146
147impl EngineLoop {
148 #[allow(clippy::too_many_arguments)]
149 pub fn new(
150 storage: std::sync::Arc<Storage>,
151 event_bus: EventBus,
152 providers: ProviderRegistry,
153 plugins: PluginRegistry,
154 agents: AgentRegistry,
155 permissions: PermissionManager,
156 tools: ToolRegistry,
157 cancellations: CancellationRegistry,
158 host_runtime_context: HostRuntimeContext,
159 ) -> Self {
160 Self {
161 storage,
162 event_bus,
163 providers,
164 plugins,
165 agents,
166 permissions,
167 tools,
168 cancellations,
169 host_runtime_context,
170 workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
171 session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
172 session_auto_approve_permissions: std::sync::Arc::new(RwLock::new(HashMap::new())),
173 spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
174 tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
175 prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
176 }
177 }
178
179 pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
180 *self.spawn_agent_hook.write().await = Some(hook);
181 }
182
183 pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
184 *self.tool_policy_hook.write().await = Some(hook);
185 }
186
187 pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
188 *self.prompt_context_hook.write().await = Some(hook);
189 }
190
191 pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
192 let normalized = allowed_tools
193 .into_iter()
194 .map(|tool| normalize_tool_name(&tool))
195 .filter(|tool| !tool.trim().is_empty())
196 .collect::<Vec<_>>();
197 self.session_allowed_tools
198 .write()
199 .await
200 .insert(session_id.to_string(), normalized);
201 }
202
203 pub async fn clear_session_allowed_tools(&self, session_id: &str) {
204 self.session_allowed_tools.write().await.remove(session_id);
205 }
206
207 pub async fn set_session_auto_approve_permissions(&self, session_id: &str, enabled: bool) {
208 if enabled {
209 self.session_auto_approve_permissions
210 .write()
211 .await
212 .insert(session_id.to_string(), true);
213 } else {
214 self.session_auto_approve_permissions
215 .write()
216 .await
217 .remove(session_id);
218 }
219 }
220
221 pub async fn clear_session_auto_approve_permissions(&self, session_id: &str) {
222 self.session_auto_approve_permissions
223 .write()
224 .await
225 .remove(session_id);
226 }
227
228 pub async fn grant_workspace_override_for_session(
229 &self,
230 session_id: &str,
231 ttl_seconds: u64,
232 ) -> u64 {
233 let expires_at = chrono::Utc::now()
234 .timestamp_millis()
235 .max(0)
236 .saturating_add((ttl_seconds as i64).saturating_mul(1000))
237 as u64;
238 self.workspace_overrides
239 .write()
240 .await
241 .insert(session_id.to_string(), expires_at);
242 expires_at
243 }
244
245 pub async fn run_prompt_async(
246 &self,
247 session_id: String,
248 req: SendMessageRequest,
249 ) -> anyhow::Result<()> {
250 self.run_prompt_async_with_context(session_id, req, None)
251 .await
252 }
253
254 pub async fn run_prompt_async_with_context(
255 &self,
256 session_id: String,
257 req: SendMessageRequest,
258 correlation_id: Option<String>,
259 ) -> anyhow::Result<()> {
260 let session_model = self
261 .storage
262 .get_session(&session_id)
263 .await
264 .and_then(|s| s.model);
265 let (provider_id, model_id_value) =
266 resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
267 anyhow::anyhow!(
268 "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
269 )
270 })?;
271 let correlation_ref = correlation_id.as_deref();
272 let model_id = Some(model_id_value.as_str());
273 let cancel = self.cancellations.create(&session_id).await;
274 emit_event(
275 Level::INFO,
276 ProcessKind::Engine,
277 ObservabilityEvent {
278 event: "provider.call.start",
279 component: "engine.loop",
280 correlation_id: correlation_ref,
281 session_id: Some(&session_id),
282 run_id: None,
283 message_id: None,
284 provider_id: Some(provider_id.as_str()),
285 model_id,
286 status: Some("start"),
287 error_code: None,
288 detail: Some("run_prompt_async dispatch"),
289 },
290 );
291 self.event_bus.publish(EngineEvent::new(
292 "session.status",
293 json!({"sessionID": session_id, "status":"running"}),
294 ));
295 let request_parts = req.parts.clone();
296 let requested_tool_mode = req.tool_mode.clone().unwrap_or(ToolMode::Auto);
297 let requested_context_mode = req.context_mode.clone().unwrap_or(ContextMode::Auto);
298 let requested_write_required = req.write_required.unwrap_or(false);
299 let request_tool_allowlist = req
300 .tool_allowlist
301 .clone()
302 .unwrap_or_default()
303 .into_iter()
304 .map(|tool| normalize_tool_name(&tool))
305 .filter(|tool| !tool.trim().is_empty())
306 .collect::<HashSet<_>>();
307 let text = req
308 .parts
309 .iter()
310 .map(|p| match p {
311 MessagePartInput::Text { text } => text.clone(),
312 MessagePartInput::File {
313 mime,
314 filename,
315 url,
316 } => format!(
317 "[file mime={} name={} url={}]",
318 mime,
319 filename.clone().unwrap_or_else(|| "unknown".to_string()),
320 url
321 ),
322 })
323 .collect::<Vec<_>>()
324 .join("\n");
325 let runtime_attachments = build_runtime_attachments(&provider_id, &request_parts).await;
326 self.auto_rename_session_from_user_text(&session_id, &text)
327 .await;
328 let active_agent = self.agents.get(req.agent.as_deref()).await;
329 let mut user_message_id = self
330 .find_recent_matching_user_message_id(&session_id, &text)
331 .await;
332 if user_message_id.is_none() {
333 let user_message = Message::new(
334 MessageRole::User,
335 vec![MessagePart::Text { text: text.clone() }],
336 );
337 let created_message_id = user_message.id.clone();
338 self.storage
339 .append_message(&session_id, user_message)
340 .await?;
341
342 let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
343 self.event_bus.publish(EngineEvent::new(
344 "message.part.updated",
345 json!({
346 "part": user_part,
347 "delta": text,
348 "agent": active_agent.name
349 }),
350 ));
351 user_message_id = Some(created_message_id);
352 }
353 let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
354
355 if cancel.is_cancelled() {
356 self.event_bus.publish(EngineEvent::new(
357 "session.status",
358 json!({"sessionID": session_id, "status":"cancelled"}),
359 ));
360 self.cancellations.remove(&session_id).await;
361 return Ok(());
362 }
363
364 let mut question_tool_used = false;
365 let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
366 if normalize_tool_name(&tool) == "question" {
367 question_tool_used = true;
368 }
369 if !agent_can_use_tool(&active_agent, &tool) {
370 format!(
371 "Tool `{tool}` is not enabled for agent `{}`.",
372 active_agent.name
373 )
374 } else {
375 self.execute_tool_with_permission(
376 &session_id,
377 &user_message_id,
378 tool.clone(),
379 args,
380 active_agent.skills.as_deref(),
381 &text,
382 requested_write_required,
383 None,
384 cancel.clone(),
385 )
386 .await?
387 .unwrap_or_default()
388 }
389 } else {
390 let mut completion = String::new();
391 let mut max_iterations = max_tool_iterations();
392 let mut followup_context: Option<String> = None;
393 let mut last_tool_outputs: Vec<String> = Vec::new();
394 let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
395 let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
396 let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
397 let mut mutable_signature_counts: HashMap<String, usize> = HashMap::new();
398 let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
399 let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
400 let mut websearch_query_blocked = false;
401 let websearch_duplicate_signature_limit = websearch_duplicate_signature_limit();
402 let mut pack_builder_executed = false;
403 let mut auto_workspace_probe_attempted = false;
404 let mut productive_tool_calls_total = 0usize;
405 let mut productive_write_tool_calls_total = 0usize;
406 let mut required_tool_retry_count = 0usize;
407 let mut required_write_retry_count = 0usize;
408 let mut invalid_tool_args_retry_count = 0usize;
409 let strict_write_retry_max_attempts = strict_write_retry_max_attempts();
410 let mut required_tool_unsatisfied_emitted = false;
411 let mut latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
412 let email_delivery_requested = requires_email_delivery_prompt(&text);
413 let web_research_requested = requires_web_research_prompt(&text);
414 let mut email_action_executed = false;
415 let mut latest_email_action_note: Option<String> = None;
416 let intent = classify_intent(&text);
417 let router_enabled = tool_router_enabled();
418 let retrieval_enabled = semantic_tool_retrieval_enabled();
419 let retrieval_k = semantic_tool_retrieval_k();
420 let mcp_server_names = if mcp_catalog_in_system_prompt_enabled() {
421 self.tools.mcp_server_names().await
422 } else {
423 Vec::new()
424 };
425 let mut auto_tools_escalated = matches!(requested_tool_mode, ToolMode::Required);
426 let context_is_auto_compact = matches!(requested_context_mode, ContextMode::Auto)
427 && runtime_attachments.is_empty()
428 && is_short_simple_prompt(&text)
429 && matches!(intent, ToolIntent::Chitchat | ToolIntent::Knowledge);
430
431 while max_iterations > 0 && !cancel.is_cancelled() {
432 let iteration = 26usize.saturating_sub(max_iterations);
433 max_iterations -= 1;
434 let context_profile = if matches!(requested_context_mode, ContextMode::Full) {
435 ChatHistoryProfile::Full
436 } else if matches!(requested_context_mode, ContextMode::Compact)
437 || context_is_auto_compact
438 {
439 ChatHistoryProfile::Compact
440 } else {
441 ChatHistoryProfile::Standard
442 };
443 let mut messages =
444 load_chat_history(self.storage.clone(), &session_id, context_profile).await;
445 if iteration == 1 && !runtime_attachments.is_empty() {
446 attach_to_last_user_message(&mut messages, &runtime_attachments);
447 }
448 let history_char_count = messages.iter().map(|m| m.content.len()).sum::<usize>();
449 self.event_bus.publish(EngineEvent::new(
450 "context.profile.selected",
451 json!({
452 "sessionID": session_id,
453 "messageID": user_message_id,
454 "iteration": iteration,
455 "contextMode": format_context_mode(&requested_context_mode, context_is_auto_compact),
456 "historyMessageCount": messages.len(),
457 "historyCharCount": history_char_count,
458 "memoryInjected": false
459 }),
460 ));
461 let mut system_parts = vec![tandem_runtime_system_prompt(
462 &self.host_runtime_context,
463 &mcp_server_names,
464 )];
465 if let Some(system) = active_agent.system_prompt.as_ref() {
466 system_parts.push(system.clone());
467 }
468 messages.insert(
469 0,
470 ChatMessage {
471 role: "system".to_string(),
472 content: system_parts.join("\n\n"),
473 attachments: Vec::new(),
474 },
475 );
476 if let Some(extra) = followup_context.take() {
477 messages.push(ChatMessage {
478 role: "user".to_string(),
479 content: extra,
480 attachments: Vec::new(),
481 });
482 }
483 if let Some(hook) = self.prompt_context_hook.read().await.clone() {
484 let ctx = PromptContextHookContext {
485 session_id: session_id.clone(),
486 message_id: user_message_id.clone(),
487 provider_id: provider_id.clone(),
488 model_id: model_id_value.clone(),
489 iteration,
490 };
491 let hook_timeout =
492 Duration::from_millis(prompt_context_hook_timeout_ms() as u64);
493 match tokio::time::timeout(
494 hook_timeout,
495 hook.augment_provider_messages(ctx, messages.clone()),
496 )
497 .await
498 {
499 Ok(Ok(augmented)) => {
500 messages = augmented;
501 }
502 Ok(Err(err)) => {
503 self.event_bus.publish(EngineEvent::new(
504 "memory.context.error",
505 json!({
506 "sessionID": session_id,
507 "messageID": user_message_id,
508 "iteration": iteration,
509 "error": truncate_text(&err.to_string(), 500),
510 }),
511 ));
512 }
513 Err(_) => {
514 self.event_bus.publish(EngineEvent::new(
515 "memory.context.error",
516 json!({
517 "sessionID": session_id,
518 "messageID": user_message_id,
519 "iteration": iteration,
520 "error": format!(
521 "prompt context hook timeout after {} ms",
522 hook_timeout.as_millis()
523 ),
524 }),
525 ));
526 }
527 }
528 }
529 let all_tools = self.tools.list().await;
530 let mut retrieval_fallback_reason: Option<&'static str> = None;
531 let mut candidate_tools = if retrieval_enabled {
532 self.tools.retrieve(&text, retrieval_k).await
533 } else {
534 all_tools.clone()
535 };
536 if retrieval_enabled {
537 if candidate_tools.is_empty() && !all_tools.is_empty() {
538 candidate_tools = all_tools.clone();
539 retrieval_fallback_reason = Some("retrieval_empty_result");
540 } else if web_research_requested
541 && has_web_research_tools(&all_tools)
542 && !has_web_research_tools(&candidate_tools)
543 {
544 candidate_tools = all_tools.clone();
545 retrieval_fallback_reason = Some("missing_web_tools_for_research_prompt");
546 } else if email_delivery_requested
547 && has_email_action_tools(&all_tools)
548 && !has_email_action_tools(&candidate_tools)
549 {
550 candidate_tools = all_tools.clone();
551 retrieval_fallback_reason = Some("missing_email_tools_for_delivery_prompt");
552 }
553 }
554 let mut tool_schemas = if !router_enabled {
555 candidate_tools
556 } else {
557 match requested_tool_mode {
558 ToolMode::None => Vec::new(),
559 ToolMode::Required => select_tool_subset(
560 candidate_tools,
561 intent,
562 &request_tool_allowlist,
563 iteration > 1,
564 ),
565 ToolMode::Auto => {
566 if !auto_tools_escalated {
567 Vec::new()
568 } else {
569 select_tool_subset(
570 candidate_tools,
571 intent,
572 &request_tool_allowlist,
573 iteration > 1,
574 )
575 }
576 }
577 }
578 };
579 let mut policy_patterns =
580 request_tool_allowlist.iter().cloned().collect::<Vec<_>>();
581 if let Some(agent_tools) = active_agent.tools.as_ref() {
582 policy_patterns
583 .extend(agent_tools.iter().map(|tool| normalize_tool_name(tool)));
584 }
585 let session_allowed_tools = self
586 .session_allowed_tools
587 .read()
588 .await
589 .get(&session_id)
590 .cloned()
591 .unwrap_or_default();
592 policy_patterns.extend(session_allowed_tools.iter().cloned());
593 if !policy_patterns.is_empty() {
594 let mut included = tool_schemas
595 .iter()
596 .map(|schema| normalize_tool_name(&schema.name))
597 .collect::<HashSet<_>>();
598 for schema in &all_tools {
599 let normalized = normalize_tool_name(&schema.name);
600 if policy_patterns
601 .iter()
602 .any(|pattern| tool_name_matches_policy(pattern, &normalized))
603 && included.insert(normalized)
604 {
605 tool_schemas.push(schema.clone());
606 }
607 }
608 }
609 if !request_tool_allowlist.is_empty() {
610 tool_schemas.retain(|schema| {
611 let tool = normalize_tool_name(&schema.name);
612 request_tool_allowlist
613 .iter()
614 .any(|pattern| tool_name_matches_policy(pattern, &tool))
615 });
616 }
617 if requested_write_required && required_write_retry_count > 0 {
618 tool_schemas.retain(|schema| is_workspace_write_tool(&schema.name));
619 }
620 if active_agent.tools.is_some() {
621 tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
622 }
623 tool_schemas.retain(|schema| {
624 let normalized = normalize_tool_name(&schema.name);
625 if let Some(server) = mcp_server_from_tool_name(&normalized) {
626 !blocked_mcp_servers.contains(server)
627 } else {
628 true
629 }
630 });
631 if let Some(allowed_tools) = self
632 .session_allowed_tools
633 .read()
634 .await
635 .get(&session_id)
636 .cloned()
637 {
638 if !allowed_tools.is_empty() {
639 tool_schemas.retain(|schema| {
640 let normalized = normalize_tool_name(&schema.name);
641 any_policy_matches(&allowed_tools, &normalized)
642 });
643 }
644 }
645 if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
646 let detail = validation_err.to_string();
647 emit_event(
648 Level::ERROR,
649 ProcessKind::Engine,
650 ObservabilityEvent {
651 event: "provider.call.error",
652 component: "engine.loop",
653 correlation_id: correlation_ref,
654 session_id: Some(&session_id),
655 run_id: None,
656 message_id: Some(&user_message_id),
657 provider_id: Some(provider_id.as_str()),
658 model_id,
659 status: Some("failed"),
660 error_code: Some("TOOL_SCHEMA_INVALID"),
661 detail: Some(&detail),
662 },
663 );
664 anyhow::bail!("{detail}");
665 }
666 let routing_decision = ToolRoutingDecision {
667 pass: if auto_tools_escalated { 2 } else { 1 },
668 mode: match requested_tool_mode {
669 ToolMode::Auto => default_mode_name(),
670 ToolMode::None => "none",
671 ToolMode::Required => "required",
672 },
673 intent,
674 selected_count: tool_schemas.len(),
675 total_available_count: all_tools.len(),
676 mcp_included: tool_schemas
677 .iter()
678 .any(|schema| normalize_tool_name(&schema.name).starts_with("mcp.")),
679 };
680 self.event_bus.publish(EngineEvent::new(
681 "tool.routing.decision",
682 json!({
683 "sessionID": session_id,
684 "messageID": user_message_id,
685 "iteration": iteration,
686 "pass": routing_decision.pass,
687 "mode": routing_decision.mode,
688 "intent": format!("{:?}", routing_decision.intent).to_ascii_lowercase(),
689 "selectedToolCount": routing_decision.selected_count,
690 "totalAvailableTools": routing_decision.total_available_count,
691 "mcpIncluded": routing_decision.mcp_included,
692 "retrievalEnabled": retrieval_enabled,
693 "retrievalK": retrieval_k,
694 "fallbackToFullTools": retrieval_fallback_reason.is_some(),
695 "fallbackReason": retrieval_fallback_reason
696 }),
697 ));
698 let allowed_tool_names = tool_schemas
699 .iter()
700 .map(|schema| normalize_tool_name(&schema.name))
701 .collect::<HashSet<_>>();
702 let offered_tool_preview = tool_schemas
703 .iter()
704 .take(8)
705 .map(|schema| normalize_tool_name(&schema.name))
706 .collect::<Vec<_>>()
707 .join(", ");
708 self.event_bus.publish(EngineEvent::new(
709 "provider.call.iteration.start",
710 json!({
711 "sessionID": session_id,
712 "messageID": user_message_id,
713 "iteration": iteration,
714 "selectedToolCount": allowed_tool_names.len(),
715 }),
716 ));
717 let provider_connect_timeout =
718 Duration::from_millis(provider_stream_connect_timeout_ms() as u64);
719 let stream_result = tokio::time::timeout(
720 provider_connect_timeout,
721 self.providers.stream_for_provider(
722 Some(provider_id.as_str()),
723 Some(model_id_value.as_str()),
724 messages,
725 requested_tool_mode.clone(),
726 Some(tool_schemas),
727 cancel.clone(),
728 ),
729 )
730 .await
731 .map_err(|_| {
732 anyhow::anyhow!(
733 "provider stream connect timeout after {} ms",
734 provider_connect_timeout.as_millis()
735 )
736 })
737 .and_then(|result| result);
738 let stream = match stream_result {
739 Ok(stream) => stream,
740 Err(err) => {
741 let error_text = err.to_string();
742 let error_code = provider_error_code(&error_text);
743 let detail = truncate_text(&error_text, 500);
744 emit_event(
745 Level::ERROR,
746 ProcessKind::Engine,
747 ObservabilityEvent {
748 event: "provider.call.error",
749 component: "engine.loop",
750 correlation_id: correlation_ref,
751 session_id: Some(&session_id),
752 run_id: None,
753 message_id: Some(&user_message_id),
754 provider_id: Some(provider_id.as_str()),
755 model_id,
756 status: Some("failed"),
757 error_code: Some(error_code),
758 detail: Some(&detail),
759 },
760 );
761 self.event_bus.publish(EngineEvent::new(
762 "provider.call.iteration.error",
763 json!({
764 "sessionID": session_id,
765 "messageID": user_message_id,
766 "iteration": iteration,
767 "error": detail,
768 }),
769 ));
770 return Err(err);
771 }
772 };
773 tokio::pin!(stream);
774 completion.clear();
775 let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
776 let mut provider_usage: Option<TokenUsage> = None;
777 let mut accepted_tool_calls_in_cycle = 0usize;
778 let provider_idle_timeout =
779 Duration::from_millis(provider_stream_idle_timeout_ms() as u64);
780 loop {
781 let next_chunk_result =
782 tokio::time::timeout(provider_idle_timeout, stream.next())
783 .await
784 .map_err(|_| {
785 anyhow::anyhow!(
786 "provider stream idle timeout after {} ms",
787 provider_idle_timeout.as_millis()
788 )
789 });
790 let next_chunk = match next_chunk_result {
791 Ok(next_chunk) => next_chunk,
792 Err(err) => {
793 self.event_bus.publish(EngineEvent::new(
794 "provider.call.iteration.error",
795 json!({
796 "sessionID": session_id,
797 "messageID": user_message_id,
798 "iteration": iteration,
799 "error": truncate_text(&err.to_string(), 500),
800 }),
801 ));
802 return Err(err);
803 }
804 };
805 let Some(chunk) = next_chunk else {
806 break;
807 };
808 let chunk = match chunk {
809 Ok(chunk) => chunk,
810 Err(err) => {
811 let error_text = err.to_string();
812 let error_code = provider_error_code(&error_text);
813 let detail = truncate_text(&error_text, 500);
814 emit_event(
815 Level::ERROR,
816 ProcessKind::Engine,
817 ObservabilityEvent {
818 event: "provider.call.error",
819 component: "engine.loop",
820 correlation_id: correlation_ref,
821 session_id: Some(&session_id),
822 run_id: None,
823 message_id: Some(&user_message_id),
824 provider_id: Some(provider_id.as_str()),
825 model_id,
826 status: Some("failed"),
827 error_code: Some(error_code),
828 detail: Some(&detail),
829 },
830 );
831 self.event_bus.publish(EngineEvent::new(
832 "provider.call.iteration.error",
833 json!({
834 "sessionID": session_id,
835 "messageID": user_message_id,
836 "iteration": iteration,
837 "error": detail,
838 }),
839 ));
840 return Err(anyhow::anyhow!(
841 "provider stream chunk error: {error_text}"
842 ));
843 }
844 };
845 match chunk {
846 StreamChunk::TextDelta(delta) => {
847 let delta = strip_model_control_markers(&delta);
848 if delta.trim().is_empty() {
849 continue;
850 }
851 if completion.is_empty() {
852 emit_event(
853 Level::INFO,
854 ProcessKind::Engine,
855 ObservabilityEvent {
856 event: "provider.call.first_byte",
857 component: "engine.loop",
858 correlation_id: correlation_ref,
859 session_id: Some(&session_id),
860 run_id: None,
861 message_id: Some(&user_message_id),
862 provider_id: Some(provider_id.as_str()),
863 model_id,
864 status: Some("streaming"),
865 error_code: None,
866 detail: Some("first text delta"),
867 },
868 );
869 }
870 completion.push_str(&delta);
871 let delta = truncate_text(&delta, 4_000);
872 let delta_part =
873 WireMessagePart::text(&session_id, &user_message_id, delta.clone());
874 self.event_bus.publish(EngineEvent::new(
875 "message.part.updated",
876 json!({"part": delta_part, "delta": delta}),
877 ));
878 }
879 StreamChunk::ReasoningDelta(_reasoning) => {}
880 StreamChunk::Done {
881 finish_reason: _,
882 usage,
883 } => {
884 if usage.is_some() {
885 provider_usage = usage;
886 }
887 break;
888 }
889 StreamChunk::ToolCallStart { id, name } => {
890 let entry = streamed_tool_calls.entry(id).or_default();
891 if entry.name.is_empty() {
892 entry.name = name;
893 }
894 }
895 StreamChunk::ToolCallDelta { id, args_delta } => {
896 let entry = streamed_tool_calls.entry(id.clone()).or_default();
897 entry.args.push_str(&args_delta);
898 let tool_name = if entry.name.trim().is_empty() {
899 "tool".to_string()
900 } else {
901 normalize_tool_name(&entry.name)
902 };
903 let parsed_preview = if entry.name.trim().is_empty() {
904 Value::String(truncate_text(&entry.args, 1_000))
905 } else {
906 parse_streamed_tool_args(&tool_name, &entry.args)
907 };
908 let mut tool_part = WireMessagePart::tool_invocation(
909 &session_id,
910 &user_message_id,
911 tool_name.clone(),
912 parsed_preview.clone(),
913 );
914 tool_part.id = Some(id.clone());
915 if tool_name == "write" {
916 tracing::info!(
917 session_id = %session_id,
918 message_id = %user_message_id,
919 tool_call_id = %id,
920 args_delta_len = args_delta.len(),
921 accumulated_args_len = entry.args.len(),
922 parsed_preview_empty = parsed_preview.is_null()
923 || parsed_preview.as_object().is_some_and(|value| value.is_empty())
924 || parsed_preview
925 .as_str()
926 .map(|value| value.trim().is_empty())
927 .unwrap_or(false),
928 "streamed write tool args delta received"
929 );
930 }
931 self.event_bus.publish(EngineEvent::new(
932 "message.part.updated",
933 json!({
934 "part": tool_part,
935 "toolCallDelta": {
936 "id": id,
937 "tool": tool_name,
938 "argsDelta": truncate_text(&args_delta, 1_000),
939 "parsedArgsPreview": parsed_preview
940 }
941 }),
942 ));
943 }
944 StreamChunk::ToolCallEnd { id: _ } => {}
945 }
946 if cancel.is_cancelled() {
947 break;
948 }
949 }
950
951 let streamed_tool_call_count = streamed_tool_calls.len();
952 let streamed_tool_call_parse_failed = streamed_tool_calls
953 .values()
954 .any(|call| !call.args.trim().is_empty() && call.name.trim().is_empty());
955 let mut tool_calls = streamed_tool_calls
956 .into_values()
957 .filter_map(|call| {
958 if call.name.trim().is_empty() {
959 return None;
960 }
961 let tool_name = normalize_tool_name(&call.name);
962 let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
963 Some((tool_name, parsed_args))
964 })
965 .collect::<Vec<_>>();
966 if tool_calls.is_empty() {
967 tool_calls = parse_tool_invocations_from_response(&completion);
968 }
969 let provider_tool_parse_failed = tool_calls.is_empty()
970 && (streamed_tool_call_parse_failed
971 || (streamed_tool_call_count > 0
972 && looks_like_unparsed_tool_payload(&completion))
973 || looks_like_unparsed_tool_payload(&completion));
974 if provider_tool_parse_failed {
975 latest_required_tool_failure_kind =
976 RequiredToolFailureKind::ToolCallParseFailed;
977 } else if tool_calls.is_empty() {
978 latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
979 }
980 if router_enabled
981 && matches!(requested_tool_mode, ToolMode::Auto)
982 && !auto_tools_escalated
983 && iteration == 1
984 && should_escalate_auto_tools(intent, &text, &completion)
985 {
986 auto_tools_escalated = true;
987 followup_context = Some(
988 "Tool access is now enabled for this request. Use only necessary tools and then answer concisely."
989 .to_string(),
990 );
991 self.event_bus.publish(EngineEvent::new(
992 "provider.call.iteration.finish",
993 json!({
994 "sessionID": session_id,
995 "messageID": user_message_id,
996 "iteration": iteration,
997 "finishReason": "auto_escalate",
998 "acceptedToolCalls": accepted_tool_calls_in_cycle,
999 "rejectedToolCalls": 0,
1000 }),
1001 ));
1002 continue;
1003 }
1004 if tool_calls.is_empty()
1005 && !auto_workspace_probe_attempted
1006 && should_force_workspace_probe(&text, &completion)
1007 && allowed_tool_names.contains("glob")
1008 {
1009 auto_workspace_probe_attempted = true;
1010 tool_calls = vec![("glob".to_string(), json!({ "pattern": "*" }))];
1011 }
1012 if !tool_calls.is_empty() {
1013 let saw_tool_call_candidate = true;
1014 let mut outputs = Vec::new();
1015 let mut executed_productive_tool = false;
1016 let mut write_tool_attempted_in_cycle = false;
1017 let mut auth_required_hit_in_cycle = false;
1018 let mut guard_budget_hit_in_cycle = false;
1019 let mut duplicate_signature_hit_in_cycle = false;
1020 let mut rejected_tool_call_in_cycle = false;
1021 for (tool, args) in tool_calls {
1022 if !agent_can_use_tool(&active_agent, &tool) {
1023 rejected_tool_call_in_cycle = true;
1024 continue;
1025 }
1026 let tool_key = normalize_tool_name(&tool);
1027 if is_workspace_write_tool(&tool_key) {
1028 write_tool_attempted_in_cycle = true;
1029 }
1030 if !allowed_tool_names.contains(&tool_key) {
1031 rejected_tool_call_in_cycle = true;
1032 let note = if offered_tool_preview.is_empty() {
1033 format!(
1034 "Tool `{}` call skipped: it is not available in this turn.",
1035 tool_key
1036 )
1037 } else {
1038 format!(
1039 "Tool `{}` call skipped: it is not available in this turn. Available tools: {}.",
1040 tool_key, offered_tool_preview
1041 )
1042 };
1043 self.event_bus.publish(EngineEvent::new(
1044 "tool.call.rejected_unoffered",
1045 json!({
1046 "sessionID": session_id,
1047 "messageID": user_message_id,
1048 "iteration": iteration,
1049 "tool": tool_key,
1050 "offeredToolCount": allowed_tool_names.len()
1051 }),
1052 ));
1053 if tool_name_looks_like_email_action(&tool_key) {
1054 latest_email_action_note = Some(note.clone());
1055 }
1056 outputs.push(note);
1057 continue;
1058 }
1059 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1060 if blocked_mcp_servers.contains(server) {
1061 rejected_tool_call_in_cycle = true;
1062 outputs.push(format!(
1063 "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
1064 tool_key, server
1065 ));
1066 continue;
1067 }
1068 }
1069 if tool_key == "question" {
1070 question_tool_used = true;
1071 }
1072 if tool_key == "pack_builder" && pack_builder_executed {
1073 rejected_tool_call_in_cycle = true;
1074 outputs.push(
1075 "Tool `pack_builder` call skipped: already executed in this run. Provide a final response or ask any required follow-up question."
1076 .to_string(),
1077 );
1078 continue;
1079 }
1080 if websearch_query_blocked && tool_key == "websearch" {
1081 rejected_tool_call_in_cycle = true;
1082 outputs.push(
1083 "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
1084 .to_string(),
1085 );
1086 continue;
1087 }
1088 let mut effective_args = args.clone();
1089 if tool_key == "todo_write" {
1090 effective_args = normalize_todo_write_args(effective_args, &completion);
1091 if is_empty_todo_write_args(&effective_args) {
1092 rejected_tool_call_in_cycle = true;
1093 outputs.push(
1094 "Tool `todo_write` call skipped: empty todo payload."
1095 .to_string(),
1096 );
1097 continue;
1098 }
1099 }
1100 let signature = if tool_key == "batch" {
1101 batch_tool_signature(&args)
1102 .unwrap_or_else(|| tool_signature(&tool_key, &args))
1103 } else {
1104 tool_signature(&tool_key, &args)
1105 };
1106 if is_shell_tool_name(&tool_key)
1107 && shell_mismatch_signatures.contains(&signature)
1108 {
1109 rejected_tool_call_in_cycle = true;
1110 outputs.push(
1111 "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
1112 .to_string(),
1113 );
1114 continue;
1115 }
1116 let mut signature_count = 1usize;
1117 if is_read_only_tool(&tool_key)
1118 || (tool_key == "batch" && is_read_only_batch_call(&args))
1119 {
1120 let count = readonly_signature_counts
1121 .entry(signature.clone())
1122 .and_modify(|v| *v = v.saturating_add(1))
1123 .or_insert(1);
1124 signature_count = *count;
1125 if tool_key == "websearch" {
1126 if let Some(limit) = websearch_duplicate_signature_limit {
1127 if *count > limit {
1128 rejected_tool_call_in_cycle = true;
1129 self.event_bus.publish(EngineEvent::new(
1130 "tool.loop_guard.triggered",
1131 json!({
1132 "sessionID": session_id,
1133 "messageID": user_message_id,
1134 "tool": tool_key,
1135 "reason": "duplicate_signature_retry_exhausted",
1136 "duplicateLimit": limit,
1137 "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
1138 "loop_guard_triggered": true
1139 }),
1140 ));
1141 outputs.push(
1142 "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
1143 .to_string(),
1144 );
1145 continue;
1146 }
1147 }
1148 }
1149 if tool_key != "websearch" && *count > 1 {
1150 rejected_tool_call_in_cycle = true;
1151 if let Some(cached) = readonly_tool_cache.get(&signature) {
1152 outputs.push(cached.clone());
1153 } else {
1154 outputs.push(format!(
1155 "Tool `{}` call skipped: duplicate call signature detected.",
1156 tool_key
1157 ));
1158 }
1159 continue;
1160 }
1161 }
1162 let is_read_only_signature = is_read_only_tool(&tool_key)
1163 || (tool_key == "batch" && is_read_only_batch_call(&args));
1164 if !is_read_only_signature {
1165 let duplicate_limit = duplicate_signature_limit_for(&tool_key);
1166 let seen = mutable_signature_counts
1167 .entry(signature.clone())
1168 .and_modify(|v| *v = v.saturating_add(1))
1169 .or_insert(1);
1170 if *seen > duplicate_limit {
1171 rejected_tool_call_in_cycle = true;
1172 self.event_bus.publish(EngineEvent::new(
1173 "tool.loop_guard.triggered",
1174 json!({
1175 "sessionID": session_id,
1176 "messageID": user_message_id,
1177 "tool": tool_key,
1178 "reason": "duplicate_signature_retry_exhausted",
1179 "signatureHash": stable_hash(&signature),
1180 "duplicateLimit": duplicate_limit,
1181 "loop_guard_triggered": true
1182 }),
1183 ));
1184 outputs.push(format!(
1185 "Tool `{}` call skipped: duplicate call signature retry limit reached ({}).",
1186 tool_key, duplicate_limit
1187 ));
1188 duplicate_signature_hit_in_cycle = true;
1189 continue;
1190 }
1191 }
1192 let budget = tool_budget_for(&tool_key);
1193 let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
1194 if *entry >= budget {
1195 rejected_tool_call_in_cycle = true;
1196 outputs.push(format!(
1197 "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
1198 tool_key, budget
1199 ));
1200 guard_budget_hit_in_cycle = true;
1201 continue;
1202 }
1203 let mut finalized_part = WireMessagePart::tool_invocation(
1204 &session_id,
1205 &user_message_id,
1206 tool.clone(),
1207 effective_args.clone(),
1208 );
1209 finalized_part.state = Some("pending".to_string());
1210 self.event_bus.publish(EngineEvent::new(
1211 "message.part.updated",
1212 json!({"part": finalized_part}),
1213 ));
1214 *entry += 1;
1215 accepted_tool_calls_in_cycle =
1216 accepted_tool_calls_in_cycle.saturating_add(1);
1217 if let Some(output) = self
1218 .execute_tool_with_permission(
1219 &session_id,
1220 &user_message_id,
1221 tool,
1222 effective_args,
1223 active_agent.skills.as_deref(),
1224 &text,
1225 requested_write_required,
1226 Some(&completion),
1227 cancel.clone(),
1228 )
1229 .await?
1230 {
1231 let productive = is_productive_tool_output(&tool_key, &output);
1232 if output.contains("WEBSEARCH_QUERY_MISSING") {
1233 websearch_query_blocked = true;
1234 }
1235 if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
1236 {
1237 shell_mismatch_signatures.insert(signature.clone());
1238 }
1239 if is_read_only_tool(&tool_key)
1240 && tool_key != "websearch"
1241 && signature_count == 1
1242 {
1243 readonly_tool_cache.insert(signature, output.clone());
1244 }
1245 if productive {
1246 productive_tool_calls_total =
1247 productive_tool_calls_total.saturating_add(1);
1248 if is_workspace_write_tool(&tool_key) {
1249 productive_write_tool_calls_total =
1250 productive_write_tool_calls_total.saturating_add(1);
1251 }
1252 executed_productive_tool = true;
1253 if tool_key == "pack_builder" {
1254 pack_builder_executed = true;
1255 }
1256 }
1257 if tool_name_looks_like_email_action(&tool_key) {
1258 if productive {
1259 email_action_executed = true;
1260 } else {
1261 latest_email_action_note =
1262 Some(truncate_text(&output, 280).replace('\n', " "));
1263 }
1264 }
1265 if is_auth_required_tool_output(&output) {
1266 if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1267 blocked_mcp_servers.insert(server.to_string());
1268 }
1269 auth_required_hit_in_cycle = true;
1270 }
1271 outputs.push(output);
1272 if auth_required_hit_in_cycle {
1273 break;
1274 }
1275 if guard_budget_hit_in_cycle {
1276 break;
1277 }
1278 }
1279 }
1280 if !outputs.is_empty() {
1281 last_tool_outputs = outputs.clone();
1282 if matches!(requested_tool_mode, ToolMode::Required)
1283 && productive_tool_calls_total == 0
1284 {
1285 latest_required_tool_failure_kind = classify_required_tool_failure(
1286 &outputs,
1287 saw_tool_call_candidate,
1288 accepted_tool_calls_in_cycle,
1289 provider_tool_parse_failed,
1290 rejected_tool_call_in_cycle,
1291 );
1292 if requested_write_required
1293 && write_tool_attempted_in_cycle
1294 && productive_write_tool_calls_total == 0
1295 && is_write_invalid_args_failure_kind(
1296 latest_required_tool_failure_kind,
1297 )
1298 {
1299 if required_write_retry_count + 1 < strict_write_retry_max_attempts
1300 {
1301 required_write_retry_count += 1;
1302 required_tool_retry_count += 1;
1303 followup_context = Some(build_write_required_retry_context(
1304 &offered_tool_preview,
1305 latest_required_tool_failure_kind,
1306 &text,
1307 ));
1308 self.event_bus.publish(EngineEvent::new(
1309 "provider.call.iteration.finish",
1310 json!({
1311 "sessionID": session_id,
1312 "messageID": user_message_id,
1313 "iteration": iteration,
1314 "finishReason": "required_write_invalid_retry",
1315 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1316 "rejectedToolCalls": 0,
1317 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1318 }),
1319 ));
1320 continue;
1321 }
1322 }
1323 if !requested_write_required && required_tool_retry_count == 0 {
1324 required_tool_retry_count += 1;
1325 followup_context = Some(build_required_tool_retry_context(
1326 &offered_tool_preview,
1327 latest_required_tool_failure_kind,
1328 ));
1329 self.event_bus.publish(EngineEvent::new(
1330 "provider.call.iteration.finish",
1331 json!({
1332 "sessionID": session_id,
1333 "messageID": user_message_id,
1334 "iteration": iteration,
1335 "finishReason": "required_tool_retry",
1336 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1337 "rejectedToolCalls": 0,
1338 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1339 }),
1340 ));
1341 continue;
1342 }
1343 completion = required_tool_mode_unsatisfied_completion(
1344 latest_required_tool_failure_kind,
1345 );
1346 if !required_tool_unsatisfied_emitted {
1347 required_tool_unsatisfied_emitted = true;
1348 self.event_bus.publish(EngineEvent::new(
1349 "tool.mode.required.unsatisfied",
1350 json!({
1351 "sessionID": session_id,
1352 "messageID": user_message_id,
1353 "iteration": iteration,
1354 "selectedToolCount": allowed_tool_names.len(),
1355 "offeredToolsPreview": offered_tool_preview,
1356 "reason": latest_required_tool_failure_kind.code(),
1357 }),
1358 ));
1359 }
1360 self.event_bus.publish(EngineEvent::new(
1361 "provider.call.iteration.finish",
1362 json!({
1363 "sessionID": session_id,
1364 "messageID": user_message_id,
1365 "iteration": iteration,
1366 "finishReason": "required_tool_unsatisfied",
1367 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1368 "rejectedToolCalls": 0,
1369 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1370 }),
1371 ));
1372 break;
1373 }
1374 if requested_write_required
1375 && productive_tool_calls_total > 0
1376 && productive_write_tool_calls_total == 0
1377 {
1378 latest_required_tool_failure_kind =
1379 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1380 if required_write_retry_count + 1 < strict_write_retry_max_attempts {
1381 required_write_retry_count += 1;
1382 followup_context = Some(build_write_required_retry_context(
1383 &offered_tool_preview,
1384 latest_required_tool_failure_kind,
1385 &text,
1386 ));
1387 self.event_bus.publish(EngineEvent::new(
1388 "provider.call.iteration.finish",
1389 json!({
1390 "sessionID": session_id,
1391 "messageID": user_message_id,
1392 "iteration": iteration,
1393 "finishReason": "required_write_retry",
1394 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1395 "rejectedToolCalls": 0,
1396 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1397 }),
1398 ));
1399 continue;
1400 }
1401 completion = required_tool_mode_unsatisfied_completion(
1402 latest_required_tool_failure_kind,
1403 );
1404 if !required_tool_unsatisfied_emitted {
1405 required_tool_unsatisfied_emitted = true;
1406 self.event_bus.publish(EngineEvent::new(
1407 "tool.mode.required.unsatisfied",
1408 json!({
1409 "sessionID": session_id,
1410 "messageID": user_message_id,
1411 "iteration": iteration,
1412 "selectedToolCount": allowed_tool_names.len(),
1413 "offeredToolsPreview": offered_tool_preview,
1414 "reason": latest_required_tool_failure_kind.code(),
1415 }),
1416 ));
1417 }
1418 self.event_bus.publish(EngineEvent::new(
1419 "provider.call.iteration.finish",
1420 json!({
1421 "sessionID": session_id,
1422 "messageID": user_message_id,
1423 "iteration": iteration,
1424 "finishReason": "required_write_unsatisfied",
1425 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1426 "rejectedToolCalls": 0,
1427 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1428 }),
1429 ));
1430 break;
1431 }
1432 if invalid_tool_args_retry_count < invalid_tool_args_retry_max_attempts() {
1433 if let Some(retry_context) =
1434 build_invalid_tool_args_retry_context_from_outputs(
1435 &outputs,
1436 invalid_tool_args_retry_count,
1437 )
1438 {
1439 invalid_tool_args_retry_count += 1;
1440 followup_context = Some(format!(
1441 "Previous tool call arguments were invalid. {}",
1442 retry_context
1443 ));
1444 self.event_bus.publish(EngineEvent::new(
1445 "provider.call.iteration.finish",
1446 json!({
1447 "sessionID": session_id,
1448 "messageID": user_message_id,
1449 "iteration": iteration,
1450 "finishReason": "invalid_tool_args_retry",
1451 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1452 "rejectedToolCalls": 0,
1453 }),
1454 ));
1455 continue;
1456 }
1457 }
1458 let guard_budget_hit =
1459 outputs.iter().any(|o| is_guard_budget_tool_output(o));
1460 if executed_productive_tool {
1461 followup_context = Some(format!(
1462 "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
1463 summarize_tool_outputs(&outputs)
1464 ));
1465 self.event_bus.publish(EngineEvent::new(
1466 "provider.call.iteration.finish",
1467 json!({
1468 "sessionID": session_id,
1469 "messageID": user_message_id,
1470 "iteration": iteration,
1471 "finishReason": "tool_followup",
1472 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1473 "rejectedToolCalls": 0,
1474 }),
1475 ));
1476 continue;
1477 }
1478 if guard_budget_hit {
1479 completion = summarize_guard_budget_outputs(&outputs)
1480 .unwrap_or_else(|| {
1481 "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()
1482 });
1483 } else if duplicate_signature_hit_in_cycle {
1484 completion = summarize_duplicate_signature_outputs(&outputs)
1485 .unwrap_or_else(|| {
1486 "This run paused because the same tool call kept repeating. Rephrase the request or provide a different command target and retry.".to_string()
1487 });
1488 } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
1489 completion = summary;
1490 } else {
1491 completion.clear();
1492 }
1493 self.event_bus.publish(EngineEvent::new(
1494 "provider.call.iteration.finish",
1495 json!({
1496 "sessionID": session_id,
1497 "messageID": user_message_id,
1498 "iteration": iteration,
1499 "finishReason": "tool_summary",
1500 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1501 "rejectedToolCalls": 0,
1502 }),
1503 ));
1504 break;
1505 } else if matches!(requested_tool_mode, ToolMode::Required) {
1506 latest_required_tool_failure_kind = classify_required_tool_failure(
1507 &outputs,
1508 saw_tool_call_candidate,
1509 accepted_tool_calls_in_cycle,
1510 provider_tool_parse_failed,
1511 rejected_tool_call_in_cycle,
1512 );
1513 }
1514 }
1515
1516 if let Some(usage) = provider_usage {
1517 self.event_bus.publish(EngineEvent::new(
1518 "provider.usage",
1519 json!({
1520 "sessionID": session_id,
1521 "messageID": user_message_id,
1522 "promptTokens": usage.prompt_tokens,
1523 "completionTokens": usage.completion_tokens,
1524 "totalTokens": usage.total_tokens,
1525 }),
1526 ));
1527 }
1528
1529 if matches!(requested_tool_mode, ToolMode::Required)
1530 && productive_tool_calls_total == 0
1531 {
1532 if requested_write_required
1533 && required_write_retry_count > 0
1534 && productive_write_tool_calls_total == 0
1535 && !is_write_invalid_args_failure_kind(latest_required_tool_failure_kind)
1536 {
1537 latest_required_tool_failure_kind =
1538 RequiredToolFailureKind::WriteRequiredNotSatisfied;
1539 }
1540 if requested_write_required
1541 && required_write_retry_count + 1 < strict_write_retry_max_attempts
1542 {
1543 required_write_retry_count += 1;
1544 followup_context = Some(build_write_required_retry_context(
1545 &offered_tool_preview,
1546 latest_required_tool_failure_kind,
1547 &text,
1548 ));
1549 continue;
1550 }
1551 if !requested_write_required && required_tool_retry_count == 0 {
1552 required_tool_retry_count += 1;
1553 followup_context = Some(build_required_tool_retry_context(
1554 &offered_tool_preview,
1555 latest_required_tool_failure_kind,
1556 ));
1557 continue;
1558 }
1559 completion = required_tool_mode_unsatisfied_completion(
1560 latest_required_tool_failure_kind,
1561 );
1562 if !required_tool_unsatisfied_emitted {
1563 required_tool_unsatisfied_emitted = true;
1564 self.event_bus.publish(EngineEvent::new(
1565 "tool.mode.required.unsatisfied",
1566 json!({
1567 "sessionID": session_id,
1568 "messageID": user_message_id,
1569 "iteration": iteration,
1570 "selectedToolCount": allowed_tool_names.len(),
1571 "offeredToolsPreview": offered_tool_preview,
1572 "reason": latest_required_tool_failure_kind.code(),
1573 }),
1574 ));
1575 }
1576 self.event_bus.publish(EngineEvent::new(
1577 "provider.call.iteration.finish",
1578 json!({
1579 "sessionID": session_id,
1580 "messageID": user_message_id,
1581 "iteration": iteration,
1582 "finishReason": "required_tool_unsatisfied",
1583 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1584 "rejectedToolCalls": 0,
1585 "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1586 }),
1587 ));
1588 } else {
1589 self.event_bus.publish(EngineEvent::new(
1590 "provider.call.iteration.finish",
1591 json!({
1592 "sessionID": session_id,
1593 "messageID": user_message_id,
1594 "iteration": iteration,
1595 "finishReason": "provider_completion",
1596 "acceptedToolCalls": accepted_tool_calls_in_cycle,
1597 "rejectedToolCalls": 0,
1598 }),
1599 ));
1600 }
1601 break;
1602 }
1603 if matches!(requested_tool_mode, ToolMode::Required) && productive_tool_calls_total == 0
1604 {
1605 completion =
1606 required_tool_mode_unsatisfied_completion(latest_required_tool_failure_kind);
1607 if !required_tool_unsatisfied_emitted {
1608 self.event_bus.publish(EngineEvent::new(
1609 "tool.mode.required.unsatisfied",
1610 json!({
1611 "sessionID": session_id,
1612 "messageID": user_message_id,
1613 "selectedToolCount": tool_call_counts.len(),
1614 "reason": latest_required_tool_failure_kind.code(),
1615 }),
1616 ));
1617 }
1618 }
1619 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
1620 if let Some(narrative) = self
1621 .generate_final_narrative_without_tools(
1622 &session_id,
1623 &active_agent,
1624 Some(provider_id.as_str()),
1625 Some(model_id_value.as_str()),
1626 cancel.clone(),
1627 &last_tool_outputs,
1628 )
1629 .await
1630 {
1631 completion = narrative;
1632 }
1633 }
1634 if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
1635 let preview = last_tool_outputs
1636 .iter()
1637 .take(3)
1638 .map(|o| truncate_text(o, 240))
1639 .collect::<Vec<_>>()
1640 .join("\n");
1641 completion = format!(
1642 "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
1643 preview
1644 );
1645 }
1646 if completion.trim().is_empty() {
1647 completion =
1648 "I couldn't produce a final response for that run. Please retry your request."
1649 .to_string();
1650 }
1651 if email_delivery_requested
1652 && !email_action_executed
1653 && completion_claims_email_sent(&completion)
1654 {
1655 let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
1656 .to_string();
1657 if let Some(note) = latest_email_action_note.as_ref() {
1658 fallback.push_str("\n\nLast email tool status: ");
1659 fallback.push_str(note);
1660 }
1661 fallback.push_str(
1662 "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
1663 );
1664 completion = fallback;
1665 }
1666 completion = strip_model_control_markers(&completion);
1667 truncate_text(&completion, 16_000)
1668 };
1669 emit_event(
1670 Level::INFO,
1671 ProcessKind::Engine,
1672 ObservabilityEvent {
1673 event: "provider.call.finish",
1674 component: "engine.loop",
1675 correlation_id: correlation_ref,
1676 session_id: Some(&session_id),
1677 run_id: None,
1678 message_id: Some(&user_message_id),
1679 provider_id: Some(provider_id.as_str()),
1680 model_id,
1681 status: Some("ok"),
1682 error_code: None,
1683 detail: Some("provider stream complete"),
1684 },
1685 );
1686 if active_agent.name.eq_ignore_ascii_case("plan") {
1687 emit_plan_todo_fallback(
1688 self.storage.clone(),
1689 &self.event_bus,
1690 &session_id,
1691 &user_message_id,
1692 &completion,
1693 )
1694 .await;
1695 let todos_after_fallback = self.storage.get_todos(&session_id).await;
1696 if todos_after_fallback.is_empty() && !question_tool_used {
1697 emit_plan_question_fallback(
1698 self.storage.clone(),
1699 &self.event_bus,
1700 &session_id,
1701 &user_message_id,
1702 &completion,
1703 )
1704 .await;
1705 }
1706 }
1707 if cancel.is_cancelled() {
1708 self.event_bus.publish(EngineEvent::new(
1709 "session.status",
1710 json!({"sessionID": session_id, "status":"cancelled"}),
1711 ));
1712 self.cancellations.remove(&session_id).await;
1713 return Ok(());
1714 }
1715 let assistant = Message::new(
1716 MessageRole::Assistant,
1717 vec![MessagePart::Text {
1718 text: completion.clone(),
1719 }],
1720 );
1721 let assistant_message_id = assistant.id.clone();
1722 self.storage.append_message(&session_id, assistant).await?;
1723 let final_part = WireMessagePart::text(
1724 &session_id,
1725 &assistant_message_id,
1726 truncate_text(&completion, 16_000),
1727 );
1728 self.event_bus.publish(EngineEvent::new(
1729 "message.part.updated",
1730 json!({"part": final_part}),
1731 ));
1732 self.event_bus.publish(EngineEvent::new(
1733 "session.updated",
1734 json!({"sessionID": session_id, "status":"idle"}),
1735 ));
1736 self.event_bus.publish(EngineEvent::new(
1737 "session.status",
1738 json!({"sessionID": session_id, "status":"idle"}),
1739 ));
1740 self.cancellations.remove(&session_id).await;
1741 Ok(())
1742 }
1743
1744 pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
1745 self.providers.default_complete(&prompt).await
1746 }
1747
1748 pub async fn run_oneshot_for_provider(
1749 &self,
1750 prompt: String,
1751 provider_id: Option<&str>,
1752 ) -> anyhow::Result<String> {
1753 self.providers
1754 .complete_for_provider(provider_id, &prompt, None)
1755 .await
1756 }
1757
1758 #[allow(clippy::too_many_arguments)]
1759 async fn execute_tool_with_permission(
1760 &self,
1761 session_id: &str,
1762 message_id: &str,
1763 tool: String,
1764 args: Value,
1765 equipped_skills: Option<&[String]>,
1766 latest_user_text: &str,
1767 write_required: bool,
1768 latest_assistant_context: Option<&str>,
1769 cancel: CancellationToken,
1770 ) -> anyhow::Result<Option<String>> {
1771 let tool = normalize_tool_name(&tool);
1772 let raw_args = args.clone();
1773 let normalized = normalize_tool_args_with_mode(
1774 &tool,
1775 args,
1776 latest_user_text,
1777 latest_assistant_context.unwrap_or_default(),
1778 if write_required {
1779 WritePathRecoveryMode::OutputTargetOnly
1780 } else {
1781 WritePathRecoveryMode::Heuristic
1782 },
1783 );
1784 self.event_bus.publish(EngineEvent::new(
1785 "tool.args.normalized",
1786 json!({
1787 "sessionID": session_id,
1788 "messageID": message_id,
1789 "tool": tool,
1790 "argsSource": normalized.args_source,
1791 "argsIntegrity": normalized.args_integrity,
1792 "rawArgsState": normalized.raw_args_state.as_str(),
1793 "query": normalized.query,
1794 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
1795 "requestID": Value::Null
1796 }),
1797 ));
1798 if normalized.args_integrity == "recovered" {
1799 self.event_bus.publish(EngineEvent::new(
1800 "tool.args.recovered",
1801 json!({
1802 "sessionID": session_id,
1803 "messageID": message_id,
1804 "tool": tool,
1805 "argsSource": normalized.args_source,
1806 "query": normalized.query,
1807 "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
1808 "requestID": Value::Null
1809 }),
1810 ));
1811 }
1812 if normalized.missing_terminal {
1813 let missing_reason = normalized
1814 .missing_terminal_reason
1815 .clone()
1816 .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
1817 let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
1818 let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
1819 let latest_user_preview = truncate_text(latest_user_text, 500);
1820 let latest_assistant_preview =
1821 truncate_text(latest_assistant_context.unwrap_or_default(), 500);
1822 self.event_bus.publish(EngineEvent::new(
1823 "tool.args.missing_terminal",
1824 json!({
1825 "sessionID": session_id,
1826 "messageID": message_id,
1827 "tool": tool,
1828 "argsSource": normalized.args_source,
1829 "argsIntegrity": normalized.args_integrity,
1830 "rawArgsState": normalized.raw_args_state.as_str(),
1831 "requestID": Value::Null,
1832 "error": missing_reason,
1833 "rawArgsPreview": raw_args_preview,
1834 "normalizedArgsPreview": normalized_args_preview,
1835 "latestUserPreview": latest_user_preview,
1836 "latestAssistantPreview": latest_assistant_preview,
1837 }),
1838 ));
1839 if tool == "write" {
1840 tracing::warn!(
1841 session_id = %session_id,
1842 message_id = %message_id,
1843 tool = %tool,
1844 reason = %missing_reason,
1845 args_source = %normalized.args_source,
1846 args_integrity = %normalized.args_integrity,
1847 raw_args_state = %normalized.raw_args_state.as_str(),
1848 raw_args = %raw_args_preview,
1849 normalized_args = %normalized_args_preview,
1850 latest_user = %latest_user_preview,
1851 latest_assistant = %latest_assistant_preview,
1852 "write tool arguments missing terminal field"
1853 );
1854 }
1855 let mut failed_part = WireMessagePart::tool_result(
1856 session_id,
1857 message_id,
1858 tool.clone(),
1859 Some(raw_args.clone()),
1860 json!(null),
1861 );
1862 failed_part.state = Some("failed".to_string());
1863 let surfaced_reason =
1864 provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
1865 .unwrap_or_else(|| missing_reason.clone());
1866 failed_part.error = Some(surfaced_reason.clone());
1867 self.event_bus.publish(EngineEvent::new(
1868 "message.part.updated",
1869 json!({"part": failed_part}),
1870 ));
1871 return Ok(Some(surfaced_reason));
1872 }
1873
1874 let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
1875 Ok(args) => args,
1876 Err(message) => return Ok(Some(message)),
1877 };
1878 if let Some(allowed_tools) = self
1879 .session_allowed_tools
1880 .read()
1881 .await
1882 .get(session_id)
1883 .cloned()
1884 {
1885 if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
1886 return Ok(Some(format!("Tool `{tool}` is not allowed for this run.")));
1887 }
1888 }
1889 if let Some(hook) = self.tool_policy_hook.read().await.clone() {
1890 let decision = hook
1891 .evaluate_tool(ToolPolicyContext {
1892 session_id: session_id.to_string(),
1893 message_id: message_id.to_string(),
1894 tool: tool.clone(),
1895 args: args.clone(),
1896 })
1897 .await?;
1898 if !decision.allowed {
1899 let reason = decision
1900 .reason
1901 .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
1902 let mut blocked_part = WireMessagePart::tool_result(
1903 session_id,
1904 message_id,
1905 tool.clone(),
1906 Some(args.clone()),
1907 json!(null),
1908 );
1909 blocked_part.state = Some("failed".to_string());
1910 blocked_part.error = Some(reason.clone());
1911 self.event_bus.publish(EngineEvent::new(
1912 "message.part.updated",
1913 json!({"part": blocked_part}),
1914 ));
1915 return Ok(Some(reason));
1916 }
1917 }
1918 let mut tool_call_id: Option<String> = None;
1919 if let Some(violation) = self
1920 .workspace_sandbox_violation(session_id, &tool, &args)
1921 .await
1922 {
1923 let mut blocked_part = WireMessagePart::tool_result(
1924 session_id,
1925 message_id,
1926 tool.clone(),
1927 Some(args.clone()),
1928 json!(null),
1929 );
1930 blocked_part.state = Some("failed".to_string());
1931 blocked_part.error = Some(violation.clone());
1932 self.event_bus.publish(EngineEvent::new(
1933 "message.part.updated",
1934 json!({"part": blocked_part}),
1935 ));
1936 return Ok(Some(violation));
1937 }
1938 let rule = self
1939 .plugins
1940 .permission_override(&tool)
1941 .await
1942 .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
1943 if matches!(rule, PermissionAction::Deny) {
1944 return Ok(Some(format!(
1945 "Permission denied for tool `{tool}` by policy."
1946 )));
1947 }
1948
1949 let mut effective_args = args.clone();
1950 if matches!(rule, PermissionAction::Ask) {
1951 let auto_approve_permissions = self
1952 .session_auto_approve_permissions
1953 .read()
1954 .await
1955 .get(session_id)
1956 .copied()
1957 .unwrap_or(false);
1958 if auto_approve_permissions {
1959 self.event_bus.publish(EngineEvent::new(
1960 "permission.auto_approved",
1961 json!({
1962 "sessionID": session_id,
1963 "messageID": message_id,
1964 "tool": tool,
1965 }),
1966 ));
1967 effective_args = args;
1968 } else {
1969 let pending = self
1970 .permissions
1971 .ask_for_session_with_context(
1972 Some(session_id),
1973 &tool,
1974 args.clone(),
1975 Some(crate::PermissionArgsContext {
1976 args_source: normalized.args_source.clone(),
1977 args_integrity: normalized.args_integrity.clone(),
1978 query: normalized.query.clone(),
1979 }),
1980 )
1981 .await;
1982 let mut pending_part = WireMessagePart::tool_invocation(
1983 session_id,
1984 message_id,
1985 tool.clone(),
1986 args.clone(),
1987 );
1988 pending_part.id = Some(pending.id.clone());
1989 tool_call_id = Some(pending.id.clone());
1990 pending_part.state = Some("pending".to_string());
1991 self.event_bus.publish(EngineEvent::new(
1992 "message.part.updated",
1993 json!({"part": pending_part}),
1994 ));
1995 let reply = self
1996 .permissions
1997 .wait_for_reply_with_timeout(
1998 &pending.id,
1999 cancel.clone(),
2000 Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2001 )
2002 .await;
2003 let (reply, timed_out) = reply;
2004 if cancel.is_cancelled() {
2005 return Ok(None);
2006 }
2007 if timed_out {
2008 let timeout_ms = permission_wait_timeout_ms();
2009 self.event_bus.publish(EngineEvent::new(
2010 "permission.wait.timeout",
2011 json!({
2012 "sessionID": session_id,
2013 "messageID": message_id,
2014 "tool": tool,
2015 "requestID": pending.id,
2016 "timeoutMs": timeout_ms,
2017 }),
2018 ));
2019 let mut timeout_part = WireMessagePart::tool_result(
2020 session_id,
2021 message_id,
2022 tool.clone(),
2023 Some(args.clone()),
2024 json!(null),
2025 );
2026 timeout_part.id = Some(pending.id);
2027 timeout_part.state = Some("failed".to_string());
2028 timeout_part.error = Some(format!(
2029 "Permission request timed out after {} ms",
2030 timeout_ms
2031 ));
2032 self.event_bus.publish(EngineEvent::new(
2033 "message.part.updated",
2034 json!({"part": timeout_part}),
2035 ));
2036 return Ok(Some(format!(
2037 "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2038 )));
2039 }
2040 let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2041 if !approved {
2042 let mut denied_part = WireMessagePart::tool_result(
2043 session_id,
2044 message_id,
2045 tool.clone(),
2046 Some(args.clone()),
2047 json!(null),
2048 );
2049 denied_part.id = Some(pending.id);
2050 denied_part.state = Some("denied".to_string());
2051 denied_part.error = Some("Permission denied by user".to_string());
2052 self.event_bus.publish(EngineEvent::new(
2053 "message.part.updated",
2054 json!({"part": denied_part}),
2055 ));
2056 return Ok(Some(format!(
2057 "Permission denied for tool `{tool}` by user."
2058 )));
2059 }
2060 effective_args = args;
2061 }
2062 }
2063
2064 let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2065 let tool_context = self.resolve_tool_execution_context(session_id).await;
2066 if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2067 if let Some(obj) = args.as_object_mut() {
2068 obj.insert(
2069 "__workspace_root".to_string(),
2070 Value::String(workspace_root.clone()),
2071 );
2072 obj.insert(
2073 "__effective_cwd".to_string(),
2074 Value::String(effective_cwd.clone()),
2075 );
2076 obj.insert(
2077 "__session_id".to_string(),
2078 Value::String(session_id.to_string()),
2079 );
2080 if let Some(project_id) = project_id.clone() {
2081 obj.insert("__project_id".to_string(), Value::String(project_id));
2082 }
2083 }
2084 tracing::info!(
2085 "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2086 session_id,
2087 tool,
2088 workspace_root,
2089 effective_cwd,
2090 project_id.clone().unwrap_or_default()
2091 );
2092 }
2093 let mut invoke_part =
2094 WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2095 if let Some(call_id) = tool_call_id.clone() {
2096 invoke_part.id = Some(call_id);
2097 }
2098 let invoke_part_id = invoke_part.id.clone();
2099 self.event_bus.publish(EngineEvent::new(
2100 "message.part.updated",
2101 json!({"part": invoke_part}),
2102 ));
2103 let args_for_side_events = args.clone();
2104 if tool == "spawn_agent" {
2105 let hook = self.spawn_agent_hook.read().await.clone();
2106 if let Some(hook) = hook {
2107 let spawned = hook
2108 .spawn_agent(SpawnAgentToolContext {
2109 session_id: session_id.to_string(),
2110 message_id: message_id.to_string(),
2111 tool_call_id: invoke_part_id.clone(),
2112 args: args_for_side_events.clone(),
2113 })
2114 .await?;
2115 let output = self.plugins.transform_tool_output(spawned.output).await;
2116 let output = truncate_text(&output, 16_000);
2117 emit_tool_side_events(
2118 self.storage.clone(),
2119 &self.event_bus,
2120 ToolSideEventContext {
2121 session_id,
2122 message_id,
2123 tool: &tool,
2124 args: &args_for_side_events,
2125 metadata: &spawned.metadata,
2126 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2127 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2128 },
2129 )
2130 .await;
2131 let mut result_part = WireMessagePart::tool_result(
2132 session_id,
2133 message_id,
2134 tool.clone(),
2135 Some(args_for_side_events.clone()),
2136 json!(output.clone()),
2137 );
2138 result_part.id = invoke_part_id;
2139 self.event_bus.publish(EngineEvent::new(
2140 "message.part.updated",
2141 json!({"part": result_part}),
2142 ));
2143 return Ok(Some(truncate_text(
2144 &format!("Tool `{tool}` result:\n{output}"),
2145 16_000,
2146 )));
2147 }
2148 let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2149 let mut failed_part = WireMessagePart::tool_result(
2150 session_id,
2151 message_id,
2152 tool.clone(),
2153 Some(args_for_side_events.clone()),
2154 json!(null),
2155 );
2156 failed_part.id = invoke_part_id.clone();
2157 failed_part.state = Some("failed".to_string());
2158 failed_part.error = Some(output.to_string());
2159 self.event_bus.publish(EngineEvent::new(
2160 "message.part.updated",
2161 json!({"part": failed_part}),
2162 ));
2163 return Ok(Some(output.to_string()));
2164 }
2165 let result = match self
2166 .execute_tool_with_timeout(&tool, args, cancel.clone())
2167 .await
2168 {
2169 Ok(result) => result,
2170 Err(err) => {
2171 let err_text = err.to_string();
2172 if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
2173 let timeout_ms = tool_exec_timeout_ms();
2174 let timeout_output = format!(
2175 "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
2176 );
2177 let mut failed_part = WireMessagePart::tool_result(
2178 session_id,
2179 message_id,
2180 tool.clone(),
2181 Some(args_for_side_events.clone()),
2182 json!(null),
2183 );
2184 failed_part.id = invoke_part_id.clone();
2185 failed_part.state = Some("failed".to_string());
2186 failed_part.error = Some(timeout_output.clone());
2187 self.event_bus.publish(EngineEvent::new(
2188 "message.part.updated",
2189 json!({"part": failed_part}),
2190 ));
2191 return Ok(Some(timeout_output));
2192 }
2193 if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
2194 self.event_bus.publish(EngineEvent::new(
2195 "mcp.auth.required",
2196 json!({
2197 "sessionID": session_id,
2198 "messageID": message_id,
2199 "tool": tool.clone(),
2200 "server": auth.server,
2201 "authorizationUrl": auth.authorization_url,
2202 "message": auth.message,
2203 "challengeId": auth.challenge_id
2204 }),
2205 ));
2206 let auth_output = format!(
2207 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
2208 tool, auth.message, auth.authorization_url
2209 );
2210 let mut result_part = WireMessagePart::tool_result(
2211 session_id,
2212 message_id,
2213 tool.clone(),
2214 Some(args_for_side_events.clone()),
2215 json!(auth_output.clone()),
2216 );
2217 result_part.id = invoke_part_id.clone();
2218 self.event_bus.publish(EngineEvent::new(
2219 "message.part.updated",
2220 json!({"part": result_part}),
2221 ));
2222 return Ok(Some(truncate_text(
2223 &format!("Tool `{tool}` result:\n{auth_output}"),
2224 16_000,
2225 )));
2226 }
2227 let mut failed_part = WireMessagePart::tool_result(
2228 session_id,
2229 message_id,
2230 tool.clone(),
2231 Some(args_for_side_events.clone()),
2232 json!(null),
2233 );
2234 failed_part.id = invoke_part_id.clone();
2235 failed_part.state = Some("failed".to_string());
2236 failed_part.error = Some(err_text.clone());
2237 self.event_bus.publish(EngineEvent::new(
2238 "message.part.updated",
2239 json!({"part": failed_part}),
2240 ));
2241 return Err(err);
2242 }
2243 };
2244 if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
2245 let event_name = if auth.pending && auth.blocked {
2246 "mcp.auth.pending"
2247 } else {
2248 "mcp.auth.required"
2249 };
2250 self.event_bus.publish(EngineEvent::new(
2251 event_name,
2252 json!({
2253 "sessionID": session_id,
2254 "messageID": message_id,
2255 "tool": tool.clone(),
2256 "server": auth.server,
2257 "authorizationUrl": auth.authorization_url,
2258 "message": auth.message,
2259 "challengeId": auth.challenge_id,
2260 "pending": auth.pending,
2261 "blocked": auth.blocked,
2262 "retryAfterMs": auth.retry_after_ms
2263 }),
2264 ));
2265 }
2266 emit_tool_side_events(
2267 self.storage.clone(),
2268 &self.event_bus,
2269 ToolSideEventContext {
2270 session_id,
2271 message_id,
2272 tool: &tool,
2273 args: &args_for_side_events,
2274 metadata: &result.metadata,
2275 workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2276 effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2277 },
2278 )
2279 .await;
2280 let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
2281 if auth.pending && auth.blocked {
2282 let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
2283 format!(
2284 "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
2285 tool, auth.message, auth.authorization_url, retry_after_secs
2286 )
2287 } else {
2288 format!(
2289 "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
2290 tool, auth.message, auth.authorization_url
2291 )
2292 }
2293 } else {
2294 self.plugins.transform_tool_output(result.output).await
2295 };
2296 let output = truncate_text(&output, 16_000);
2297 let mut result_part = WireMessagePart::tool_result(
2298 session_id,
2299 message_id,
2300 tool.clone(),
2301 Some(args_for_side_events.clone()),
2302 json!(output.clone()),
2303 );
2304 result_part.id = invoke_part_id;
2305 self.event_bus.publish(EngineEvent::new(
2306 "message.part.updated",
2307 json!({"part": result_part}),
2308 ));
2309 Ok(Some(truncate_text(
2310 &format!("Tool `{tool}` result:\n{output}"),
2311 16_000,
2312 )))
2313 }
2314
2315 async fn execute_tool_with_timeout(
2316 &self,
2317 tool: &str,
2318 args: Value,
2319 cancel: CancellationToken,
2320 ) -> anyhow::Result<tandem_types::ToolResult> {
2321 let timeout_ms = tool_exec_timeout_ms() as u64;
2322 match tokio::time::timeout(
2323 Duration::from_millis(timeout_ms),
2324 self.tools.execute_with_cancel(tool, args, cancel),
2325 )
2326 .await
2327 {
2328 Ok(result) => result,
2329 Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
2330 }
2331 }
2332
2333 async fn find_recent_matching_user_message_id(
2334 &self,
2335 session_id: &str,
2336 text: &str,
2337 ) -> Option<String> {
2338 let session = self.storage.get_session(session_id).await?;
2339 let last = session.messages.last()?;
2340 if !matches!(last.role, MessageRole::User) {
2341 return None;
2342 }
2343 let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
2344 if age_ms > 10_000 {
2345 return None;
2346 }
2347 let last_text = last
2348 .parts
2349 .iter()
2350 .filter_map(|part| match part {
2351 MessagePart::Text { text } => Some(text.clone()),
2352 _ => None,
2353 })
2354 .collect::<Vec<_>>()
2355 .join("\n");
2356 if last_text == text {
2357 return Some(last.id.clone());
2358 }
2359 None
2360 }
2361
2362 async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
2363 let Some(mut session) = self.storage.get_session(session_id).await else {
2364 return;
2365 };
2366 if !title_needs_repair(&session.title) {
2367 return;
2368 }
2369
2370 let first_user_text = session.messages.iter().find_map(|message| {
2371 if !matches!(message.role, MessageRole::User) {
2372 return None;
2373 }
2374 message.parts.iter().find_map(|part| match part {
2375 MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
2376 _ => None,
2377 })
2378 });
2379
2380 let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
2381 let Some(title) = derive_session_title_from_prompt(&source, 60) else {
2382 return;
2383 };
2384
2385 session.title = title;
2386 session.time.updated = Utc::now();
2387 let _ = self.storage.save_session(session).await;
2388 }
2389
2390 async fn workspace_sandbox_violation(
2391 &self,
2392 session_id: &str,
2393 tool: &str,
2394 args: &Value,
2395 ) -> Option<String> {
2396 if self.workspace_override_active(session_id).await {
2397 return None;
2398 }
2399 let session = self.storage.get_session(session_id).await?;
2400 let workspace = session
2401 .workspace_root
2402 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
2403 let workspace_path = PathBuf::from(&workspace);
2404 let candidate_paths = extract_tool_candidate_paths(tool, args);
2405 if candidate_paths.is_empty() {
2406 if is_shell_tool_name(tool) {
2407 if let Some(command) = extract_shell_command(args) {
2408 if shell_command_targets_sensitive_path(&command) {
2409 return Some(format!(
2410 "Sandbox blocked `{tool}` command targeting sensitive paths."
2411 ));
2412 }
2413 }
2414 }
2415 return None;
2416 }
2417 if let Some(sensitive) = candidate_paths.iter().find(|path| {
2418 let raw = Path::new(path);
2419 let resolved = if raw.is_absolute() {
2420 raw.to_path_buf()
2421 } else {
2422 workspace_path.join(raw)
2423 };
2424 is_sensitive_path_candidate(&resolved)
2425 }) {
2426 return Some(format!(
2427 "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
2428 ));
2429 }
2430
2431 let outside = candidate_paths.iter().find(|path| {
2432 let raw = Path::new(path);
2433 let resolved = if raw.is_absolute() {
2434 raw.to_path_buf()
2435 } else {
2436 workspace_path.join(raw)
2437 };
2438 !crate::is_within_workspace_root(&resolved, &workspace_path)
2439 })?;
2440 Some(format!(
2441 "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
2442 ))
2443 }
2444
2445 async fn resolve_tool_execution_context(
2446 &self,
2447 session_id: &str,
2448 ) -> Option<(String, String, Option<String>)> {
2449 let session = self.storage.get_session(session_id).await?;
2450 let workspace_root = session
2451 .workspace_root
2452 .or_else(|| crate::normalize_workspace_path(&session.directory))?;
2453 let effective_cwd = if session.directory.trim().is_empty()
2454 || session.directory.trim() == "."
2455 {
2456 workspace_root.clone()
2457 } else {
2458 crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
2459 };
2460 let project_id = session
2461 .project_id
2462 .clone()
2463 .or_else(|| crate::workspace_project_id(&workspace_root));
2464 Some((workspace_root, effective_cwd, project_id))
2465 }
2466
2467 async fn workspace_override_active(&self, session_id: &str) -> bool {
2468 let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
2469 let mut overrides = self.workspace_overrides.write().await;
2470 overrides.retain(|_, expires_at| *expires_at > now);
2471 overrides
2472 .get(session_id)
2473 .map(|expires_at| *expires_at > now)
2474 .unwrap_or(false)
2475 }
2476
2477 async fn generate_final_narrative_without_tools(
2478 &self,
2479 session_id: &str,
2480 active_agent: &AgentDefinition,
2481 provider_hint: Option<&str>,
2482 model_id: Option<&str>,
2483 cancel: CancellationToken,
2484 tool_outputs: &[String],
2485 ) -> Option<String> {
2486 if cancel.is_cancelled() {
2487 return None;
2488 }
2489 let mut messages = load_chat_history(
2490 self.storage.clone(),
2491 session_id,
2492 ChatHistoryProfile::Standard,
2493 )
2494 .await;
2495 let mut system_parts = vec![tandem_runtime_system_prompt(
2496 &self.host_runtime_context,
2497 &[],
2498 )];
2499 if let Some(system) = active_agent.system_prompt.as_ref() {
2500 system_parts.push(system.clone());
2501 }
2502 messages.insert(
2503 0,
2504 ChatMessage {
2505 role: "system".to_string(),
2506 content: system_parts.join("\n\n"),
2507 attachments: Vec::new(),
2508 },
2509 );
2510 messages.push(ChatMessage {
2511 role: "user".to_string(),
2512 content: format!(
2513 "Tool observations:\n{}\n\nProvide a direct final answer now. Do not call tools.",
2514 summarize_tool_outputs(tool_outputs)
2515 ),
2516 attachments: Vec::new(),
2517 });
2518 let stream = self
2519 .providers
2520 .stream_for_provider(
2521 provider_hint,
2522 model_id,
2523 messages,
2524 ToolMode::None,
2525 None,
2526 cancel.clone(),
2527 )
2528 .await
2529 .ok()?;
2530 tokio::pin!(stream);
2531 let mut completion = String::new();
2532 while let Some(chunk) = stream.next().await {
2533 if cancel.is_cancelled() {
2534 return None;
2535 }
2536 match chunk {
2537 Ok(StreamChunk::TextDelta(delta)) => {
2538 let delta = strip_model_control_markers(&delta);
2539 if !delta.trim().is_empty() {
2540 completion.push_str(&delta);
2541 }
2542 }
2543 Ok(StreamChunk::Done { .. }) => break,
2544 Ok(_) => {}
2545 Err(_) => return None,
2546 }
2547 }
2548 let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
2549 if completion.trim().is_empty() {
2550 None
2551 } else {
2552 Some(completion)
2553 }
2554 }
2555}
2556
2557fn resolve_model_route(
2558 request_model: Option<&ModelSpec>,
2559 session_model: Option<&ModelSpec>,
2560) -> Option<(String, String)> {
2561 fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
2562 let provider_id = spec.provider_id.trim();
2563 let model_id = spec.model_id.trim();
2564 if provider_id.is_empty() || model_id.is_empty() {
2565 return None;
2566 }
2567 Some((provider_id.to_string(), model_id.to_string()))
2568 }
2569
2570 request_model
2571 .and_then(normalize)
2572 .or_else(|| session_model.and_then(normalize))
2573}
2574
2575fn strip_model_control_markers(input: &str) -> String {
2576 let mut cleaned = input.to_string();
2577 for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
2578 if cleaned.contains(marker) {
2579 cleaned = cleaned.replace(marker, "");
2580 }
2581 }
2582 cleaned
2583}
2584
2585fn truncate_text(input: &str, max_len: usize) -> String {
2586 if input.len() <= max_len {
2587 return input.to_string();
2588 }
2589 let mut out = input[..max_len].to_string();
2590 out.push_str("...<truncated>");
2591 out
2592}
2593
2594fn provider_error_code(error_text: &str) -> &'static str {
2595 let lower = error_text.to_lowercase();
2596 if lower.contains("invalid_function_parameters")
2597 || lower.contains("array schema missing items")
2598 || lower.contains("tool schema")
2599 {
2600 return "TOOL_SCHEMA_INVALID";
2601 }
2602 if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
2603 {
2604 return "RATE_LIMIT_EXCEEDED";
2605 }
2606 if lower.contains("context length")
2607 || lower.contains("max tokens")
2608 || lower.contains("token limit")
2609 {
2610 return "CONTEXT_LENGTH_EXCEEDED";
2611 }
2612 if lower.contains("unauthorized")
2613 || lower.contains("authentication")
2614 || lower.contains("401")
2615 || lower.contains("403")
2616 {
2617 return "AUTHENTICATION_ERROR";
2618 }
2619 if lower.contains("timeout") || lower.contains("timed out") {
2620 return "TIMEOUT";
2621 }
2622 if lower.contains("server error")
2623 || lower.contains("500")
2624 || lower.contains("502")
2625 || lower.contains("503")
2626 || lower.contains("504")
2627 {
2628 return "PROVIDER_SERVER_ERROR";
2629 }
2630 "PROVIDER_REQUEST_FAILED"
2631}
2632
2633fn normalize_tool_name(name: &str) -> String {
2634 let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
2635 for prefix in [
2636 "default_api:",
2637 "default_api.",
2638 "functions.",
2639 "function.",
2640 "tools.",
2641 "tool.",
2642 "builtin:",
2643 "builtin.",
2644 ] {
2645 if let Some(rest) = normalized.strip_prefix(prefix) {
2646 let trimmed = rest.trim();
2647 if !trimmed.is_empty() {
2648 normalized = trimmed.to_string();
2649 break;
2650 }
2651 }
2652 }
2653 match normalized.as_str() {
2654 "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
2655 "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
2656 other => other.to_string(),
2657 }
2658}
2659
2660fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
2661 let mut parts = tool_name.split('.');
2662 let prefix = parts.next()?;
2663 if prefix != "mcp" {
2664 return None;
2665 }
2666 parts.next().filter(|server| !server.is_empty())
2667}
2668
2669fn requires_web_research_prompt(input: &str) -> bool {
2670 let lower = input.to_ascii_lowercase();
2671 [
2672 "research",
2673 "top news",
2674 "today's news",
2675 "todays news",
2676 "with links",
2677 "latest headlines",
2678 "current events",
2679 ]
2680 .iter()
2681 .any(|needle| lower.contains(needle))
2682}
2683
2684fn requires_email_delivery_prompt(input: &str) -> bool {
2685 let lower = input.to_ascii_lowercase();
2686 (lower.contains("send") && lower.contains("email"))
2687 || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
2688 || lower.contains("email to")
2689}
2690
2691fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
2692 schemas.iter().any(|schema| {
2693 let name = normalize_tool_name(&schema.name);
2694 name == "websearch" || name == "webfetch" || name == "webfetch_html"
2695 })
2696}
2697
2698fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
2699 schemas
2700 .iter()
2701 .map(|schema| normalize_tool_name(&schema.name))
2702 .any(|name| tool_name_looks_like_email_action(&name))
2703}
2704
2705fn tool_name_looks_like_email_action(name: &str) -> bool {
2706 let normalized = normalize_tool_name(name);
2707 if normalized.starts_with("mcp.") {
2708 return normalized.contains("gmail")
2709 || normalized.contains("mail")
2710 || normalized.contains("email");
2711 }
2712 normalized.contains("mail") || normalized.contains("email")
2713}
2714
2715fn completion_claims_email_sent(text: &str) -> bool {
2716 let lower = text.to_ascii_lowercase();
2717 let has_email_marker = lower.contains("email status")
2718 || lower.contains("emailed")
2719 || lower.contains("email sent")
2720 || lower.contains("sent to");
2721 has_email_marker
2722 && (lower.contains("sent")
2723 || lower.contains("delivered")
2724 || lower.contains("has been sent"))
2725}
2726
2727fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
2728 let Some(obj) = args.as_object() else {
2729 return Vec::new();
2730 };
2731 let keys: &[&str] = match tool {
2732 "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
2733 "glob" => &["pattern"],
2734 "lsp" => &["filePath", "path"],
2735 "bash" => &["cwd"],
2736 "apply_patch" => &[],
2737 _ => &["path", "cwd"],
2738 };
2739 keys.iter()
2740 .filter_map(|key| obj.get(*key))
2741 .filter_map(|value| value.as_str())
2742 .filter(|s| !s.trim().is_empty())
2743 .map(ToString::to_string)
2744 .collect()
2745}
2746
2747fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
2748 let target = normalize_tool_name(tool_name);
2749 match agent.tools.as_ref() {
2750 None => true,
2751 Some(list) => {
2752 let normalized = list
2753 .iter()
2754 .map(|t| normalize_tool_name(t))
2755 .collect::<Vec<_>>();
2756 any_policy_matches(&normalized, &target)
2757 }
2758 }
2759}
2760
2761fn enforce_skill_scope(
2762 tool_name: &str,
2763 args: Value,
2764 equipped_skills: Option<&[String]>,
2765) -> Result<Value, String> {
2766 if normalize_tool_name(tool_name) != "skill" {
2767 return Ok(args);
2768 }
2769 let Some(configured) = equipped_skills else {
2770 return Ok(args);
2771 };
2772
2773 let mut allowed = configured
2774 .iter()
2775 .map(|s| s.trim().to_string())
2776 .filter(|s| !s.is_empty())
2777 .collect::<Vec<_>>();
2778 if allowed
2779 .iter()
2780 .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
2781 {
2782 return Ok(args);
2783 }
2784 allowed.sort();
2785 allowed.dedup();
2786 if allowed.is_empty() {
2787 return Err("No skills are equipped for this agent.".to_string());
2788 }
2789
2790 let requested = args
2791 .get("name")
2792 .and_then(|v| v.as_str())
2793 .map(|v| v.trim().to_string())
2794 .unwrap_or_default();
2795 if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
2796 return Err(format!(
2797 "Skill '{}' is not equipped for this agent. Equipped skills: {}",
2798 requested,
2799 allowed.join(", ")
2800 ));
2801 }
2802
2803 let mut out = if let Some(obj) = args.as_object() {
2804 Value::Object(obj.clone())
2805 } else {
2806 json!({})
2807 };
2808 if let Some(obj) = out.as_object_mut() {
2809 obj.insert("allowed_skills".to_string(), json!(allowed));
2810 }
2811 Ok(out)
2812}
2813
2814fn is_read_only_tool(tool_name: &str) -> bool {
2815 matches!(
2816 normalize_tool_name(tool_name).as_str(),
2817 "glob"
2818 | "read"
2819 | "grep"
2820 | "search"
2821 | "codesearch"
2822 | "list"
2823 | "ls"
2824 | "lsp"
2825 | "websearch"
2826 | "webfetch"
2827 | "webfetch_html"
2828 )
2829}
2830
2831fn is_workspace_write_tool(tool_name: &str) -> bool {
2832 matches!(
2833 normalize_tool_name(tool_name).as_str(),
2834 "write" | "edit" | "apply_patch"
2835 )
2836}
2837
2838fn is_batch_wrapper_tool_name(name: &str) -> bool {
2839 matches!(
2840 normalize_tool_name(name).as_str(),
2841 "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
2842 )
2843}
2844
2845fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
2846 obj.get(key)
2847 .and_then(|v| v.as_str())
2848 .map(str::trim)
2849 .filter(|s| !s.is_empty())
2850}
2851
2852fn nested_non_empty_string_at<'a>(
2853 obj: &'a Map<String, Value>,
2854 parent: &str,
2855 key: &str,
2856) -> Option<&'a str> {
2857 obj.get(parent)
2858 .and_then(|v| v.as_object())
2859 .and_then(|nested| nested.get(key))
2860 .and_then(|v| v.as_str())
2861 .map(str::trim)
2862 .filter(|s| !s.is_empty())
2863}
2864
2865fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
2866 let calls = args
2867 .get("tool_calls")
2868 .and_then(|v| v.as_array())
2869 .cloned()
2870 .unwrap_or_default();
2871 calls
2872 .into_iter()
2873 .filter_map(|call| {
2874 let obj = call.as_object()?;
2875 let tool_raw = non_empty_string_at(obj, "tool")
2876 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
2877 .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
2878 .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
2879 .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
2880 let name_raw = non_empty_string_at(obj, "name")
2881 .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
2882 .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
2883 .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
2884 .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
2885 let effective = match (tool_raw, name_raw) {
2886 (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
2887 (Some(t), _) => t,
2888 (None, Some(n)) => n,
2889 (None, None) => return None,
2890 };
2891 let normalized = normalize_tool_name(effective);
2892 let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
2893 Some((normalized, call_args))
2894 })
2895 .collect()
2896}
2897
2898fn is_read_only_batch_call(args: &Value) -> bool {
2899 let calls = extract_batch_calls(args);
2900 !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
2901}
2902
2903fn batch_tool_signature(args: &Value) -> Option<String> {
2904 let calls = extract_batch_calls(args);
2905 if calls.is_empty() {
2906 return None;
2907 }
2908 let parts = calls
2909 .into_iter()
2910 .map(|(tool, call_args)| tool_signature(&tool, &call_args))
2911 .collect::<Vec<_>>();
2912 Some(format!("batch:{}", parts.join("|")))
2913}
2914
2915fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
2916 let normalized_tool = normalize_tool_name(tool_name);
2917 if normalized_tool == "batch" && is_non_productive_batch_output(output) {
2918 return false;
2919 }
2920 if is_auth_required_tool_output(output) {
2921 return false;
2922 }
2923 let Some(result_body) = extract_tool_result_body(output) else {
2924 return false;
2925 };
2926 !is_non_productive_tool_result_body(result_body)
2927}
2928
2929fn extract_tool_result_body(output: &str) -> Option<&str> {
2930 let trimmed = output.trim();
2931 let rest = trimmed.strip_prefix("Tool `")?;
2932 let (_, result_body) = rest.split_once("` result:")?;
2933 Some(result_body.trim())
2934}
2935
2936fn is_non_productive_tool_result_body(output: &str) -> bool {
2937 let trimmed = output.trim();
2938 if trimmed.is_empty() {
2939 return true;
2940 }
2941 let lower = trimmed.to_ascii_lowercase();
2942 lower.starts_with("unknown tool:")
2943 || lower.contains("call skipped")
2944 || lower.contains("guard budget exceeded")
2945 || lower.contains("invalid_function_parameters")
2946 || is_terminal_tool_error_reason(trimmed)
2947}
2948
2949fn is_terminal_tool_error_reason(output: &str) -> bool {
2950 let first_line = output.lines().next().unwrap_or_default().trim();
2951 if first_line.is_empty() {
2952 return false;
2953 }
2954 let normalized = first_line.to_ascii_uppercase();
2955 matches!(
2956 normalized.as_str(),
2957 "TOOL_ARGUMENTS_MISSING"
2958 | "WEBSEARCH_QUERY_MISSING"
2959 | "BASH_COMMAND_MISSING"
2960 | "FILE_PATH_MISSING"
2961 | "WRITE_CONTENT_MISSING"
2962 | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
2963 | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
2964 | "WEBFETCH_URL_MISSING"
2965 | "PACK_BUILDER_PLAN_ID_MISSING"
2966 | "PACK_BUILDER_GOAL_MISSING"
2967 | "PROVIDER_REQUEST_FAILED"
2968 | "AUTHENTICATION_ERROR"
2969 | "CONTEXT_LENGTH_EXCEEDED"
2970 | "RATE_LIMIT_EXCEEDED"
2971 ) || normalized.ends_with("_MISSING")
2972 || normalized.ends_with("_ERROR")
2973}
2974
2975fn is_non_productive_batch_output(output: &str) -> bool {
2976 let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
2977 return false;
2978 };
2979 let Some(items) = value.as_array() else {
2980 return false;
2981 };
2982 if items.is_empty() {
2983 return true;
2984 }
2985 items.iter().all(|item| {
2986 let text = item
2987 .get("output")
2988 .and_then(|v| v.as_str())
2989 .map(str::trim)
2990 .unwrap_or_default()
2991 .to_ascii_lowercase();
2992 text.is_empty()
2993 || text.starts_with("unknown tool:")
2994 || text.contains("call skipped")
2995 || text.contains("guard budget exceeded")
2996 })
2997}
2998
2999fn is_auth_required_tool_output(output: &str) -> bool {
3000 let lower = output.to_ascii_lowercase();
3001 (lower.contains("authorization required")
3002 || lower.contains("requires authorization")
3003 || lower.contains("authorization pending"))
3004 && (lower.contains("authorize here") || lower.contains("http"))
3005}
3006
3007#[derive(Debug, Clone)]
3008struct McpAuthRequiredMetadata {
3009 challenge_id: String,
3010 authorization_url: String,
3011 message: String,
3012 server: Option<String>,
3013 pending: bool,
3014 blocked: bool,
3015 retry_after_ms: Option<u64>,
3016}
3017
3018fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
3019 let auth = metadata.get("mcpAuth")?;
3020 if !auth
3021 .get("required")
3022 .and_then(|v| v.as_bool())
3023 .unwrap_or(false)
3024 {
3025 return None;
3026 }
3027 let authorization_url = auth
3028 .get("authorizationUrl")
3029 .and_then(|v| v.as_str())
3030 .map(str::trim)
3031 .filter(|v| !v.is_empty())?
3032 .to_string();
3033 let message = auth
3034 .get("message")
3035 .and_then(|v| v.as_str())
3036 .map(str::trim)
3037 .filter(|v| !v.is_empty())
3038 .unwrap_or("This tool requires authorization before it can run.")
3039 .to_string();
3040 let challenge_id = auth
3041 .get("challengeId")
3042 .and_then(|v| v.as_str())
3043 .map(str::trim)
3044 .filter(|v| !v.is_empty())
3045 .unwrap_or("unknown")
3046 .to_string();
3047 let server = metadata
3048 .get("server")
3049 .and_then(|v| v.as_str())
3050 .map(str::trim)
3051 .filter(|v| !v.is_empty())
3052 .map(ToString::to_string);
3053 let pending = auth
3054 .get("pending")
3055 .and_then(|v| v.as_bool())
3056 .unwrap_or(false);
3057 let blocked = auth
3058 .get("blocked")
3059 .and_then(|v| v.as_bool())
3060 .unwrap_or(false);
3061 let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
3062 Some(McpAuthRequiredMetadata {
3063 challenge_id,
3064 authorization_url,
3065 message,
3066 server,
3067 pending,
3068 blocked,
3069 retry_after_ms,
3070 })
3071}
3072
3073fn extract_mcp_auth_required_from_error_text(
3074 tool_name: &str,
3075 error_text: &str,
3076) -> Option<McpAuthRequiredMetadata> {
3077 let lower = error_text.to_ascii_lowercase();
3078 let auth_hint = lower.contains("authorization")
3079 || lower.contains("oauth")
3080 || lower.contains("invalid oauth token")
3081 || lower.contains("requires authorization");
3082 if !auth_hint {
3083 return None;
3084 }
3085 let authorization_url = find_first_url(error_text)?;
3086 let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
3087 let server = tool_name
3088 .strip_prefix("mcp.")
3089 .and_then(|rest| rest.split('.').next())
3090 .filter(|s| !s.is_empty())
3091 .map(ToString::to_string);
3092 Some(McpAuthRequiredMetadata {
3093 challenge_id,
3094 authorization_url,
3095 message: "This integration requires authorization before this action can run.".to_string(),
3096 server,
3097 pending: false,
3098 blocked: false,
3099 retry_after_ms: None,
3100 })
3101}
3102
3103fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
3104 if outputs.is_empty()
3105 || !outputs
3106 .iter()
3107 .all(|output| is_auth_required_tool_output(output))
3108 {
3109 return None;
3110 }
3111 let mut auth_lines = outputs
3112 .iter()
3113 .filter_map(|output| {
3114 let trimmed = output.trim();
3115 if trimmed.is_empty() {
3116 None
3117 } else {
3118 Some(trimmed.to_string())
3119 }
3120 })
3121 .collect::<Vec<_>>();
3122 auth_lines.sort();
3123 auth_lines.dedup();
3124 if auth_lines.is_empty() {
3125 return None;
3126 }
3127 Some(format!(
3128 "Authorization is required before I can continue with this action.\n\n{}",
3129 auth_lines.join("\n\n")
3130 ))
3131}
3132
3133fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
3134 if outputs.is_empty()
3135 || !outputs
3136 .iter()
3137 .all(|output| is_guard_budget_tool_output(output))
3138 {
3139 return None;
3140 }
3141 let mut lines = outputs
3142 .iter()
3143 .filter_map(|output| {
3144 let trimmed = output.trim();
3145 if trimmed.is_empty() {
3146 None
3147 } else {
3148 Some(trimmed.to_string())
3149 }
3150 })
3151 .collect::<Vec<_>>();
3152 lines.sort();
3153 lines.dedup();
3154 if lines.is_empty() {
3155 return None;
3156 }
3157 Some(format!(
3158 "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.",
3159 lines.join("\n")
3160 ))
3161}
3162
3163fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
3164 if outputs.is_empty()
3165 || !outputs
3166 .iter()
3167 .all(|output| is_duplicate_signature_limit_output(output))
3168 {
3169 return None;
3170 }
3171 let mut lines = outputs
3172 .iter()
3173 .filter_map(|output| {
3174 let trimmed = output.trim();
3175 if trimmed.is_empty() {
3176 None
3177 } else {
3178 Some(trimmed.to_string())
3179 }
3180 })
3181 .collect::<Vec<_>>();
3182 lines.sort();
3183 lines.dedup();
3184 if lines.is_empty() {
3185 return None;
3186 }
3187 Some(format!(
3188 "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.",
3189 lines.join("\n")
3190 ))
3191}
3192
3193const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
3194
3195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3196enum RequiredToolFailureKind {
3197 NoToolCallEmitted,
3198 ToolCallParseFailed,
3199 ToolCallInvalidArgs,
3200 WriteArgsEmptyFromProvider,
3201 WriteArgsUnparseableFromProvider,
3202 ToolCallRejectedByPolicy,
3203 ToolCallExecutedNonProductive,
3204 WriteRequiredNotSatisfied,
3205}
3206
3207impl RequiredToolFailureKind {
3208 fn code(self) -> &'static str {
3209 match self {
3210 Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
3211 Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
3212 Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
3213 Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
3214 Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
3215 Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
3216 Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
3217 Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
3218 }
3219 }
3220}
3221
3222fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
3223 format!(
3224 "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
3225 reason.code()
3226 )
3227}
3228
3229fn build_required_tool_retry_context(
3230 offered_tool_preview: &str,
3231 previous_reason: RequiredToolFailureKind,
3232) -> String {
3233 let offered = offered_tool_preview.trim();
3234 let available_tools = if offered.is_empty() {
3235 "Use one of the tools offered in this turn before you produce final text.".to_string()
3236 } else {
3237 format!("Use one of these offered tools before you produce final text: {offered}.")
3238 };
3239 let execution_instruction = if previous_reason
3240 == RequiredToolFailureKind::WriteRequiredNotSatisfied
3241 {
3242 "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
3243 } else if is_write_invalid_args_failure_kind(previous_reason) {
3244 "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()
3245 } else {
3246 available_tools
3247 };
3248 format!(
3249 "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
3250 previous_reason.code(),
3251 execution_instruction
3252 )
3253}
3254
3255fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
3256 matches!(
3257 reason,
3258 RequiredToolFailureKind::ToolCallInvalidArgs
3259 | RequiredToolFailureKind::WriteArgsEmptyFromProvider
3260 | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
3261 )
3262}
3263
3264fn build_write_required_retry_context(
3265 offered_tool_preview: &str,
3266 previous_reason: RequiredToolFailureKind,
3267 latest_user_text: &str,
3268) -> String {
3269 let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
3270 if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
3271 prompt.push(' ');
3272 prompt.push_str(&format!(
3273 "The required output target for this task is `{path}`. Write or update that file now."
3274 ));
3275 }
3276 prompt
3277}
3278
3279fn invalid_tool_args_retry_max_attempts() -> usize {
3280 2
3281}
3282
3283fn build_invalid_tool_args_retry_context_from_outputs(
3284 outputs: &[String],
3285 previous_attempts: usize,
3286) -> Option<String> {
3287 if outputs
3288 .iter()
3289 .any(|output| output.contains("BASH_COMMAND_MISSING"))
3290 {
3291 let emphasis = if previous_attempts > 0 {
3292 "You already tried `bash` without a valid command. Do not repeat an empty bash call."
3293 } else {
3294 "If you use `bash`, include a full non-empty command string."
3295 };
3296 return Some(format!(
3297 "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."
3298 ));
3299 }
3300 if outputs
3301 .iter()
3302 .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
3303 {
3304 return Some(
3305 "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(),
3306 );
3307 }
3308 if outputs
3309 .iter()
3310 .any(|output| output.contains("WEBFETCH_URL_MISSING"))
3311 {
3312 return Some(
3313 "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
3314 );
3315 }
3316 if outputs
3317 .iter()
3318 .any(|output| output.contains("FILE_PATH_MISSING"))
3319 {
3320 return Some(
3321 "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(),
3322 );
3323 }
3324 if outputs
3325 .iter()
3326 .any(|output| output.contains("WRITE_CONTENT_MISSING"))
3327 {
3328 return Some(
3329 "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(),
3330 );
3331 }
3332 None
3333}
3334
3335fn looks_like_unparsed_tool_payload(output: &str) -> bool {
3336 let trimmed = output.trim();
3337 if trimmed.is_empty() {
3338 return false;
3339 }
3340 let lower = trimmed.to_ascii_lowercase();
3341 lower.contains("\"tool_calls\"")
3342 || lower.contains("\"function_call\"")
3343 || lower.contains("\"function\":{")
3344 || lower.contains("\"type\":\"tool_call\"")
3345 || lower.contains("\"type\":\"function_call\"")
3346 || lower.contains("\"type\":\"tool_use\"")
3347}
3348
3349fn is_policy_rejection_output(output: &str) -> bool {
3350 let lower = output.trim().to_ascii_lowercase();
3351 lower.contains("call skipped")
3352 || lower.contains("authorization required")
3353 || lower.contains("not allowed")
3354 || lower.contains("permission denied")
3355}
3356
3357fn classify_required_tool_failure(
3358 outputs: &[String],
3359 saw_tool_call_candidate: bool,
3360 accepted_tool_calls: usize,
3361 parse_failed: bool,
3362 rejected_by_policy: bool,
3363) -> RequiredToolFailureKind {
3364 if parse_failed {
3365 return RequiredToolFailureKind::ToolCallParseFailed;
3366 }
3367 if !saw_tool_call_candidate {
3368 return RequiredToolFailureKind::NoToolCallEmitted;
3369 }
3370 if accepted_tool_calls == 0 || rejected_by_policy {
3371 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
3372 }
3373 if outputs
3374 .iter()
3375 .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
3376 {
3377 return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
3378 }
3379 if outputs
3380 .iter()
3381 .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
3382 {
3383 return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
3384 }
3385 if outputs
3386 .iter()
3387 .any(|output| is_terminal_tool_error_reason(output))
3388 {
3389 return RequiredToolFailureKind::ToolCallInvalidArgs;
3390 }
3391 if outputs
3392 .iter()
3393 .any(|output| is_policy_rejection_output(output))
3394 {
3395 return RequiredToolFailureKind::ToolCallRejectedByPolicy;
3396 }
3397 RequiredToolFailureKind::ToolCallExecutedNonProductive
3398}
3399
3400fn find_first_url(text: &str) -> Option<String> {
3401 text.split_whitespace().find_map(|token| {
3402 if token.starts_with("https://") || token.starts_with("http://") {
3403 let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
3404 if cleaned.len() > "https://".len() {
3405 return Some(cleaned.to_string());
3406 }
3407 }
3408 None
3409 })
3410}
3411
3412fn max_tool_iterations() -> usize {
3413 let default_iterations = 25usize;
3414 std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
3415 .ok()
3416 .and_then(|raw| raw.trim().parse::<usize>().ok())
3417 .filter(|value| *value > 0)
3418 .unwrap_or(default_iterations)
3419}
3420
3421fn strict_write_retry_max_attempts() -> usize {
3422 std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
3423 .ok()
3424 .and_then(|raw| raw.trim().parse::<usize>().ok())
3425 .filter(|value| *value > 0)
3426 .unwrap_or(3)
3427}
3428
3429fn provider_stream_connect_timeout_ms() -> usize {
3430 std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
3431 .ok()
3432 .and_then(|raw| raw.trim().parse::<usize>().ok())
3433 .filter(|value| *value > 0)
3434 .unwrap_or(90_000)
3435}
3436
3437fn provider_stream_idle_timeout_ms() -> usize {
3438 std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
3439 .ok()
3440 .and_then(|raw| raw.trim().parse::<usize>().ok())
3441 .filter(|value| *value > 0)
3442 .unwrap_or(90_000)
3443}
3444
3445fn prompt_context_hook_timeout_ms() -> usize {
3446 std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
3447 .ok()
3448 .and_then(|raw| raw.trim().parse::<usize>().ok())
3449 .filter(|value| *value > 0)
3450 .unwrap_or(5_000)
3451}
3452
3453fn permission_wait_timeout_ms() -> usize {
3454 std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
3455 .ok()
3456 .and_then(|raw| raw.trim().parse::<usize>().ok())
3457 .filter(|value| *value > 0)
3458 .unwrap_or(15_000)
3459}
3460
3461fn tool_exec_timeout_ms() -> usize {
3462 std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
3463 .ok()
3464 .and_then(|raw| raw.trim().parse::<usize>().ok())
3465 .filter(|value| *value > 0)
3466 .unwrap_or(45_000)
3467}
3468
3469fn is_guard_budget_tool_output(output: &str) -> bool {
3470 output
3471 .to_ascii_lowercase()
3472 .contains("per-run guard budget exceeded")
3473}
3474
3475fn is_duplicate_signature_limit_output(output: &str) -> bool {
3476 output
3477 .to_ascii_lowercase()
3478 .contains("duplicate call signature retry limit reached")
3479}
3480
3481fn is_sensitive_path_candidate(path: &Path) -> bool {
3482 let lowered = path.to_string_lossy().to_ascii_lowercase();
3483 if lowered.contains("/.ssh/")
3484 || lowered.ends_with("/.ssh")
3485 || lowered.contains("/.gnupg/")
3486 || lowered.ends_with("/.gnupg")
3487 {
3488 return true;
3489 }
3490 if lowered.contains("/.aws/credentials")
3491 || lowered.ends_with("/.npmrc")
3492 || lowered.ends_with("/.netrc")
3493 || lowered.ends_with("/.pypirc")
3494 {
3495 return true;
3496 }
3497 if lowered.contains("id_rsa")
3498 || lowered.contains("id_ed25519")
3499 || lowered.contains("id_ecdsa")
3500 || lowered.contains(".pem")
3501 || lowered.contains(".p12")
3502 || lowered.contains(".pfx")
3503 || lowered.contains(".key")
3504 {
3505 return true;
3506 }
3507 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
3508 let n = name.to_ascii_lowercase();
3509 if n == ".env" || n.starts_with(".env.") {
3510 return true;
3511 }
3512 }
3513 false
3514}
3515
3516fn shell_command_targets_sensitive_path(command: &str) -> bool {
3517 let lower = command.to_ascii_lowercase();
3518 let patterns = [
3519 ".env",
3520 ".ssh",
3521 ".gnupg",
3522 ".aws/credentials",
3523 "id_rsa",
3524 "id_ed25519",
3525 ".pem",
3526 ".p12",
3527 ".pfx",
3528 ".key",
3529 ];
3530 patterns.iter().any(|p| lower.contains(p))
3531}
3532
3533#[derive(Debug, Clone)]
3534struct NormalizedToolArgs {
3535 args: Value,
3536 args_source: String,
3537 args_integrity: String,
3538 raw_args_state: RawToolArgsState,
3539 query: Option<String>,
3540 missing_terminal: bool,
3541 missing_terminal_reason: Option<String>,
3542}
3543
3544#[cfg(test)]
3545fn normalize_tool_args(
3546 tool_name: &str,
3547 raw_args: Value,
3548 latest_user_text: &str,
3549 latest_assistant_context: &str,
3550) -> NormalizedToolArgs {
3551 normalize_tool_args_with_mode(
3552 tool_name,
3553 raw_args,
3554 latest_user_text,
3555 latest_assistant_context,
3556 WritePathRecoveryMode::Heuristic,
3557 )
3558}
3559
3560fn normalize_tool_args_with_mode(
3561 tool_name: &str,
3562 raw_args: Value,
3563 latest_user_text: &str,
3564 latest_assistant_context: &str,
3565 write_path_recovery_mode: WritePathRecoveryMode,
3566) -> NormalizedToolArgs {
3567 let normalized_tool = normalize_tool_name(tool_name);
3568 let original_args = raw_args.clone();
3569 let mut args = raw_args;
3570 let mut args_source = if args.is_string() {
3571 "provider_string".to_string()
3572 } else {
3573 "provider_json".to_string()
3574 };
3575 let mut args_integrity = "ok".to_string();
3576 let raw_args_state = classify_raw_tool_args_state(&args);
3577 let mut query = None;
3578 let mut missing_terminal = false;
3579 let mut missing_terminal_reason = None;
3580
3581 if normalized_tool == "websearch" {
3582 if let Some(found) = extract_websearch_query(&args) {
3583 query = Some(found);
3584 args = set_websearch_query_and_source(args, query.clone(), "tool_args");
3585 } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
3586 args_source = "inferred_from_user".to_string();
3587 args_integrity = "recovered".to_string();
3588 query = Some(inferred);
3589 args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
3590 } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
3591 args_source = "recovered_from_context".to_string();
3592 args_integrity = "recovered".to_string();
3593 query = Some(recovered);
3594 args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
3595 } else {
3596 args_source = "missing".to_string();
3597 args_integrity = "empty".to_string();
3598 missing_terminal = true;
3599 missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
3600 }
3601 } else if is_shell_tool_name(&normalized_tool) {
3602 if let Some(command) = extract_shell_command(&args) {
3603 args = set_shell_command(args, command);
3604 } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
3605 args_source = "inferred_from_context".to_string();
3606 args_integrity = "recovered".to_string();
3607 args = set_shell_command(args, inferred);
3608 } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
3609 args_source = "inferred_from_user".to_string();
3610 args_integrity = "recovered".to_string();
3611 args = set_shell_command(args, inferred);
3612 } else {
3613 args_source = "missing".to_string();
3614 args_integrity = "empty".to_string();
3615 missing_terminal = true;
3616 missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
3617 }
3618 } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
3619 if let Some(path) = extract_file_path_arg(&args) {
3620 args = set_file_path_arg(args, path);
3621 } else if normalized_tool == "write" || normalized_tool == "edit" {
3622 if let Some(inferred) = infer_required_output_target_path_from_text(latest_user_text)
3623 .or_else(|| infer_required_output_target_path_from_text(latest_assistant_context))
3624 {
3625 args_source = "recovered_from_context".to_string();
3626 args_integrity = "recovered".to_string();
3627 args = set_file_path_arg(args, inferred);
3628 } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
3629 if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
3630 args_source = "inferred_from_user".to_string();
3631 args_integrity = "recovered".to_string();
3632 args = set_file_path_arg(args, inferred);
3633 } else {
3634 args_source = "missing".to_string();
3635 args_integrity = "empty".to_string();
3636 missing_terminal = true;
3637 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
3638 }
3639 } else {
3640 args_source = "missing".to_string();
3641 args_integrity = "empty".to_string();
3642 missing_terminal = true;
3643 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
3644 }
3645 } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
3646 args_source = "inferred_from_user".to_string();
3647 args_integrity = "recovered".to_string();
3648 args = set_file_path_arg(args, inferred);
3649 } else {
3650 args_source = "missing".to_string();
3651 args_integrity = "empty".to_string();
3652 missing_terminal = true;
3653 missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
3654 }
3655
3656 if !missing_terminal && normalized_tool == "write" {
3657 if let Some(content) = extract_write_content_arg(&args) {
3658 args = set_write_content_arg(args, content);
3659 } else if let Some(recovered) =
3660 infer_write_content_from_assistant_context(latest_assistant_context)
3661 {
3662 args_source = "recovered_from_context".to_string();
3663 args_integrity = "recovered".to_string();
3664 args = set_write_content_arg(args, recovered);
3665 } else {
3666 args_source = "missing".to_string();
3667 args_integrity = "empty".to_string();
3668 missing_terminal = true;
3669 missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
3670 }
3671 }
3672 } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
3673 if let Some(url) = extract_webfetch_url_arg(&args) {
3674 args = set_webfetch_url_arg(args, url);
3675 } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
3676 args_source = "inferred_from_context".to_string();
3677 args_integrity = "recovered".to_string();
3678 args = set_webfetch_url_arg(args, inferred);
3679 } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
3680 args_source = "inferred_from_user".to_string();
3681 args_integrity = "recovered".to_string();
3682 args = set_webfetch_url_arg(args, inferred);
3683 } else {
3684 args_source = "missing".to_string();
3685 args_integrity = "empty".to_string();
3686 missing_terminal = true;
3687 missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
3688 }
3689 } else if normalized_tool == "pack_builder" {
3690 let mode = extract_pack_builder_mode_arg(&args);
3691 let plan_id = extract_pack_builder_plan_id_arg(&args);
3692 if mode.as_deref() == Some("apply") && plan_id.is_none() {
3693 if let Some(inferred_plan) =
3694 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
3695 {
3696 args_source = "recovered_from_context".to_string();
3697 args_integrity = "recovered".to_string();
3698 args = set_pack_builder_apply_args(args, inferred_plan);
3699 } else {
3700 args_source = "missing".to_string();
3701 args_integrity = "empty".to_string();
3702 missing_terminal = true;
3703 missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
3704 }
3705 } else if mode.as_deref() == Some("apply") {
3706 args = ensure_pack_builder_default_mode(args);
3707 } else if let Some(inferred_plan) =
3708 infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
3709 {
3710 args_source = "recovered_from_context".to_string();
3711 args_integrity = "recovered".to_string();
3712 args = set_pack_builder_apply_args(args, inferred_plan);
3713 } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
3714 args = set_pack_builder_goal_arg(args, goal);
3715 } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
3716 args_source = "inferred_from_user".to_string();
3717 args_integrity = "recovered".to_string();
3718 args = set_pack_builder_goal_arg(args, inferred);
3719 } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
3720 {
3721 args_source = "recovered_from_context".to_string();
3722 args_integrity = "recovered".to_string();
3723 args = set_pack_builder_goal_arg(args, recovered);
3724 } else {
3725 args_source = "missing".to_string();
3726 args_integrity = "empty".to_string();
3727 missing_terminal = true;
3728 missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
3729 }
3730 args = ensure_pack_builder_default_mode(args);
3731 } else if is_email_delivery_tool_name(&normalized_tool) {
3732 let sanitized = sanitize_email_attachment_args(args);
3733 if sanitized != original_args {
3734 args_source = "sanitized_attachment".to_string();
3735 args_integrity = "recovered".to_string();
3736 }
3737 args = sanitized;
3738 }
3739
3740 NormalizedToolArgs {
3741 args,
3742 args_source,
3743 args_integrity,
3744 raw_args_state,
3745 query,
3746 missing_terminal,
3747 missing_terminal_reason,
3748 }
3749}
3750
3751fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
3752 match raw_args {
3753 Value::Null => RawToolArgsState::Empty,
3754 Value::Object(obj) => {
3755 if obj.is_empty() {
3756 RawToolArgsState::Empty
3757 } else {
3758 RawToolArgsState::Present
3759 }
3760 }
3761 Value::Array(items) => {
3762 if items.is_empty() {
3763 RawToolArgsState::Empty
3764 } else {
3765 RawToolArgsState::Present
3766 }
3767 }
3768 Value::String(raw) => {
3769 let trimmed = raw.trim();
3770 if trimmed.is_empty() {
3771 return RawToolArgsState::Empty;
3772 }
3773 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3774 return classify_raw_tool_args_state(&parsed);
3775 }
3776 if parse_function_style_args(trimmed).is_empty() {
3777 return RawToolArgsState::Unparseable;
3778 }
3779 RawToolArgsState::Present
3780 }
3781 _ => RawToolArgsState::Present,
3782 }
3783}
3784
3785fn provider_specific_write_reason(
3786 tool: &str,
3787 missing_reason: &str,
3788 raw_args_state: RawToolArgsState,
3789) -> Option<String> {
3790 if tool != "write"
3791 || !matches!(
3792 missing_reason,
3793 "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
3794 )
3795 {
3796 return None;
3797 }
3798 match raw_args_state {
3799 RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
3800 RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
3801 RawToolArgsState::Present => None,
3802 }
3803}
3804
3805fn is_shell_tool_name(tool_name: &str) -> bool {
3806 matches!(
3807 tool_name.trim().to_ascii_lowercase().as_str(),
3808 "bash" | "shell" | "powershell" | "cmd"
3809 )
3810}
3811
3812fn is_email_delivery_tool_name(tool_name: &str) -> bool {
3813 matches!(
3814 normalize_tool_name(tool_name).as_str(),
3815 "mcp.composio_1.gmail_send_email"
3816 | "mcp.composio_1.gmail_send_draft"
3817 | "mcp.composio.gmail_send_email"
3818 | "mcp.composio.gmail_send_draft"
3819 ) || tool_name.ends_with(".gmail_send_email")
3820 || tool_name.ends_with(".gmail_send_draft")
3821}
3822
3823fn sanitize_email_attachment_args(args: Value) -> Value {
3824 let mut obj = match args {
3825 Value::Object(map) => map,
3826 other => return other,
3827 };
3828 if let Some(Value::Object(attachment)) = obj.get("attachment") {
3829 let s3key = attachment
3830 .get("s3key")
3831 .and_then(Value::as_str)
3832 .map(str::trim)
3833 .unwrap_or("");
3834 if s3key.is_empty() {
3835 obj.remove("attachment");
3836 }
3837 } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
3838 obj.remove("attachment");
3839 }
3840 if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
3841 attachments.retain(|entry| {
3842 entry
3843 .get("s3key")
3844 .and_then(Value::as_str)
3845 .map(str::trim)
3846 .map(|value| !value.is_empty())
3847 .unwrap_or(false)
3848 });
3849 if attachments.is_empty() {
3850 obj.remove("attachments");
3851 }
3852 }
3853 Value::Object(obj)
3854}
3855
3856fn set_file_path_arg(args: Value, path: String) -> Value {
3857 let mut obj = args.as_object().cloned().unwrap_or_default();
3858 obj.insert("path".to_string(), Value::String(path));
3859 Value::Object(obj)
3860}
3861
3862fn set_write_content_arg(args: Value, content: String) -> Value {
3863 let mut obj = args.as_object().cloned().unwrap_or_default();
3864 obj.insert("content".to_string(), Value::String(content));
3865 Value::Object(obj)
3866}
3867
3868fn extract_file_path_arg(args: &Value) -> Option<String> {
3869 extract_file_path_arg_internal(args, 0)
3870}
3871
3872fn extract_write_content_arg(args: &Value) -> Option<String> {
3873 extract_write_content_arg_internal(args, 0)
3874}
3875
3876fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
3877 if depth > 5 {
3878 return None;
3879 }
3880
3881 match args {
3882 Value::String(raw) => {
3883 let trimmed = raw.trim();
3884 if trimmed.is_empty() {
3885 return None;
3886 }
3887 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
3889 return sanitize_path_candidate(trimmed);
3890 }
3891 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3892 return extract_file_path_arg_internal(&parsed, depth + 1);
3893 }
3894 sanitize_path_candidate(trimmed)
3895 }
3896 Value::Array(items) => items
3897 .iter()
3898 .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
3899 Value::Object(obj) => {
3900 for key in FILE_PATH_KEYS {
3901 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
3902 if let Some(path) = sanitize_path_candidate(raw) {
3903 return Some(path);
3904 }
3905 }
3906 }
3907 for container in NESTED_ARGS_KEYS {
3908 if let Some(nested) = obj.get(container) {
3909 if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
3910 return Some(path);
3911 }
3912 }
3913 }
3914 None
3915 }
3916 _ => None,
3917 }
3918}
3919
3920fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
3921 if depth > 5 {
3922 return None;
3923 }
3924
3925 match args {
3926 Value::String(raw) => {
3927 let trimmed = raw.trim();
3928 if trimmed.is_empty() {
3929 return None;
3930 }
3931 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3932 return extract_write_content_arg_internal(&parsed, depth + 1);
3933 }
3934 if sanitize_path_candidate(trimmed).is_some()
3937 && !trimmed.contains('\n')
3938 && trimmed.split_whitespace().count() <= 3
3939 {
3940 return None;
3941 }
3942 Some(trimmed.to_string())
3943 }
3944 Value::Array(items) => items
3945 .iter()
3946 .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
3947 Value::Object(obj) => {
3948 for key in WRITE_CONTENT_KEYS {
3949 if let Some(value) = obj.get(key) {
3950 if let Some(raw) = value.as_str() {
3951 if !raw.is_empty() {
3952 return Some(raw.to_string());
3953 }
3954 } else if let Some(recovered) =
3955 extract_write_content_arg_internal(value, depth + 1)
3956 {
3957 return Some(recovered);
3958 }
3959 }
3960 }
3961 for container in NESTED_ARGS_KEYS {
3962 if let Some(nested) = obj.get(container) {
3963 if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
3964 return Some(content);
3965 }
3966 }
3967 }
3968 None
3969 }
3970 _ => None,
3971 }
3972}
3973
3974fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
3975 let text = latest_assistant_context.trim();
3976 if text.len() < 32 {
3977 return None;
3978 }
3979 Some(text.to_string())
3980}
3981
3982fn set_shell_command(args: Value, command: String) -> Value {
3983 let mut obj = args.as_object().cloned().unwrap_or_default();
3984 obj.insert("command".to_string(), Value::String(command));
3985 Value::Object(obj)
3986}
3987
3988fn extract_shell_command(args: &Value) -> Option<String> {
3989 extract_shell_command_internal(args, 0)
3990}
3991
3992fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
3993 if depth > 5 {
3994 return None;
3995 }
3996
3997 match args {
3998 Value::String(raw) => {
3999 let trimmed = raw.trim();
4000 if trimmed.is_empty() {
4001 return None;
4002 }
4003 if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
4004 return sanitize_shell_command_candidate(trimmed);
4005 }
4006 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
4007 return extract_shell_command_internal(&parsed, depth + 1);
4008 }
4009 sanitize_shell_command_candidate(trimmed)
4010 }
4011 Value::Array(items) => items
4012 .iter()
4013 .find_map(|item| extract_shell_command_internal(item, depth + 1)),
4014 Value::Object(obj) => {
4015 for key in SHELL_COMMAND_KEYS {
4016 if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
4017 if let Some(command) = sanitize_shell_command_candidate(raw) {
4018 return Some(command);
4019 }
4020 }
4021 }
4022 for container in NESTED_ARGS_KEYS {
4023 if let Some(nested) = obj.get(container) {
4024 if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
4025 return Some(command);
4026 }
4027 }
4028 }
4029 None
4030 }
4031 _ => None,
4032 }
4033}
4034
4035fn infer_shell_command_from_text(text: &str) -> Option<String> {
4036 let trimmed = text.trim();
4037 if trimmed.is_empty() {
4038 return None;
4039 }
4040
4041 let mut in_tick = false;
4043 let mut tick_buf = String::new();
4044 for ch in trimmed.chars() {
4045 if ch == '`' {
4046 if in_tick {
4047 if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
4048 if looks_like_shell_command(&candidate) {
4049 return Some(candidate);
4050 }
4051 }
4052 tick_buf.clear();
4053 }
4054 in_tick = !in_tick;
4055 continue;
4056 }
4057 if in_tick {
4058 tick_buf.push(ch);
4059 }
4060 }
4061
4062 for line in trimmed.lines() {
4063 let line = line.trim();
4064 if line.is_empty() {
4065 continue;
4066 }
4067 let lower = line.to_ascii_lowercase();
4068 for prefix in [
4069 "run ",
4070 "execute ",
4071 "call ",
4072 "use bash ",
4073 "use shell ",
4074 "bash ",
4075 "shell ",
4076 "powershell ",
4077 "pwsh ",
4078 ] {
4079 if lower.starts_with(prefix) {
4080 let candidate = line[prefix.len()..].trim();
4081 if let Some(command) = sanitize_shell_command_candidate(candidate) {
4082 if looks_like_shell_command(&command) {
4083 return Some(command);
4084 }
4085 }
4086 }
4087 }
4088 }
4089
4090 None
4091}
4092
4093fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
4094 let mut obj = args.as_object().cloned().unwrap_or_default();
4095 if let Some(q) = query {
4096 obj.insert("query".to_string(), Value::String(q));
4097 }
4098 obj.insert(
4099 "__query_source".to_string(),
4100 Value::String(query_source.to_string()),
4101 );
4102 Value::Object(obj)
4103}
4104
4105fn set_webfetch_url_arg(args: Value, url: String) -> Value {
4106 let mut obj = args.as_object().cloned().unwrap_or_default();
4107 obj.insert("url".to_string(), Value::String(url));
4108 Value::Object(obj)
4109}
4110
4111fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
4112 let mut obj = args.as_object().cloned().unwrap_or_default();
4113 obj.insert("goal".to_string(), Value::String(goal));
4114 Value::Object(obj)
4115}
4116
4117fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
4118 let mut obj = args.as_object().cloned().unwrap_or_default();
4119 obj.insert("mode".to_string(), Value::String("apply".to_string()));
4120 obj.insert("plan_id".to_string(), Value::String(plan_id));
4121 obj.insert(
4122 "approve_connector_registration".to_string(),
4123 Value::Bool(true),
4124 );
4125 obj.insert("approve_pack_install".to_string(), Value::Bool(true));
4126 obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
4127 Value::Object(obj)
4128}
4129
4130fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
4131 for key in ["mode"] {
4132 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
4133 let mode = value.trim().to_ascii_lowercase();
4134 if !mode.is_empty() {
4135 return Some(mode);
4136 }
4137 }
4138 }
4139 for container in ["arguments", "args", "input", "params"] {
4140 if let Some(obj) = args.get(container) {
4141 if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
4142 let mode = value.trim().to_ascii_lowercase();
4143 if !mode.is_empty() {
4144 return Some(mode);
4145 }
4146 }
4147 }
4148 }
4149 None
4150}
4151
4152fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
4153 for key in ["plan_id", "planId"] {
4154 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
4155 let plan_id = value.trim();
4156 if !plan_id.is_empty() {
4157 return Some(plan_id.to_string());
4158 }
4159 }
4160 }
4161 for container in ["arguments", "args", "input", "params"] {
4162 if let Some(obj) = args.get(container) {
4163 for key in ["plan_id", "planId"] {
4164 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
4165 let plan_id = value.trim();
4166 if !plan_id.is_empty() {
4167 return Some(plan_id.to_string());
4168 }
4169 }
4170 }
4171 }
4172 }
4173 None
4174}
4175
4176fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
4177 if text.trim().is_empty() {
4178 return None;
4179 }
4180 let bytes = text.as_bytes();
4181 let mut idx = 0usize;
4182 while idx + 5 <= bytes.len() {
4183 if &bytes[idx..idx + 5] != b"plan-" {
4184 idx += 1;
4185 continue;
4186 }
4187 let mut end = idx + 5;
4188 while end < bytes.len() {
4189 let ch = bytes[end] as char;
4190 if ch.is_ascii_alphanumeric() || ch == '-' {
4191 end += 1;
4192 } else {
4193 break;
4194 }
4195 }
4196 if end > idx + 5 {
4197 let candidate = &text[idx..end];
4198 if candidate.len() >= 10 {
4199 return Some(candidate.to_string());
4200 }
4201 }
4202 idx = end.saturating_add(1);
4203 }
4204 None
4205}
4206
4207fn is_pack_builder_confirmation_text(text: &str) -> bool {
4208 let trimmed = text.trim();
4209 if trimmed.is_empty() {
4210 return false;
4211 }
4212 let lower = trimmed.to_ascii_lowercase();
4213 matches!(
4214 lower.as_str(),
4215 "confirm"
4216 | "confirmed"
4217 | "approve"
4218 | "approved"
4219 | "yes"
4220 | "y"
4221 | "ok"
4222 | "okay"
4223 | "go"
4224 | "go ahead"
4225 | "ship it"
4226 | "do it"
4227 | "apply"
4228 | "run it"
4229 | "✅"
4230 | "👍"
4231 )
4232}
4233
4234fn infer_pack_builder_apply_plan_id(
4235 latest_user_text: &str,
4236 latest_assistant_context: &str,
4237) -> Option<String> {
4238 if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
4239 return Some(plan_id);
4240 }
4241 if !is_pack_builder_confirmation_text(latest_user_text) {
4242 return None;
4243 }
4244 extract_pack_builder_plan_id_from_text(latest_assistant_context)
4245}
4246
4247fn ensure_pack_builder_default_mode(args: Value) -> Value {
4248 let mut obj = args.as_object().cloned().unwrap_or_default();
4249 let has_mode = obj
4250 .get("mode")
4251 .and_then(Value::as_str)
4252 .map(str::trim)
4253 .is_some_and(|v| !v.is_empty());
4254 if !has_mode {
4255 obj.insert("mode".to_string(), Value::String("preview".to_string()));
4256 }
4257 Value::Object(obj)
4258}
4259
4260fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
4261 const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
4262 for key in URL_KEYS {
4263 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
4264 if let Some(url) = sanitize_url_candidate(value) {
4265 return Some(url);
4266 }
4267 }
4268 }
4269 for container in ["arguments", "args", "input", "params"] {
4270 if let Some(obj) = args.get(container) {
4271 for key in URL_KEYS {
4272 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
4273 if let Some(url) = sanitize_url_candidate(value) {
4274 return Some(url);
4275 }
4276 }
4277 }
4278 }
4279 }
4280 args.as_str().and_then(sanitize_url_candidate)
4281}
4282
4283fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
4284 const GOAL_KEYS: [&str; 1] = ["goal"];
4285 for key in GOAL_KEYS {
4286 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
4287 let trimmed = value.trim();
4288 if !trimmed.is_empty() {
4289 return Some(trimmed.to_string());
4290 }
4291 }
4292 }
4293 for container in ["arguments", "args", "input", "params"] {
4294 if let Some(obj) = args.get(container) {
4295 for key in GOAL_KEYS {
4296 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
4297 let trimmed = value.trim();
4298 if !trimmed.is_empty() {
4299 return Some(trimmed.to_string());
4300 }
4301 }
4302 }
4303 }
4304 }
4305 args.as_str()
4306 .map(str::trim)
4307 .filter(|v| !v.is_empty())
4308 .map(ToString::to_string)
4309}
4310
4311fn extract_websearch_query(args: &Value) -> Option<String> {
4312 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
4313 for key in QUERY_KEYS {
4314 if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
4315 if let Some(query) = sanitize_websearch_query_candidate(value) {
4316 return Some(query);
4317 }
4318 }
4319 }
4320 for container in ["arguments", "args", "input", "params"] {
4321 if let Some(obj) = args.get(container) {
4322 for key in QUERY_KEYS {
4323 if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
4324 if let Some(query) = sanitize_websearch_query_candidate(value) {
4325 return Some(query);
4326 }
4327 }
4328 }
4329 }
4330 }
4331 args.as_str().and_then(sanitize_websearch_query_candidate)
4332}
4333
4334fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
4335 let trimmed = raw.trim();
4336 if trimmed.is_empty() {
4337 return None;
4338 }
4339
4340 let lower = trimmed.to_ascii_lowercase();
4341 if let Some(start) = lower.find("<arg_value>") {
4342 let value_start = start + "<arg_value>".len();
4343 let tail = &trimmed[value_start..];
4344 let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
4345 &tail[..end]
4346 } else {
4347 tail
4348 };
4349 let cleaned = value.trim();
4350 if !cleaned.is_empty() {
4351 return Some(cleaned.to_string());
4352 }
4353 }
4354
4355 let without_wrappers = trimmed
4356 .replace("<arg_key>", " ")
4357 .replace("</arg_key>", " ")
4358 .replace("<arg_value>", " ")
4359 .replace("</arg_value>", " ");
4360 let collapsed = without_wrappers
4361 .split_whitespace()
4362 .collect::<Vec<_>>()
4363 .join(" ");
4364 if collapsed.is_empty() {
4365 return None;
4366 }
4367
4368 let collapsed_lower = collapsed.to_ascii_lowercase();
4369 if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
4370 let offset = collapsed.len() - rest.len();
4371 let q = collapsed[offset..].trim();
4372 if !q.is_empty() {
4373 return Some(q.to_string());
4374 }
4375 }
4376 if let Some(rest) = collapsed_lower.strip_prefix("query ") {
4377 let offset = collapsed.len() - rest.len();
4378 let q = collapsed[offset..].trim();
4379 if !q.is_empty() {
4380 return Some(q.to_string());
4381 }
4382 }
4383
4384 Some(collapsed)
4385}
4386
4387fn infer_websearch_query_from_text(text: &str) -> Option<String> {
4388 let trimmed = text.trim();
4389 if trimmed.is_empty() {
4390 return None;
4391 }
4392
4393 let lower = trimmed.to_lowercase();
4394 const PREFIXES: [&str; 11] = [
4395 "web search",
4396 "websearch",
4397 "search web for",
4398 "search web",
4399 "search for",
4400 "search",
4401 "look up",
4402 "lookup",
4403 "find",
4404 "web lookup",
4405 "query",
4406 ];
4407
4408 let mut candidate = trimmed;
4409 for prefix in PREFIXES {
4410 if lower.starts_with(prefix) && lower.len() >= prefix.len() {
4411 let remainder = trimmed[prefix.len()..]
4412 .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
4413 candidate = remainder;
4414 break;
4415 }
4416 }
4417
4418 let normalized = candidate
4419 .trim()
4420 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
4421 .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
4422 .trim()
4423 .to_string();
4424
4425 if normalized.split_whitespace().count() < 2 {
4426 return None;
4427 }
4428 Some(normalized)
4429}
4430
4431fn infer_file_path_from_text(text: &str) -> Option<String> {
4432 let trimmed = text.trim();
4433 if trimmed.is_empty() {
4434 return None;
4435 }
4436
4437 let mut candidates: Vec<String> = Vec::new();
4438
4439 let mut in_tick = false;
4441 let mut tick_buf = String::new();
4442 for ch in trimmed.chars() {
4443 if ch == '`' {
4444 if in_tick {
4445 let cand = sanitize_path_candidate(&tick_buf);
4446 if let Some(path) = cand {
4447 candidates.push(path);
4448 }
4449 tick_buf.clear();
4450 }
4451 in_tick = !in_tick;
4452 continue;
4453 }
4454 if in_tick {
4455 tick_buf.push(ch);
4456 }
4457 }
4458
4459 for raw in trimmed.split_whitespace() {
4461 if let Some(path) = sanitize_path_candidate(raw) {
4462 candidates.push(path);
4463 }
4464 }
4465
4466 let mut deduped = Vec::new();
4467 let mut seen = HashSet::new();
4468 for candidate in candidates {
4469 if seen.insert(candidate.clone()) {
4470 deduped.push(candidate);
4471 }
4472 }
4473
4474 deduped.into_iter().next()
4475}
4476
4477fn infer_workspace_root_from_text(text: &str) -> Option<String> {
4478 text.lines().find_map(|line| {
4479 let trimmed = line.trim();
4480 let value = trimmed.strip_prefix("Workspace:")?.trim();
4481 sanitize_path_candidate(value)
4482 })
4483}
4484
4485fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
4486 let marker = "Required output target:";
4487 let idx = text.find(marker)?;
4488 let tail = text[idx + marker.len()..].trim_start();
4489 if let Some(start) = tail.find('{') {
4490 let json_candidate = tail[start..]
4491 .lines()
4492 .take_while(|line| {
4493 let trimmed = line.trim();
4494 !(trimmed.is_empty() && !trimmed.starts_with('{'))
4495 })
4496 .collect::<Vec<_>>()
4497 .join("\n");
4498 if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
4499 if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
4500 if let Some(clean) = sanitize_explicit_output_target_path(path) {
4501 return Some(clean);
4502 }
4503 }
4504 }
4505 }
4506 None
4507}
4508
4509fn infer_write_file_path_from_text(text: &str) -> Option<String> {
4510 let inferred = infer_file_path_from_text(text)?;
4511 let workspace_root = infer_workspace_root_from_text(text);
4512 if workspace_root
4513 .as_deref()
4514 .is_some_and(|root| root == inferred)
4515 {
4516 return None;
4517 }
4518 Some(inferred)
4519}
4520
4521fn infer_url_from_text(text: &str) -> Option<String> {
4522 let trimmed = text.trim();
4523 if trimmed.is_empty() {
4524 return None;
4525 }
4526
4527 let mut candidates: Vec<String> = Vec::new();
4528
4529 let mut in_tick = false;
4531 let mut tick_buf = String::new();
4532 for ch in trimmed.chars() {
4533 if ch == '`' {
4534 if in_tick {
4535 if let Some(url) = sanitize_url_candidate(&tick_buf) {
4536 candidates.push(url);
4537 }
4538 tick_buf.clear();
4539 }
4540 in_tick = !in_tick;
4541 continue;
4542 }
4543 if in_tick {
4544 tick_buf.push(ch);
4545 }
4546 }
4547
4548 for raw in trimmed.split_whitespace() {
4550 if let Some(url) = sanitize_url_candidate(raw) {
4551 candidates.push(url);
4552 }
4553 }
4554
4555 let mut seen = HashSet::new();
4556 candidates
4557 .into_iter()
4558 .find(|candidate| seen.insert(candidate.clone()))
4559}
4560
4561fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
4562 let trimmed = text.trim();
4563 if trimmed.is_empty() {
4564 None
4565 } else {
4566 Some(trimmed.to_string())
4567 }
4568}
4569
4570fn sanitize_url_candidate(raw: &str) -> Option<String> {
4571 let token = raw
4572 .trim()
4573 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
4574 .trim_start_matches(['(', '[', '{', '<'])
4575 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
4576 .trim_end_matches('.')
4577 .trim();
4578
4579 if token.is_empty() {
4580 return None;
4581 }
4582 let lower = token.to_ascii_lowercase();
4583 if !(lower.starts_with("http://") || lower.starts_with("https://")) {
4584 return None;
4585 }
4586 Some(token.to_string())
4587}
4588
4589fn clean_path_candidate_token(raw: &str) -> Option<String> {
4590 let token = raw
4591 .trim()
4592 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
4593 .trim_start_matches(['(', '[', '{', '<'])
4594 .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
4595 .trim_end_matches('.')
4596 .trim();
4597
4598 if token.is_empty() {
4599 return None;
4600 }
4601 Some(token.to_string())
4602}
4603
4604fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
4605 let token = clean_path_candidate_token(raw)?;
4606 let lower = token.to_ascii_lowercase();
4607 if lower.starts_with("http://") || lower.starts_with("https://") {
4608 return None;
4609 }
4610 if is_malformed_tool_path_token(&token) {
4611 return None;
4612 }
4613 if is_root_only_path_token(&token) {
4614 return None;
4615 }
4616 if is_placeholder_path_token(&token) {
4617 return None;
4618 }
4619 if token.ends_with('/') || token.ends_with('\\') {
4620 return None;
4621 }
4622 Some(token.to_string())
4623}
4624
4625fn sanitize_path_candidate(raw: &str) -> Option<String> {
4626 let token = clean_path_candidate_token(raw)?;
4627 let lower = token.to_ascii_lowercase();
4628 if lower.starts_with("http://") || lower.starts_with("https://") {
4629 return None;
4630 }
4631 if is_malformed_tool_path_token(token.as_str()) {
4632 return None;
4633 }
4634 if is_root_only_path_token(token.as_str()) {
4635 return None;
4636 }
4637 if is_placeholder_path_token(token.as_str()) {
4638 return None;
4639 }
4640
4641 let looks_like_path = token.contains('/') || token.contains('\\');
4642 let has_file_ext = [
4643 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
4644 ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
4645 ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
4646 ]
4647 .iter()
4648 .any(|ext| lower.ends_with(ext));
4649
4650 if !looks_like_path && !has_file_ext {
4651 return None;
4652 }
4653
4654 Some(token)
4655}
4656
4657fn is_placeholder_path_token(token: &str) -> bool {
4658 let lowered = token.trim().to_ascii_lowercase();
4659 if lowered.is_empty() {
4660 return true;
4661 }
4662 matches!(
4663 lowered.as_str(),
4664 "files/directories"
4665 | "file/directory"
4666 | "relative/or/absolute/path"
4667 | "path/to/file"
4668 | "path/to/your/file"
4669 | "tool/policy"
4670 | "tools/policy"
4671 | "the expected artifact file"
4672 | "workspace/file"
4673 )
4674}
4675
4676fn is_malformed_tool_path_token(token: &str) -> bool {
4677 let lower = token.to_ascii_lowercase();
4678 if lower.contains("<tool_call")
4680 || lower.contains("</tool_call")
4681 || lower.contains("<function=")
4682 || lower.contains("<parameter=")
4683 || lower.contains("</function>")
4684 || lower.contains("</parameter>")
4685 {
4686 return true;
4687 }
4688 if token.contains('\n') || token.contains('\r') {
4690 return true;
4691 }
4692 if token.contains('*') || token.contains('?') {
4694 return true;
4695 }
4696 false
4697}
4698
4699fn is_root_only_path_token(token: &str) -> bool {
4700 let trimmed = token.trim();
4701 if trimmed.is_empty() {
4702 return true;
4703 }
4704 if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
4705 return true;
4706 }
4707 let bytes = trimmed.as_bytes();
4709 if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
4710 return true;
4711 }
4712 if bytes.len() == 3
4713 && bytes[1] == b':'
4714 && (bytes[0] as char).is_ascii_alphabetic()
4715 && (bytes[2] == b'\\' || bytes[2] == b'/')
4716 {
4717 return true;
4718 }
4719 false
4720}
4721
4722fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
4723 let token = raw
4724 .trim()
4725 .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
4726 .trim();
4727 if token.is_empty() {
4728 return None;
4729 }
4730 Some(token.to_string())
4731}
4732
4733fn looks_like_shell_command(candidate: &str) -> bool {
4734 let lower = candidate.to_ascii_lowercase();
4735 if lower.is_empty() {
4736 return false;
4737 }
4738 let first = lower.split_whitespace().next().unwrap_or_default();
4739 let common = [
4740 "rg",
4741 "git",
4742 "cargo",
4743 "pnpm",
4744 "npm",
4745 "node",
4746 "python",
4747 "pytest",
4748 "pwsh",
4749 "powershell",
4750 "cmd",
4751 "dir",
4752 "ls",
4753 "cat",
4754 "type",
4755 "echo",
4756 "cd",
4757 "mkdir",
4758 "cp",
4759 "copy",
4760 "move",
4761 "del",
4762 "rm",
4763 ];
4764 common.contains(&first)
4765 || first.starts_with("get-")
4766 || first.starts_with("./")
4767 || first.starts_with(".\\")
4768 || lower.contains(" | ")
4769 || lower.contains(" && ")
4770 || lower.contains(" ; ")
4771}
4772
4773const FILE_PATH_KEYS: [&str; 10] = [
4774 "path",
4775 "file_path",
4776 "filePath",
4777 "filepath",
4778 "filename",
4779 "file",
4780 "target",
4781 "targetFile",
4782 "absolutePath",
4783 "uri",
4784];
4785
4786const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
4787
4788const WRITE_CONTENT_KEYS: [&str; 8] = [
4789 "content",
4790 "text",
4791 "body",
4792 "value",
4793 "markdown",
4794 "document",
4795 "output",
4796 "file_content",
4797];
4798
4799const NESTED_ARGS_KEYS: [&str; 10] = [
4800 "arguments",
4801 "args",
4802 "input",
4803 "params",
4804 "payload",
4805 "data",
4806 "tool_input",
4807 "toolInput",
4808 "tool_args",
4809 "toolArgs",
4810];
4811
4812fn tool_signature(tool_name: &str, args: &Value) -> String {
4813 let normalized = normalize_tool_name(tool_name);
4814 if normalized == "websearch" {
4815 let query = extract_websearch_query(args)
4816 .unwrap_or_default()
4817 .to_lowercase();
4818 let limit = args
4819 .get("limit")
4820 .or_else(|| args.get("numResults"))
4821 .or_else(|| args.get("num_results"))
4822 .and_then(|v| v.as_u64())
4823 .unwrap_or(8);
4824 let domains = args
4825 .get("domains")
4826 .or_else(|| args.get("domain"))
4827 .map(|v| v.to_string())
4828 .unwrap_or_default();
4829 let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
4830 return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
4831 }
4832 format!("{}:{}", normalized, args)
4833}
4834
4835fn stable_hash(input: &str) -> String {
4836 let mut hasher = DefaultHasher::new();
4837 input.hash(&mut hasher);
4838 format!("{:016x}", hasher.finish())
4839}
4840
4841fn summarize_tool_outputs(outputs: &[String]) -> String {
4842 outputs
4843 .iter()
4844 .take(6)
4845 .map(|output| truncate_text(output, 600))
4846 .collect::<Vec<_>>()
4847 .join("\n\n")
4848}
4849
4850fn is_os_mismatch_tool_output(output: &str) -> bool {
4851 let lower = output.to_ascii_lowercase();
4852 lower.contains("os error 3")
4853 || lower.contains("system cannot find the path specified")
4854 || lower.contains("command not found")
4855 || lower.contains("is not recognized as an internal or external command")
4856 || lower.contains("shell command blocked on windows")
4857}
4858
4859fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
4860 match requested {
4861 ContextMode::Full => "full",
4862 ContextMode::Compact => "compact",
4863 ContextMode::Auto => {
4864 if auto_compact {
4865 "auto_compact"
4866 } else {
4867 "auto_standard"
4868 }
4869 }
4870 }
4871}
4872
4873fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
4874 let mut sections = Vec::new();
4875 if os_aware_prompts_enabled() {
4876 sections.push(format!(
4877 "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
4878 host_os_label(host.os),
4879 shell_family_label(host.shell_family),
4880 path_style_label(host.path_style),
4881 host.arch
4882 ));
4883 }
4884 sections.push(
4885 "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
4886Use tool calls to inspect and modify the workspace when needed instead of asking the user
4887to manually run basic discovery steps. Permission prompts may occur for some tools; if
4888a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
4889 .to_string(),
4890 );
4891 sections.push(
4892 "For greetings or simple conversational messages (for example: hi, hello, thanks),
4893respond directly without calling tools."
4894 .to_string(),
4895 );
4896 if host.os == HostOs::Windows {
4897 sections.push(
4898 "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
4899Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
4900If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
4901 .to_string(),
4902 );
4903 } else {
4904 sections.push(
4905 "POSIX guidance: standard shell commands are available.
4906Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
4907 .to_string(),
4908 );
4909 }
4910 if !mcp_server_names.is_empty() {
4911 let cap = mcp_catalog_max_servers();
4912 let mut listed = mcp_server_names
4913 .iter()
4914 .take(cap)
4915 .cloned()
4916 .collect::<Vec<_>>();
4917 listed.sort();
4918 let mut catalog = listed
4919 .iter()
4920 .map(|name| format!("- {name}"))
4921 .collect::<Vec<_>>();
4922 if mcp_server_names.len() > cap {
4923 catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
4924 }
4925 sections.push(format!(
4926 "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
4927 catalog.join("\n")
4928 ));
4929 }
4930 sections.join("\n\n")
4931}
4932
4933fn os_aware_prompts_enabled() -> bool {
4934 std::env::var("TANDEM_OS_AWARE_PROMPTS")
4935 .ok()
4936 .map(|v| {
4937 let normalized = v.trim().to_ascii_lowercase();
4938 !(normalized == "0" || normalized == "false" || normalized == "off")
4939 })
4940 .unwrap_or(true)
4941}
4942
4943fn semantic_tool_retrieval_enabled() -> bool {
4944 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
4945 .ok()
4946 .map(|raw| {
4947 !matches!(
4948 raw.trim().to_ascii_lowercase().as_str(),
4949 "0" | "false" | "off" | "no"
4950 )
4951 })
4952 .unwrap_or(true)
4953}
4954
4955fn semantic_tool_retrieval_k() -> usize {
4956 std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
4957 .ok()
4958 .and_then(|raw| raw.trim().parse::<usize>().ok())
4959 .filter(|value| *value > 0)
4960 .unwrap_or_else(max_tools_per_call_expanded)
4961}
4962
4963fn mcp_catalog_in_system_prompt_enabled() -> bool {
4964 std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
4965 .ok()
4966 .map(|raw| {
4967 !matches!(
4968 raw.trim().to_ascii_lowercase().as_str(),
4969 "0" | "false" | "off" | "no"
4970 )
4971 })
4972 .unwrap_or(true)
4973}
4974
4975fn mcp_catalog_max_servers() -> usize {
4976 std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
4977 .ok()
4978 .and_then(|raw| raw.trim().parse::<usize>().ok())
4979 .filter(|value| *value > 0)
4980 .unwrap_or(20)
4981}
4982
4983fn host_os_label(os: HostOs) -> &'static str {
4984 match os {
4985 HostOs::Windows => "windows",
4986 HostOs::Linux => "linux",
4987 HostOs::Macos => "macos",
4988 }
4989}
4990
4991fn shell_family_label(shell: ShellFamily) -> &'static str {
4992 match shell {
4993 ShellFamily::Powershell => "powershell",
4994 ShellFamily::Posix => "posix",
4995 }
4996}
4997
4998fn path_style_label(path_style: PathStyle) -> &'static str {
4999 match path_style {
5000 PathStyle::Windows => "windows",
5001 PathStyle::Posix => "posix",
5002 }
5003}
5004
5005fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
5006 let user = user_text.to_lowercase();
5007 let reply = completion.to_lowercase();
5008
5009 let asked_for_project_context = [
5010 "what is this project",
5011 "what's this project",
5012 "what project is this",
5013 "explain this project",
5014 "analyze this project",
5015 "inspect this project",
5016 "look at the project",
5017 "summarize this project",
5018 "show me this project",
5019 "what files are in",
5020 "show files",
5021 "list files",
5022 "read files",
5023 "browse files",
5024 "use glob",
5025 "run glob",
5026 ]
5027 .iter()
5028 .any(|needle| user.contains(needle));
5029
5030 if !asked_for_project_context {
5031 return false;
5032 }
5033
5034 let assistant_claimed_no_access = [
5035 "can't inspect",
5036 "cannot inspect",
5037 "unable to inspect",
5038 "unable to directly inspect",
5039 "can't access",
5040 "cannot access",
5041 "unable to access",
5042 "can't read files",
5043 "cannot read files",
5044 "unable to read files",
5045 "tool restriction",
5046 "tool restrictions",
5047 "don't have visibility",
5048 "no visibility",
5049 "haven't been able to inspect",
5050 "i don't know what this project is",
5051 "need your help to",
5052 "sandbox",
5053 "restriction",
5054 "system restriction",
5055 "permissions restrictions",
5056 ]
5057 .iter()
5058 .any(|needle| reply.contains(needle));
5059
5060 asked_for_project_context && assistant_claimed_no_access
5063}
5064
5065fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
5066 let raw = input.trim();
5067 if !raw.starts_with("/tool ") {
5068 return None;
5069 }
5070 let rest = raw.trim_start_matches("/tool ").trim();
5071 let mut split = rest.splitn(2, ' ');
5072 let tool = normalize_tool_name(split.next()?.trim());
5073 let args = split
5074 .next()
5075 .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
5076 .unwrap_or_else(|| json!({}));
5077 Some((tool, args))
5078}
5079
5080fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
5081 let trimmed = input.trim();
5082 if trimmed.is_empty() {
5083 return Vec::new();
5084 }
5085
5086 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
5087 if let Some(found) = extract_tool_call_from_value(&parsed) {
5088 return vec![found];
5089 }
5090 }
5091
5092 if let Some(block) = extract_first_json_object(trimmed) {
5093 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
5094 if let Some(found) = extract_tool_call_from_value(&parsed) {
5095 return vec![found];
5096 }
5097 }
5098 }
5099
5100 parse_function_style_tool_calls(trimmed)
5101}
5102
5103#[cfg(test)]
5104fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
5105 parse_tool_invocations_from_response(input)
5106 .into_iter()
5107 .next()
5108}
5109
5110fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
5111 let mut calls = Vec::new();
5112 let lower = input.to_lowercase();
5113 let names = [
5114 "todo_write",
5115 "todowrite",
5116 "update_todo_list",
5117 "update_todos",
5118 ];
5119 let mut cursor = 0usize;
5120
5121 while cursor < lower.len() {
5122 let mut best: Option<(usize, &str)> = None;
5123 for name in names {
5124 let needle = format!("{name}(");
5125 if let Some(rel_idx) = lower[cursor..].find(&needle) {
5126 let idx = cursor + rel_idx;
5127 if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
5128 best = Some((idx, name));
5129 }
5130 }
5131 }
5132
5133 let Some((tool_start, tool_name)) = best else {
5134 break;
5135 };
5136
5137 let open_paren = tool_start + tool_name.len();
5138 if let Some(close_paren) = find_matching_paren(input, open_paren) {
5139 if let Some(args_text) = input.get(open_paren + 1..close_paren) {
5140 let args = parse_function_style_args(args_text.trim());
5141 calls.push((normalize_tool_name(tool_name), Value::Object(args)));
5142 }
5143 cursor = close_paren.saturating_add(1);
5144 } else {
5145 cursor = tool_start.saturating_add(tool_name.len());
5146 }
5147 }
5148
5149 calls
5150}
5151
5152fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
5153 if input.as_bytes().get(open_paren).copied()? != b'(' {
5154 return None;
5155 }
5156
5157 let mut depth = 0usize;
5158 let mut in_single = false;
5159 let mut in_double = false;
5160 let mut escaped = false;
5161
5162 for (offset, ch) in input.get(open_paren..)?.char_indices() {
5163 if escaped {
5164 escaped = false;
5165 continue;
5166 }
5167 if ch == '\\' && (in_single || in_double) {
5168 escaped = true;
5169 continue;
5170 }
5171 if ch == '\'' && !in_double {
5172 in_single = !in_single;
5173 continue;
5174 }
5175 if ch == '"' && !in_single {
5176 in_double = !in_double;
5177 continue;
5178 }
5179 if in_single || in_double {
5180 continue;
5181 }
5182
5183 match ch {
5184 '(' => depth += 1,
5185 ')' => {
5186 depth = depth.saturating_sub(1);
5187 if depth == 0 {
5188 return Some(open_paren + offset);
5189 }
5190 }
5191 _ => {}
5192 }
5193 }
5194
5195 None
5196}
5197
5198fn parse_function_style_args(input: &str) -> Map<String, Value> {
5199 let mut args = Map::new();
5200 if input.trim().is_empty() {
5201 return args;
5202 }
5203
5204 let mut parts = Vec::<String>::new();
5205 let mut current = String::new();
5206 let mut in_single = false;
5207 let mut in_double = false;
5208 let mut escaped = false;
5209 let mut depth_paren = 0usize;
5210 let mut depth_bracket = 0usize;
5211 let mut depth_brace = 0usize;
5212
5213 for ch in input.chars() {
5214 if escaped {
5215 current.push(ch);
5216 escaped = false;
5217 continue;
5218 }
5219 if ch == '\\' && (in_single || in_double) {
5220 current.push(ch);
5221 escaped = true;
5222 continue;
5223 }
5224 if ch == '\'' && !in_double {
5225 in_single = !in_single;
5226 current.push(ch);
5227 continue;
5228 }
5229 if ch == '"' && !in_single {
5230 in_double = !in_double;
5231 current.push(ch);
5232 continue;
5233 }
5234 if in_single || in_double {
5235 current.push(ch);
5236 continue;
5237 }
5238
5239 match ch {
5240 '(' => depth_paren += 1,
5241 ')' => depth_paren = depth_paren.saturating_sub(1),
5242 '[' => depth_bracket += 1,
5243 ']' => depth_bracket = depth_bracket.saturating_sub(1),
5244 '{' => depth_brace += 1,
5245 '}' => depth_brace = depth_brace.saturating_sub(1),
5246 ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
5247 let part = current.trim();
5248 if !part.is_empty() {
5249 parts.push(part.to_string());
5250 }
5251 current.clear();
5252 continue;
5253 }
5254 _ => {}
5255 }
5256 current.push(ch);
5257 }
5258 let tail = current.trim();
5259 if !tail.is_empty() {
5260 parts.push(tail.to_string());
5261 }
5262
5263 for part in parts {
5264 let Some((raw_key, raw_value)) = part
5265 .split_once('=')
5266 .or_else(|| part.split_once(':'))
5267 .map(|(k, v)| (k.trim(), v.trim()))
5268 else {
5269 continue;
5270 };
5271 let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
5272 if key.is_empty() {
5273 continue;
5274 }
5275 let value = parse_scalar_like_value(raw_value);
5276 args.insert(key.to_string(), value);
5277 }
5278
5279 args
5280}
5281
5282fn parse_scalar_like_value(raw: &str) -> Value {
5283 let trimmed = raw.trim();
5284 if trimmed.is_empty() {
5285 return Value::Null;
5286 }
5287
5288 if (trimmed.starts_with('"') && trimmed.ends_with('"'))
5289 || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
5290 {
5291 if trimmed.len() < 2 {
5292 return Value::String(trimmed.to_string());
5293 }
5294 return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
5295 }
5296
5297 if trimmed.eq_ignore_ascii_case("true") {
5298 return Value::Bool(true);
5299 }
5300 if trimmed.eq_ignore_ascii_case("false") {
5301 return Value::Bool(false);
5302 }
5303 if trimmed.eq_ignore_ascii_case("null") {
5304 return Value::Null;
5305 }
5306
5307 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
5308 return v;
5309 }
5310 if let Ok(v) = trimmed.parse::<i64>() {
5311 return Value::Number(Number::from(v));
5312 }
5313 if let Ok(v) = trimmed.parse::<f64>() {
5314 if let Some(n) = Number::from_f64(v) {
5315 return Value::Number(n);
5316 }
5317 }
5318
5319 Value::String(trimmed.to_string())
5320}
5321
5322fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
5323 let content = extract_loose_json_string_field(raw, "content")?;
5324 let mut obj = Map::new();
5325 if let Some(path) = extract_loose_json_string_field(raw, "path") {
5326 obj.insert("path".to_string(), Value::String(path));
5327 }
5328 obj.insert("content".to_string(), Value::String(content));
5329 Some(Value::Object(obj))
5330}
5331
5332fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
5333 let pattern = format!("\"{key}\"");
5334 let start = input.find(&pattern)?;
5335 let remainder = input.get(start + pattern.len()..)?;
5336 let colon = remainder.find(':')?;
5337 let value = remainder.get(colon + 1..)?.trim_start();
5338 let value = value.strip_prefix('"')?;
5339 Some(parse_loose_json_string_value(value))
5340}
5341
5342fn parse_loose_json_string_value(input: &str) -> String {
5343 let mut out = String::new();
5344 let mut chars = input.chars().peekable();
5345 let mut closed = false;
5346
5347 while let Some(ch) = chars.next() {
5348 if ch == '"' {
5349 closed = true;
5350 break;
5351 }
5352 if ch != '\\' {
5353 out.push(ch);
5354 continue;
5355 }
5356
5357 let Some(escaped) = chars.next() else {
5358 out.push('\\');
5359 break;
5360 };
5361 match escaped {
5362 '"' => out.push('"'),
5363 '\\' => out.push('\\'),
5364 '/' => out.push('/'),
5365 'b' => out.push('\u{0008}'),
5366 'f' => out.push('\u{000C}'),
5367 'n' => out.push('\n'),
5368 'r' => out.push('\r'),
5369 't' => out.push('\t'),
5370 'u' => {
5371 let mut hex = String::new();
5372 for _ in 0..4 {
5373 let Some(next) = chars.next() else {
5374 break;
5375 };
5376 hex.push(next);
5377 }
5378 if hex.len() == 4 {
5379 if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
5380 if let Some(decoded) = char::from_u32(codepoint as u32) {
5381 out.push(decoded);
5382 continue;
5383 }
5384 }
5385 }
5386 out.push('\\');
5387 out.push('u');
5388 out.push_str(&hex);
5389 }
5390 other => {
5391 out.push('\\');
5392 out.push(other);
5393 }
5394 }
5395 }
5396
5397 if !closed {
5398 return out;
5399 }
5400 out
5401}
5402
5403fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
5404 if is_todo_status_update_args(&args) {
5405 return args;
5406 }
5407
5408 let mut obj = match args {
5409 Value::Object(map) => map,
5410 Value::Array(items) => {
5411 return json!({ "todos": normalize_todo_arg_items(items) });
5412 }
5413 Value::String(text) => {
5414 let derived = extract_todo_candidates_from_text(&text);
5415 if !derived.is_empty() {
5416 return json!({ "todos": derived });
5417 }
5418 return json!({});
5419 }
5420 _ => return json!({}),
5421 };
5422
5423 if obj
5424 .get("todos")
5425 .and_then(|v| v.as_array())
5426 .map(|arr| !arr.is_empty())
5427 .unwrap_or(false)
5428 {
5429 return Value::Object(obj);
5430 }
5431
5432 for alias in ["tasks", "items", "list", "checklist"] {
5433 if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
5434 let normalized = normalize_todo_arg_items(items.clone());
5435 if !normalized.is_empty() {
5436 obj.insert("todos".to_string(), Value::Array(normalized));
5437 return Value::Object(obj);
5438 }
5439 }
5440 }
5441
5442 let derived = extract_todo_candidates_from_text(completion);
5443 if !derived.is_empty() {
5444 obj.insert("todos".to_string(), Value::Array(derived));
5445 }
5446 Value::Object(obj)
5447}
5448
5449fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
5450 items
5451 .into_iter()
5452 .filter_map(|item| match item {
5453 Value::String(text) => {
5454 let content = text.trim();
5455 if content.is_empty() {
5456 None
5457 } else {
5458 Some(json!({"content": content}))
5459 }
5460 }
5461 Value::Object(mut obj) => {
5462 if !obj.contains_key("content") {
5463 if let Some(text) = obj.get("text").cloned() {
5464 obj.insert("content".to_string(), text);
5465 } else if let Some(title) = obj.get("title").cloned() {
5466 obj.insert("content".to_string(), title);
5467 } else if let Some(name) = obj.get("name").cloned() {
5468 obj.insert("content".to_string(), name);
5469 }
5470 }
5471 let content = obj
5472 .get("content")
5473 .and_then(|v| v.as_str())
5474 .map(str::trim)
5475 .unwrap_or("");
5476 if content.is_empty() {
5477 None
5478 } else {
5479 Some(Value::Object(obj))
5480 }
5481 }
5482 _ => None,
5483 })
5484 .collect()
5485}
5486
5487fn is_todo_status_update_args(args: &Value) -> bool {
5488 let Some(obj) = args.as_object() else {
5489 return false;
5490 };
5491 let has_status = obj
5492 .get("status")
5493 .and_then(|v| v.as_str())
5494 .map(|s| !s.trim().is_empty())
5495 .unwrap_or(false);
5496 let has_target =
5497 obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
5498 has_status && has_target
5499}
5500
5501fn is_empty_todo_write_args(args: &Value) -> bool {
5502 if is_todo_status_update_args(args) {
5503 return false;
5504 }
5505 let Some(obj) = args.as_object() else {
5506 return true;
5507 };
5508 !obj.get("todos")
5509 .and_then(|v| v.as_array())
5510 .map(|arr| !arr.is_empty())
5511 .unwrap_or(false)
5512}
5513
5514fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
5515 let trimmed = raw_args.trim();
5516 if trimmed.is_empty() {
5517 return json!({});
5518 }
5519
5520 let normalized_tool = normalize_tool_name(tool_name);
5521 if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5522 return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
5523 }
5524
5525 if normalized_tool == "write" {
5526 if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
5527 return recovered;
5528 }
5529 }
5530
5531 let kv_args = parse_function_style_args(trimmed);
5534 if !kv_args.is_empty() {
5535 return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
5536 }
5537
5538 if normalized_tool == "websearch" {
5539 if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
5540 return json!({ "query": query });
5541 }
5542 return json!({});
5543 }
5544
5545 Value::String(trimmed.to_string())
5546}
5547
5548fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
5549 let normalized_tool = normalize_tool_name(tool_name);
5550 if normalized_tool != "websearch" {
5551 return parsed;
5552 }
5553
5554 match parsed {
5555 Value::Object(mut obj) => {
5556 if !has_websearch_query(&obj) && !raw.trim().is_empty() {
5557 if let Some(query) = sanitize_websearch_query_candidate(raw) {
5558 obj.insert("query".to_string(), Value::String(query));
5559 }
5560 }
5561 Value::Object(obj)
5562 }
5563 Value::String(s) => match sanitize_websearch_query_candidate(&s) {
5564 Some(query) => json!({ "query": query }),
5565 None => json!({}),
5566 },
5567 other => other,
5568 }
5569}
5570
5571fn has_websearch_query(obj: &Map<String, Value>) -> bool {
5572 const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
5573 QUERY_KEYS.iter().any(|key| {
5574 obj.get(*key)
5575 .and_then(|v| v.as_str())
5576 .map(|s| !s.trim().is_empty())
5577 .unwrap_or(false)
5578 })
5579}
5580
5581fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
5582 if let Some(obj) = value.as_object() {
5583 if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
5584 return Some((
5585 normalize_tool_name(tool),
5586 obj.get("args").cloned().unwrap_or_else(|| json!({})),
5587 ));
5588 }
5589
5590 if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
5591 let args = obj
5592 .get("args")
5593 .cloned()
5594 .or_else(|| obj.get("arguments").cloned())
5595 .unwrap_or_else(|| json!({}));
5596 let normalized_tool = normalize_tool_name(tool);
5597 let args = if let Some(raw) = args.as_str() {
5598 parse_streamed_tool_args(&normalized_tool, raw)
5599 } else {
5600 args
5601 };
5602 return Some((normalized_tool, args));
5603 }
5604
5605 for key in [
5606 "tool_call",
5607 "toolCall",
5608 "call",
5609 "function_call",
5610 "functionCall",
5611 ] {
5612 if let Some(nested) = obj.get(key) {
5613 if let Some(found) = extract_tool_call_from_value(nested) {
5614 return Some(found);
5615 }
5616 }
5617 }
5618
5619 if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
5620 for call in calls {
5621 if let Some(found) = extract_tool_call_from_value(call) {
5622 return Some(found);
5623 }
5624 }
5625 }
5626 }
5627
5628 if let Some(items) = value.as_array() {
5629 for item in items {
5630 if let Some(found) = extract_tool_call_from_value(item) {
5631 return Some(found);
5632 }
5633 }
5634 }
5635
5636 None
5637}
5638
5639fn extract_first_json_object(input: &str) -> Option<String> {
5640 let mut start = None;
5641 let mut depth = 0usize;
5642 for (idx, ch) in input.char_indices() {
5643 if ch == '{' {
5644 if start.is_none() {
5645 start = Some(idx);
5646 }
5647 depth += 1;
5648 } else if ch == '}' {
5649 if depth == 0 {
5650 continue;
5651 }
5652 depth -= 1;
5653 if depth == 0 {
5654 let begin = start?;
5655 let block = input.get(begin..=idx)?;
5656 return Some(block.to_string());
5657 }
5658 }
5659 }
5660 None
5661}
5662
5663fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
5664 let mut seen = HashSet::<String>::new();
5665 let mut todos = Vec::new();
5666
5667 for raw_line in input.lines() {
5668 let mut line = raw_line.trim();
5669 let mut structured_line = false;
5670 if line.is_empty() {
5671 continue;
5672 }
5673 if line.starts_with("```") {
5674 continue;
5675 }
5676 if line.ends_with(':') {
5677 continue;
5678 }
5679 if let Some(rest) = line
5680 .strip_prefix("- [ ]")
5681 .or_else(|| line.strip_prefix("* [ ]"))
5682 .or_else(|| line.strip_prefix("- [x]"))
5683 .or_else(|| line.strip_prefix("* [x]"))
5684 {
5685 line = rest.trim();
5686 structured_line = true;
5687 } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
5688 line = rest.trim();
5689 structured_line = true;
5690 } else {
5691 let bytes = line.as_bytes();
5692 let mut i = 0usize;
5693 while i < bytes.len() && bytes[i].is_ascii_digit() {
5694 i += 1;
5695 }
5696 if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
5697 line = line[i + 1..].trim();
5698 structured_line = true;
5699 }
5700 }
5701 if !structured_line {
5702 continue;
5703 }
5704
5705 let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
5706 if content.len() < 5 || content.len() > 180 {
5707 continue;
5708 }
5709 let key = content.to_lowercase();
5710 if seen.contains(&key) {
5711 continue;
5712 }
5713 seen.insert(key);
5714 todos.push(json!({ "content": content }));
5715 if todos.len() >= 25 {
5716 break;
5717 }
5718 }
5719
5720 todos
5721}
5722
5723async fn emit_plan_todo_fallback(
5724 storage: std::sync::Arc<Storage>,
5725 bus: &EventBus,
5726 session_id: &str,
5727 message_id: &str,
5728 completion: &str,
5729) {
5730 let todos = extract_todo_candidates_from_text(completion);
5731 if todos.is_empty() {
5732 return;
5733 }
5734
5735 let invoke_part = WireMessagePart::tool_invocation(
5736 session_id,
5737 message_id,
5738 "todo_write",
5739 json!({"todos": todos.clone()}),
5740 );
5741 let call_id = invoke_part.id.clone();
5742 bus.publish(EngineEvent::new(
5743 "message.part.updated",
5744 json!({"part": invoke_part}),
5745 ));
5746
5747 if storage.set_todos(session_id, todos.clone()).await.is_err() {
5748 let mut failed_part = WireMessagePart::tool_result(
5749 session_id,
5750 message_id,
5751 "todo_write",
5752 Some(json!({"todos": todos.clone()})),
5753 json!(null),
5754 );
5755 failed_part.id = call_id;
5756 failed_part.state = Some("failed".to_string());
5757 failed_part.error = Some("failed to persist plan todos".to_string());
5758 bus.publish(EngineEvent::new(
5759 "message.part.updated",
5760 json!({"part": failed_part}),
5761 ));
5762 return;
5763 }
5764
5765 let normalized = storage.get_todos(session_id).await;
5766 let mut result_part = WireMessagePart::tool_result(
5767 session_id,
5768 message_id,
5769 "todo_write",
5770 Some(json!({"todos": todos.clone()})),
5771 json!({ "todos": normalized }),
5772 );
5773 result_part.id = call_id;
5774 bus.publish(EngineEvent::new(
5775 "message.part.updated",
5776 json!({"part": result_part}),
5777 ));
5778 bus.publish(EngineEvent::new(
5779 "todo.updated",
5780 json!({
5781 "sessionID": session_id,
5782 "todos": normalized
5783 }),
5784 ));
5785}
5786
5787async fn emit_plan_question_fallback(
5788 storage: std::sync::Arc<Storage>,
5789 bus: &EventBus,
5790 session_id: &str,
5791 message_id: &str,
5792 completion: &str,
5793) {
5794 let trimmed = completion.trim();
5795 if trimmed.is_empty() {
5796 return;
5797 }
5798
5799 let hints = extract_todo_candidates_from_text(trimmed)
5800 .into_iter()
5801 .take(6)
5802 .filter_map(|v| {
5803 v.get("content")
5804 .and_then(|c| c.as_str())
5805 .map(ToString::to_string)
5806 })
5807 .collect::<Vec<_>>();
5808
5809 let mut options = hints
5810 .iter()
5811 .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
5812 .collect::<Vec<_>>();
5813 if options.is_empty() {
5814 options = vec![
5815 json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
5816 json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
5817 json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
5818 ];
5819 }
5820
5821 let question_payload = vec![json!({
5822 "header":"Planning Input",
5823 "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
5824 "options": options,
5825 "multiple": true,
5826 "custom": true
5827 })];
5828
5829 let request = storage
5830 .add_question_request(session_id, message_id, question_payload.clone())
5831 .await
5832 .ok();
5833 bus.publish(EngineEvent::new(
5834 "question.asked",
5835 json!({
5836 "id": request
5837 .as_ref()
5838 .map(|req| req.id.clone())
5839 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
5840 "sessionID": session_id,
5841 "messageID": message_id,
5842 "questions": question_payload,
5843 "tool": request.and_then(|req| {
5844 req.tool.map(|tool| {
5845 json!({
5846 "callID": tool.call_id,
5847 "messageID": tool.message_id
5848 })
5849 })
5850 })
5851 }),
5852 ));
5853}
5854
5855#[derive(Debug, Clone, Copy)]
5856enum ChatHistoryProfile {
5857 Full,
5858 Standard,
5859 Compact,
5860}
5861
5862async fn load_chat_history(
5863 storage: std::sync::Arc<Storage>,
5864 session_id: &str,
5865 profile: ChatHistoryProfile,
5866) -> Vec<ChatMessage> {
5867 let Some(session) = storage.get_session(session_id).await else {
5868 return Vec::new();
5869 };
5870 let messages = session
5871 .messages
5872 .into_iter()
5873 .map(|m| {
5874 let role = format!("{:?}", m.role).to_lowercase();
5875 let content = m
5876 .parts
5877 .into_iter()
5878 .map(|part| match part {
5879 MessagePart::Text { text } => text,
5880 MessagePart::Reasoning { text } => text,
5881 MessagePart::ToolInvocation { tool, result, .. } => {
5882 format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
5883 }
5884 })
5885 .collect::<Vec<_>>()
5886 .join("\n");
5887 ChatMessage {
5888 role,
5889 content,
5890 attachments: Vec::new(),
5891 }
5892 })
5893 .collect::<Vec<_>>();
5894 compact_chat_history(messages, profile)
5895}
5896
5897fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
5898 if attachments.is_empty() {
5899 return;
5900 }
5901 if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
5902 message.attachments = attachments.to_vec();
5903 }
5904}
5905
5906async fn build_runtime_attachments(
5907 provider_id: &str,
5908 parts: &[MessagePartInput],
5909) -> Vec<ChatAttachment> {
5910 if !supports_image_attachments(provider_id) {
5911 return Vec::new();
5912 }
5913
5914 let mut attachments = Vec::new();
5915 for part in parts {
5916 let MessagePartInput::File { mime, url, .. } = part else {
5917 continue;
5918 };
5919 if !mime.to_ascii_lowercase().starts_with("image/") {
5920 continue;
5921 }
5922 if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
5923 attachments.push(ChatAttachment::ImageUrl { url: source_url });
5924 }
5925 }
5926
5927 attachments
5928}
5929
5930fn supports_image_attachments(provider_id: &str) -> bool {
5931 matches!(
5932 provider_id,
5933 "openai"
5934 | "openrouter"
5935 | "ollama"
5936 | "groq"
5937 | "mistral"
5938 | "together"
5939 | "azure"
5940 | "bedrock"
5941 | "vertex"
5942 | "copilot"
5943 )
5944}
5945
5946async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
5947 let trimmed = url.trim();
5948 if trimmed.is_empty() {
5949 return None;
5950 }
5951 if trimmed.starts_with("http://")
5952 || trimmed.starts_with("https://")
5953 || trimmed.starts_with("data:")
5954 {
5955 return Some(trimmed.to_string());
5956 }
5957
5958 let file_path = trimmed
5959 .strip_prefix("file://")
5960 .map(PathBuf::from)
5961 .unwrap_or_else(|| PathBuf::from(trimmed));
5962 if !file_path.exists() {
5963 return None;
5964 }
5965
5966 let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
5967 .ok()
5968 .and_then(|v| v.parse::<usize>().ok())
5969 .unwrap_or(20 * 1024 * 1024);
5970
5971 let bytes = match tokio::fs::read(&file_path).await {
5972 Ok(bytes) => bytes,
5973 Err(err) => {
5974 tracing::warn!(
5975 "failed reading local attachment '{}': {}",
5976 file_path.to_string_lossy(),
5977 err
5978 );
5979 return None;
5980 }
5981 };
5982 if bytes.len() > max_bytes {
5983 tracing::warn!(
5984 "local attachment '{}' exceeds max bytes ({} > {})",
5985 file_path.to_string_lossy(),
5986 bytes.len(),
5987 max_bytes
5988 );
5989 return None;
5990 }
5991
5992 use base64::Engine as _;
5993 let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
5994 Some(format!("data:{mime};base64,{b64}"))
5995}
5996
5997struct ToolSideEventContext<'a> {
5998 session_id: &'a str,
5999 message_id: &'a str,
6000 tool: &'a str,
6001 args: &'a serde_json::Value,
6002 metadata: &'a serde_json::Value,
6003 workspace_root: Option<&'a str>,
6004 effective_cwd: Option<&'a str>,
6005}
6006
6007async fn emit_tool_side_events(
6008 storage: std::sync::Arc<Storage>,
6009 bus: &EventBus,
6010 ctx: ToolSideEventContext<'_>,
6011) {
6012 let ToolSideEventContext {
6013 session_id,
6014 message_id,
6015 tool,
6016 args,
6017 metadata,
6018 workspace_root,
6019 effective_cwd,
6020 } = ctx;
6021 if tool == "todo_write" {
6022 let todos_from_metadata = metadata
6023 .get("todos")
6024 .and_then(|v| v.as_array())
6025 .cloned()
6026 .unwrap_or_default();
6027
6028 if !todos_from_metadata.is_empty() {
6029 let _ = storage.set_todos(session_id, todos_from_metadata).await;
6030 } else {
6031 let current = storage.get_todos(session_id).await;
6032 if let Some(updated) = apply_todo_updates_from_args(current, args) {
6033 let _ = storage.set_todos(session_id, updated).await;
6034 }
6035 }
6036
6037 let normalized = storage.get_todos(session_id).await;
6038 bus.publish(EngineEvent::new(
6039 "todo.updated",
6040 json!({
6041 "sessionID": session_id,
6042 "todos": normalized,
6043 "workspaceRoot": workspace_root,
6044 "effectiveCwd": effective_cwd
6045 }),
6046 ));
6047 }
6048 if tool == "question" {
6049 let questions = metadata
6050 .get("questions")
6051 .and_then(|v| v.as_array())
6052 .cloned()
6053 .unwrap_or_default();
6054 if questions.is_empty() {
6055 tracing::warn!(
6056 "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
6057 session_id,
6058 message_id
6059 );
6060 } else {
6061 let request = storage
6062 .add_question_request(session_id, message_id, questions.clone())
6063 .await
6064 .ok();
6065 bus.publish(EngineEvent::new(
6066 "question.asked",
6067 json!({
6068 "id": request
6069 .as_ref()
6070 .map(|req| req.id.clone())
6071 .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
6072 "sessionID": session_id,
6073 "messageID": message_id,
6074 "questions": questions,
6075 "tool": request.and_then(|req| {
6076 req.tool.map(|tool| {
6077 json!({
6078 "callID": tool.call_id,
6079 "messageID": tool.message_id
6080 })
6081 })
6082 }),
6083 "workspaceRoot": workspace_root,
6084 "effectiveCwd": effective_cwd
6085 }),
6086 ));
6087 }
6088 }
6089 if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
6090 for event in events {
6091 let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
6092 continue;
6093 };
6094 if !event_type.starts_with("agent_team.") {
6095 continue;
6096 }
6097 let mut properties = event
6098 .get("properties")
6099 .and_then(|v| v.as_object())
6100 .cloned()
6101 .unwrap_or_default();
6102 properties
6103 .entry("sessionID".to_string())
6104 .or_insert(json!(session_id));
6105 properties
6106 .entry("messageID".to_string())
6107 .or_insert(json!(message_id));
6108 properties
6109 .entry("workspaceRoot".to_string())
6110 .or_insert(json!(workspace_root));
6111 properties
6112 .entry("effectiveCwd".to_string())
6113 .or_insert(json!(effective_cwd));
6114 bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
6115 }
6116 }
6117}
6118
6119fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
6120 let obj = args.as_object()?;
6121 let mut todos = current;
6122 let mut changed = false;
6123
6124 if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
6125 for item in items {
6126 let Some(item_obj) = item.as_object() else {
6127 continue;
6128 };
6129 let status = item_obj
6130 .get("status")
6131 .and_then(|v| v.as_str())
6132 .map(normalize_todo_status);
6133 let target = item_obj
6134 .get("task_id")
6135 .or_else(|| item_obj.get("todo_id"))
6136 .or_else(|| item_obj.get("id"));
6137
6138 if let (Some(status), Some(target)) = (status, target) {
6139 changed |= apply_single_todo_status_update(&mut todos, target, &status);
6140 }
6141 }
6142 }
6143
6144 let status = obj
6145 .get("status")
6146 .and_then(|v| v.as_str())
6147 .map(normalize_todo_status);
6148 let target = obj
6149 .get("task_id")
6150 .or_else(|| obj.get("todo_id"))
6151 .or_else(|| obj.get("id"));
6152 if let (Some(status), Some(target)) = (status, target) {
6153 changed |= apply_single_todo_status_update(&mut todos, target, &status);
6154 }
6155
6156 if changed {
6157 Some(todos)
6158 } else {
6159 None
6160 }
6161}
6162
6163fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
6164 let idx_from_value = match target {
6165 Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
6166 Value::String(s) => {
6167 let trimmed = s.trim();
6168 trimmed
6169 .parse::<usize>()
6170 .ok()
6171 .map(|v| v.saturating_sub(1))
6172 .or_else(|| {
6173 let digits = trimmed
6174 .chars()
6175 .rev()
6176 .take_while(|c| c.is_ascii_digit())
6177 .collect::<String>()
6178 .chars()
6179 .rev()
6180 .collect::<String>();
6181 digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
6182 })
6183 }
6184 _ => None,
6185 };
6186
6187 if let Some(idx) = idx_from_value {
6188 if idx < todos.len() {
6189 if let Some(obj) = todos[idx].as_object_mut() {
6190 obj.insert("status".to_string(), Value::String(status.to_string()));
6191 return true;
6192 }
6193 }
6194 }
6195
6196 let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
6197 if let Some(id_target) = id_target {
6198 for todo in todos.iter_mut() {
6199 if let Some(obj) = todo.as_object_mut() {
6200 if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
6201 obj.insert("status".to_string(), Value::String(status.to_string()));
6202 return true;
6203 }
6204 }
6205 }
6206 }
6207
6208 false
6209}
6210
6211fn normalize_todo_status(raw: &str) -> String {
6212 match raw.trim().to_lowercase().as_str() {
6213 "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
6214 "done" | "complete" | "completed" => "completed".to_string(),
6215 "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
6216 "open" | "todo" | "pending" => "pending".to_string(),
6217 other => other.to_string(),
6218 }
6219}
6220
6221fn compact_chat_history(
6222 messages: Vec<ChatMessage>,
6223 profile: ChatHistoryProfile,
6224) -> Vec<ChatMessage> {
6225 let (max_context_chars, keep_recent_messages) = match profile {
6226 ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
6227 ChatHistoryProfile::Standard => (80_000usize, 40usize),
6228 ChatHistoryProfile::Compact => (12_000usize, 12usize),
6229 };
6230
6231 if messages.len() <= keep_recent_messages {
6232 let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
6233 if total_chars <= max_context_chars {
6234 return messages;
6235 }
6236 }
6237
6238 let mut kept = messages;
6239 let mut dropped_count = 0usize;
6240 let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
6241
6242 while kept.len() > keep_recent_messages || total_chars > max_context_chars {
6243 if kept.is_empty() {
6244 break;
6245 }
6246 let removed = kept.remove(0);
6247 total_chars = total_chars.saturating_sub(removed.content.len());
6248 dropped_count += 1;
6249 }
6250
6251 if dropped_count > 0 {
6252 kept.insert(
6253 0,
6254 ChatMessage {
6255 role: "system".to_string(),
6256 content: format!(
6257 "[history compacted: omitted {} older messages to fit context window]",
6258 dropped_count
6259 ),
6260 attachments: Vec::new(),
6261 },
6262 );
6263 }
6264 kept
6265}
6266
6267#[cfg(test)]
6268mod tests {
6269 use super::*;
6270 use crate::{EventBus, Storage};
6271 use std::sync::{Mutex, OnceLock};
6272 use uuid::Uuid;
6273
6274 fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
6275 static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
6276 ENV_TEST_LOCK
6277 .get_or_init(|| Mutex::new(()))
6278 .lock()
6279 .expect("env test lock")
6280 }
6281
6282 #[tokio::test]
6283 async fn todo_updated_event_is_normalized() {
6284 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
6285 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
6286 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
6287 let session_id = session.id.clone();
6288 storage.save_session(session).await.expect("save session");
6289
6290 let bus = EventBus::new();
6291 let mut rx = bus.subscribe();
6292 emit_tool_side_events(
6293 storage.clone(),
6294 &bus,
6295 ToolSideEventContext {
6296 session_id: &session_id,
6297 message_id: "m1",
6298 tool: "todo_write",
6299 args: &json!({"todos":[{"content":"ship parity"}]}),
6300 metadata: &json!({"todos":[{"content":"ship parity"}]}),
6301 workspace_root: Some("."),
6302 effective_cwd: Some("."),
6303 },
6304 )
6305 .await;
6306
6307 let event = rx.recv().await.expect("event");
6308 assert_eq!(event.event_type, "todo.updated");
6309 let todos = event
6310 .properties
6311 .get("todos")
6312 .and_then(|v| v.as_array())
6313 .cloned()
6314 .unwrap_or_default();
6315 assert_eq!(todos.len(), 1);
6316 assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
6317 assert_eq!(
6318 todos[0].get("content").and_then(|v| v.as_str()),
6319 Some("ship parity")
6320 );
6321 assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
6322 }
6323
6324 #[tokio::test]
6325 async fn question_asked_event_contains_tool_reference() {
6326 let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
6327 let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
6328 let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
6329 let session_id = session.id.clone();
6330 storage.save_session(session).await.expect("save session");
6331
6332 let bus = EventBus::new();
6333 let mut rx = bus.subscribe();
6334 emit_tool_side_events(
6335 storage,
6336 &bus,
6337 ToolSideEventContext {
6338 session_id: &session_id,
6339 message_id: "msg-1",
6340 tool: "question",
6341 args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
6342 metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
6343 workspace_root: Some("."),
6344 effective_cwd: Some("."),
6345 },
6346 )
6347 .await;
6348
6349 let event = rx.recv().await.expect("event");
6350 assert_eq!(event.event_type, "question.asked");
6351 assert_eq!(
6352 event
6353 .properties
6354 .get("sessionID")
6355 .and_then(|v| v.as_str())
6356 .unwrap_or(""),
6357 session_id
6358 );
6359 let tool = event
6360 .properties
6361 .get("tool")
6362 .cloned()
6363 .unwrap_or_else(|| json!({}));
6364 assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
6365 assert_eq!(
6366 tool.get("messageID").and_then(|v| v.as_str()),
6367 Some("msg-1")
6368 );
6369 }
6370
6371 #[test]
6372 fn compact_chat_history_keeps_recent_and_inserts_summary() {
6373 let mut messages = Vec::new();
6374 for i in 0..60 {
6375 messages.push(ChatMessage {
6376 role: "user".to_string(),
6377 content: format!("message-{i}"),
6378 attachments: Vec::new(),
6379 });
6380 }
6381 let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
6382 assert!(compacted.len() <= 41);
6383 assert_eq!(compacted[0].role, "system");
6384 assert!(compacted[0].content.contains("history compacted"));
6385 assert!(compacted.iter().any(|m| m.content.contains("message-59")));
6386 }
6387
6388 #[test]
6389 fn extracts_todos_from_checklist_and_numbered_lines() {
6390 let input = r#"
6391Plan:
6392- [ ] Audit current implementation
6393- [ ] Add planner fallback
63941. Add regression test coverage
6395"#;
6396 let todos = extract_todo_candidates_from_text(input);
6397 assert_eq!(todos.len(), 3);
6398 assert_eq!(
6399 todos[0].get("content").and_then(|v| v.as_str()),
6400 Some("Audit current implementation")
6401 );
6402 }
6403
6404 #[test]
6405 fn does_not_extract_todos_from_plain_prose_lines() {
6406 let input = r#"
6407I need more information to proceed.
6408Can you tell me the event size and budget?
6409Once I have that, I can provide a detailed plan.
6410"#;
6411 let todos = extract_todo_candidates_from_text(input);
6412 assert!(todos.is_empty());
6413 }
6414
6415 #[test]
6416 fn parses_wrapped_tool_call_from_markdown_response() {
6417 let input = r#"
6418Here is the tool call:
6419```json
6420{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
6421```
6422"#;
6423 let parsed = parse_tool_invocation_from_response(input).expect("tool call");
6424 assert_eq!(parsed.0, "todo_write");
6425 assert!(parsed.1.get("todos").is_some());
6426 }
6427
6428 #[test]
6429 fn parses_top_level_name_args_tool_call() {
6430 let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
6431 let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
6432 assert_eq!(parsed.0, "bash");
6433 assert_eq!(
6434 parsed.1.get("command").and_then(|v| v.as_str()),
6435 Some("echo hi")
6436 );
6437 }
6438
6439 #[test]
6440 fn parses_function_style_todowrite_call() {
6441 let input = r#"Status: Completed
6442Call: todowrite(task_id=2, status="completed")"#;
6443 let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
6444 assert_eq!(parsed.0, "todo_write");
6445 assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
6446 assert_eq!(
6447 parsed.1.get("status").and_then(|v| v.as_str()),
6448 Some("completed")
6449 );
6450 }
6451
6452 #[test]
6453 fn parses_multiple_function_style_todowrite_calls() {
6454 let input = r#"
6455Call: todowrite(task_id=2, status="completed")
6456Call: todowrite(task_id=3, status="in_progress")
6457"#;
6458 let parsed = parse_tool_invocations_from_response(input);
6459 assert_eq!(parsed.len(), 2);
6460 assert_eq!(parsed[0].0, "todo_write");
6461 assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
6462 assert_eq!(
6463 parsed[0].1.get("status").and_then(|v| v.as_str()),
6464 Some("completed")
6465 );
6466 assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
6467 assert_eq!(
6468 parsed[1].1.get("status").and_then(|v| v.as_str()),
6469 Some("in_progress")
6470 );
6471 }
6472
6473 #[test]
6474 fn applies_todo_status_update_from_task_id_args() {
6475 let current = vec![
6476 json!({"id":"todo-1","content":"a","status":"pending"}),
6477 json!({"id":"todo-2","content":"b","status":"pending"}),
6478 json!({"id":"todo-3","content":"c","status":"pending"}),
6479 ];
6480 let updated =
6481 apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
6482 .expect("status update");
6483 assert_eq!(
6484 updated[1].get("status").and_then(|v| v.as_str()),
6485 Some("completed")
6486 );
6487 }
6488
6489 #[test]
6490 fn normalizes_todo_write_tasks_alias() {
6491 let normalized = normalize_todo_write_args(
6492 json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
6493 "",
6494 );
6495 let todos = normalized
6496 .get("todos")
6497 .and_then(|v| v.as_array())
6498 .cloned()
6499 .unwrap_or_default();
6500 assert_eq!(todos.len(), 2);
6501 assert_eq!(
6502 todos[0].get("content").and_then(|v| v.as_str()),
6503 Some("Book venue")
6504 );
6505 assert_eq!(
6506 todos[1].get("content").and_then(|v| v.as_str()),
6507 Some("Send invites")
6508 );
6509 }
6510
6511 #[test]
6512 fn normalizes_todo_write_from_completion_when_args_empty() {
6513 let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
6514 let normalized = normalize_todo_write_args(json!({}), completion);
6515 let todos = normalized
6516 .get("todos")
6517 .and_then(|v| v.as_array())
6518 .cloned()
6519 .unwrap_or_default();
6520 assert_eq!(todos.len(), 3);
6521 assert!(!is_empty_todo_write_args(&normalized));
6522 }
6523
6524 #[test]
6525 fn empty_todo_write_args_allows_status_updates() {
6526 let args = json!({"task_id": 2, "status":"completed"});
6527 assert!(!is_empty_todo_write_args(&args));
6528 }
6529
6530 #[test]
6531 fn streamed_websearch_args_fallback_to_query_string() {
6532 let parsed = parse_streamed_tool_args("websearch", "meaning of life");
6533 assert_eq!(
6534 parsed.get("query").and_then(|v| v.as_str()),
6535 Some("meaning of life")
6536 );
6537 }
6538
6539 #[test]
6540 fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
6541 assert_eq!(
6542 parse_scalar_like_value("\""),
6543 Value::String("\"".to_string())
6544 );
6545 assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
6546 }
6547
6548 #[test]
6549 fn streamed_websearch_stringified_json_args_are_unwrapped() {
6550 let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
6551 assert_eq!(
6552 parsed.get("query").and_then(|v| v.as_str()),
6553 Some("donkey gestation period")
6554 );
6555 }
6556
6557 #[test]
6558 fn streamed_websearch_args_strip_arg_key_value_wrappers() {
6559 let parsed = parse_streamed_tool_args(
6560 "websearch",
6561 "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
6562 );
6563 assert_eq!(
6564 parsed.get("query").and_then(|v| v.as_str()),
6565 Some("taj card what is it benefits how to apply")
6566 );
6567 }
6568
6569 #[test]
6570 fn normalize_tool_args_websearch_infers_from_user_text() {
6571 let normalized =
6572 normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
6573 assert_eq!(
6574 normalized.args.get("query").and_then(|v| v.as_str()),
6575 Some("meaning of life")
6576 );
6577 assert_eq!(normalized.args_source, "inferred_from_user");
6578 assert_eq!(normalized.args_integrity, "recovered");
6579 }
6580
6581 #[test]
6582 fn normalize_tool_args_websearch_keeps_existing_query() {
6583 let normalized = normalize_tool_args(
6584 "websearch",
6585 json!({"query":"already set"}),
6586 "web search should not override",
6587 "",
6588 );
6589 assert_eq!(
6590 normalized.args.get("query").and_then(|v| v.as_str()),
6591 Some("already set")
6592 );
6593 assert_eq!(normalized.args_source, "provider_json");
6594 assert_eq!(normalized.args_integrity, "ok");
6595 }
6596
6597 #[test]
6598 fn normalize_tool_args_websearch_fails_when_unrecoverable() {
6599 let normalized = normalize_tool_args("websearch", json!({}), "search", "");
6600 assert!(normalized.query.is_none());
6601 assert!(normalized.missing_terminal);
6602 assert_eq!(normalized.args_source, "missing");
6603 assert_eq!(normalized.args_integrity, "empty");
6604 }
6605
6606 #[test]
6607 fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
6608 let normalized = normalize_tool_args(
6609 "webfetch",
6610 json!({}),
6611 "Please fetch `https://tandem.frumu.ai/docs/` in markdown mode",
6612 "",
6613 );
6614 assert!(!normalized.missing_terminal);
6615 assert_eq!(
6616 normalized.args.get("url").and_then(|v| v.as_str()),
6617 Some("https://tandem.frumu.ai/docs/")
6618 );
6619 assert_eq!(normalized.args_source, "inferred_from_user");
6620 assert_eq!(normalized.args_integrity, "recovered");
6621 }
6622
6623 #[test]
6624 fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
6625 let normalized = normalize_tool_args(
6626 "webfetch",
6627 json!({"args":{"uri":"https://example.com/page"}}),
6628 "",
6629 "",
6630 );
6631 assert!(!normalized.missing_terminal);
6632 assert_eq!(
6633 normalized.args.get("url").and_then(|v| v.as_str()),
6634 Some("https://example.com/page")
6635 );
6636 assert_eq!(normalized.args_source, "provider_json");
6637 }
6638
6639 #[test]
6640 fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
6641 let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
6642 assert!(normalized.missing_terminal);
6643 assert_eq!(
6644 normalized.missing_terminal_reason.as_deref(),
6645 Some("WEBFETCH_URL_MISSING")
6646 );
6647 }
6648
6649 #[test]
6650 fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
6651 let user_text =
6652 "Create a pack that checks latest headline news every day at 8 AM and emails me.";
6653 let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
6654 assert!(!normalized.missing_terminal);
6655 assert_eq!(
6656 normalized.args.get("goal").and_then(|v| v.as_str()),
6657 Some(user_text)
6658 );
6659 assert_eq!(
6660 normalized.args.get("mode").and_then(|v| v.as_str()),
6661 Some("preview")
6662 );
6663 assert_eq!(normalized.args_source, "inferred_from_user");
6664 assert_eq!(normalized.args_integrity, "recovered");
6665 }
6666
6667 #[test]
6668 fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
6669 let normalized = normalize_tool_args(
6670 "pack_builder",
6671 json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
6672 "new goal should not override",
6673 "",
6674 );
6675 assert!(!normalized.missing_terminal);
6676 assert_eq!(
6677 normalized.args.get("goal").and_then(|v| v.as_str()),
6678 Some("existing goal")
6679 );
6680 assert_eq!(
6681 normalized.args.get("mode").and_then(|v| v.as_str()),
6682 Some("apply")
6683 );
6684 assert_eq!(normalized.args_source, "provider_json");
6685 assert_eq!(normalized.args_integrity, "ok");
6686 }
6687
6688 #[test]
6689 fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
6690 let assistant_context =
6691 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
6692 let normalized =
6693 normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
6694 assert!(!normalized.missing_terminal);
6695 assert_eq!(
6696 normalized.args.get("mode").and_then(|v| v.as_str()),
6697 Some("apply")
6698 );
6699 assert_eq!(
6700 normalized.args.get("plan_id").and_then(|v| v.as_str()),
6701 Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
6702 );
6703 assert_eq!(
6704 normalized
6705 .args
6706 .get("approve_pack_install")
6707 .and_then(|v| v.as_bool()),
6708 Some(true)
6709 );
6710 assert_eq!(normalized.args_source, "recovered_from_context");
6711 }
6712
6713 #[test]
6714 fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
6715 let assistant_context =
6716 "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
6717 let normalized = normalize_tool_args(
6718 "pack_builder",
6719 json!({"mode":"apply"}),
6720 "yes",
6721 assistant_context,
6722 );
6723 assert!(!normalized.missing_terminal);
6724 assert_eq!(
6725 normalized.args.get("mode").and_then(|v| v.as_str()),
6726 Some("apply")
6727 );
6728 assert_eq!(
6729 normalized.args.get("plan_id").and_then(|v| v.as_str()),
6730 Some("plan-11111111-2222-3333-4444-555555555555")
6731 );
6732 }
6733
6734 #[test]
6735 fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
6736 let assistant_context =
6737 "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
6738 let normalized = normalize_tool_args(
6739 "pack_builder",
6740 json!({}),
6741 "create jira sync",
6742 assistant_context,
6743 );
6744 assert!(!normalized.missing_terminal);
6745 assert_eq!(
6746 normalized.args.get("mode").and_then(|v| v.as_str()),
6747 Some("preview")
6748 );
6749 assert_eq!(
6750 normalized.args.get("goal").and_then(|v| v.as_str()),
6751 Some("create jira sync")
6752 );
6753 }
6754
6755 #[test]
6756 fn normalize_tool_args_write_requires_path() {
6757 let normalized = normalize_tool_args("write", json!({}), "", "");
6758 assert!(normalized.missing_terminal);
6759 assert_eq!(
6760 normalized.missing_terminal_reason.as_deref(),
6761 Some("FILE_PATH_MISSING")
6762 );
6763 }
6764
6765 #[test]
6766 fn normalize_tool_args_write_recovers_alias_path_key() {
6767 let normalized = normalize_tool_args(
6768 "write",
6769 json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
6770 "",
6771 "",
6772 );
6773 assert!(!normalized.missing_terminal);
6774 assert_eq!(
6775 normalized.args.get("path").and_then(|v| v.as_str()),
6776 Some("docs/CONCEPT.md")
6777 );
6778 assert_eq!(
6779 normalized.args.get("content").and_then(|v| v.as_str()),
6780 Some("hello")
6781 );
6782 }
6783
6784 #[test]
6785 fn normalize_tool_args_write_recovers_html_output_target_path() {
6786 let normalized = normalize_tool_args_with_mode(
6787 "write",
6788 json!({"content":"<html></html>"}),
6789 "Execute task.\n\nRequired output target:\n{\n \"path\": \"game.html\",\n \"kind\": \"source\",\n \"operation\": \"create_or_update\"\n}\n",
6790 "",
6791 WritePathRecoveryMode::OutputTargetOnly,
6792 );
6793 assert!(!normalized.missing_terminal);
6794 assert_eq!(
6795 normalized.args.get("path").and_then(|v| v.as_str()),
6796 Some("game.html")
6797 );
6798 }
6799
6800 #[test]
6801 fn normalize_tool_args_read_infers_path_from_user_prompt() {
6802 let normalized = normalize_tool_args(
6803 "read",
6804 json!({}),
6805 "Please inspect `FEATURE_LIST.md` and summarize key sections.",
6806 "",
6807 );
6808 assert!(!normalized.missing_terminal);
6809 assert_eq!(
6810 normalized.args.get("path").and_then(|v| v.as_str()),
6811 Some("FEATURE_LIST.md")
6812 );
6813 assert_eq!(normalized.args_source, "inferred_from_user");
6814 assert_eq!(normalized.args_integrity, "recovered");
6815 }
6816
6817 #[test]
6818 fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
6819 let normalized = normalize_tool_args(
6820 "read",
6821 json!({}),
6822 "generic instruction",
6823 "I will read src-tauri/src/orchestrator/engine.rs first.",
6824 );
6825 assert!(normalized.missing_terminal);
6826 assert_eq!(
6827 normalized.missing_terminal_reason.as_deref(),
6828 Some("FILE_PATH_MISSING")
6829 );
6830 }
6831
6832 #[test]
6833 fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
6834 let normalized = normalize_tool_args(
6835 "write",
6836 json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
6837 "",
6838 "",
6839 );
6840 assert!(!normalized.missing_terminal);
6841 assert_eq!(
6842 normalized.args.get("path").and_then(|v| v.as_str()),
6843 Some("docs/CONCEPT.md")
6844 );
6845 }
6846
6847 #[test]
6848 fn normalize_tool_args_write_recovers_content_alias() {
6849 let normalized = normalize_tool_args(
6850 "write",
6851 json!({"path":"docs/FEATURES.md","body":"feature notes"}),
6852 "",
6853 "",
6854 );
6855 assert!(!normalized.missing_terminal);
6856 assert_eq!(
6857 normalized.args.get("content").and_then(|v| v.as_str()),
6858 Some("feature notes")
6859 );
6860 }
6861
6862 #[test]
6863 fn normalize_tool_args_write_fails_when_content_missing() {
6864 let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
6865 assert!(normalized.missing_terminal);
6866 assert_eq!(
6867 normalized.missing_terminal_reason.as_deref(),
6868 Some("WRITE_CONTENT_MISSING")
6869 );
6870 }
6871
6872 #[test]
6873 fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
6874 let normalized = normalize_tool_args_with_mode(
6875 "write",
6876 json!({}),
6877 "Please implement the screen/state structure in the workspace.",
6878 "",
6879 WritePathRecoveryMode::OutputTargetOnly,
6880 );
6881 assert!(normalized.missing_terminal);
6882 assert_eq!(
6883 normalized.missing_terminal_reason.as_deref(),
6884 Some("FILE_PATH_MISSING")
6885 );
6886 }
6887
6888 #[test]
6889 fn normalize_tool_args_write_recovers_content_from_assistant_context() {
6890 let normalized = normalize_tool_args(
6891 "write",
6892 json!({"path":"docs/FEATURES.md"}),
6893 "",
6894 "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
6895 );
6896 assert!(!normalized.missing_terminal);
6897 assert_eq!(
6898 normalized.args.get("path").and_then(|v| v.as_str()),
6899 Some("docs/FEATURES.md")
6900 );
6901 assert_eq!(
6902 normalized.args.get("content").and_then(|v| v.as_str()),
6903 Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
6904 );
6905 assert_eq!(normalized.args_source, "recovered_from_context");
6906 assert_eq!(normalized.args_integrity, "recovered");
6907 }
6908
6909 #[test]
6910 fn normalize_tool_args_write_recovers_raw_nested_string_content() {
6911 let normalized = normalize_tool_args(
6912 "write",
6913 json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
6914 "",
6915 "",
6916 );
6917 assert!(!normalized.missing_terminal);
6918 assert_eq!(
6919 normalized.args.get("path").and_then(|v| v.as_str()),
6920 Some("docs/FEATURES.md")
6921 );
6922 assert_eq!(
6923 normalized.args.get("content").and_then(|v| v.as_str()),
6924 Some("Line 1\nLine 2")
6925 );
6926 }
6927
6928 #[test]
6929 fn normalize_tool_args_write_does_not_treat_path_as_content() {
6930 let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
6931 assert!(normalized.missing_terminal);
6932 assert_eq!(
6933 normalized.missing_terminal_reason.as_deref(),
6934 Some("WRITE_CONTENT_MISSING")
6935 );
6936 }
6937
6938 #[test]
6939 fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
6940 let normalized = normalize_tool_args(
6941 "mcp.composio_1.gmail_send_email",
6942 json!({
6943 "to": "evan@example.com",
6944 "subject": "Test",
6945 "body": "Hello",
6946 "attachment": {
6947 "s3key": ""
6948 }
6949 }),
6950 "",
6951 "",
6952 );
6953 assert!(normalized.args.get("attachment").is_none());
6954 assert_eq!(normalized.args_source, "sanitized_attachment");
6955 }
6956
6957 #[test]
6958 fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
6959 let normalized = normalize_tool_args(
6960 "mcp.composio_1.gmail_send_email",
6961 json!({
6962 "to": "evan@example.com",
6963 "subject": "Test",
6964 "body": "Hello",
6965 "attachment": {
6966 "s3key": "file_123"
6967 }
6968 }),
6969 "",
6970 "",
6971 );
6972 assert_eq!(
6973 normalized
6974 .args
6975 .get("attachment")
6976 .and_then(|value| value.get("s3key"))
6977 .and_then(|value| value.as_str()),
6978 Some("file_123")
6979 );
6980 }
6981
6982 #[test]
6983 fn classify_required_tool_failure_detects_empty_provider_write_args() {
6984 let reason = classify_required_tool_failure(
6985 &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
6986 true,
6987 1,
6988 false,
6989 false,
6990 );
6991 assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
6992 }
6993
6994 #[test]
6995 fn normalize_tool_args_read_infers_path_from_bold_markdown() {
6996 let normalized = normalize_tool_args(
6997 "read",
6998 json!({}),
6999 "Please read **FEATURE_LIST.md** and summarize.",
7000 "",
7001 );
7002 assert!(!normalized.missing_terminal);
7003 assert_eq!(
7004 normalized.args.get("path").and_then(|v| v.as_str()),
7005 Some("FEATURE_LIST.md")
7006 );
7007 }
7008
7009 #[test]
7010 fn normalize_tool_args_shell_infers_command_from_user_prompt() {
7011 let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
7012 assert!(!normalized.missing_terminal);
7013 assert_eq!(
7014 normalized.args.get("command").and_then(|v| v.as_str()),
7015 Some("rg -n \"TODO\" .")
7016 );
7017 assert_eq!(normalized.args_source, "inferred_from_user");
7018 assert_eq!(normalized.args_integrity, "recovered");
7019 }
7020
7021 #[test]
7022 fn normalize_tool_args_read_rejects_root_only_path() {
7023 let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
7024 assert!(normalized.missing_terminal);
7025 assert_eq!(
7026 normalized.missing_terminal_reason.as_deref(),
7027 Some("FILE_PATH_MISSING")
7028 );
7029 }
7030
7031 #[test]
7032 fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
7033 let normalized =
7034 normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
7035 assert!(!normalized.missing_terminal);
7036 assert_eq!(
7037 normalized.args.get("path").and_then(|v| v.as_str()),
7038 Some("CONCEPT.md")
7039 );
7040 assert_eq!(normalized.args_source, "inferred_from_user");
7041 assert_eq!(normalized.args_integrity, "recovered");
7042 }
7043
7044 #[test]
7045 fn normalize_tool_args_read_rejects_tool_call_markup_path() {
7046 let normalized = normalize_tool_args(
7047 "read",
7048 json!({
7049 "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
7050 }),
7051 "",
7052 "",
7053 );
7054 assert!(normalized.missing_terminal);
7055 assert_eq!(
7056 normalized.missing_terminal_reason.as_deref(),
7057 Some("FILE_PATH_MISSING")
7058 );
7059 }
7060
7061 #[test]
7062 fn normalize_tool_args_read_rejects_glob_pattern_path() {
7063 let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
7064 assert!(normalized.missing_terminal);
7065 assert_eq!(
7066 normalized.missing_terminal_reason.as_deref(),
7067 Some("FILE_PATH_MISSING")
7068 );
7069 }
7070
7071 #[test]
7072 fn normalize_tool_args_read_rejects_placeholder_path() {
7073 let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
7074 assert!(normalized.missing_terminal);
7075 assert_eq!(
7076 normalized.missing_terminal_reason.as_deref(),
7077 Some("FILE_PATH_MISSING")
7078 );
7079 }
7080
7081 #[test]
7082 fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
7083 let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
7084 assert!(normalized.missing_terminal);
7085 assert_eq!(
7086 normalized.missing_terminal_reason.as_deref(),
7087 Some("FILE_PATH_MISSING")
7088 );
7089 }
7090
7091 #[test]
7092 fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
7093 let normalized = normalize_tool_args(
7094 "read",
7095 json!({"path":"tool/policy"}),
7096 "Read `T1011U kitöltési útmutató.pdf` and summarize.",
7097 "",
7098 );
7099 assert!(!normalized.missing_terminal);
7100 assert_eq!(
7101 normalized.args.get("path").and_then(|v| v.as_str()),
7102 Some("T1011U kitöltési útmutató.pdf")
7103 );
7104 assert_eq!(normalized.args_source, "inferred_from_user");
7105 assert_eq!(normalized.args_integrity, "recovered");
7106 }
7107
7108 #[test]
7109 fn normalize_tool_name_strips_default_api_namespace() {
7110 assert_eq!(normalize_tool_name("default_api:read"), "read");
7111 assert_eq!(normalize_tool_name("functions.shell"), "bash");
7112 }
7113
7114 #[test]
7115 fn mcp_server_from_tool_name_parses_server_segment() {
7116 assert_eq!(
7117 mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
7118 Some("arcade")
7119 );
7120 assert_eq!(mcp_server_from_tool_name("read"), None);
7121 assert_eq!(mcp_server_from_tool_name("mcp"), None);
7122 }
7123
7124 #[test]
7125 fn batch_helpers_use_name_when_tool_is_wrapper() {
7126 let args = json!({
7127 "tool_calls":[
7128 {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
7129 {"tool":"default_api:glob","args":{"pattern":"*.md"}}
7130 ]
7131 });
7132 let calls = extract_batch_calls(&args);
7133 assert_eq!(calls.len(), 2);
7134 assert_eq!(calls[0].0, "read");
7135 assert_eq!(calls[1].0, "glob");
7136 assert!(is_read_only_batch_call(&args));
7137 let sig = batch_tool_signature(&args).unwrap_or_default();
7138 assert!(sig.contains("read:"));
7139 assert!(sig.contains("glob:"));
7140 }
7141
7142 #[test]
7143 fn batch_helpers_resolve_nested_function_name() {
7144 let args = json!({
7145 "tool_calls":[
7146 {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
7147 ]
7148 });
7149 let calls = extract_batch_calls(&args);
7150 assert_eq!(calls.len(), 1);
7151 assert_eq!(calls[0].0, "read");
7152 assert!(is_read_only_batch_call(&args));
7153 }
7154
7155 #[test]
7156 fn batch_output_classifier_detects_non_productive_unknown_results() {
7157 let output = r#"
7158[
7159 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
7160 {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
7161]
7162"#;
7163 assert!(is_non_productive_batch_output(output));
7164 }
7165
7166 #[test]
7167 fn runtime_prompt_includes_execution_environment_block() {
7168 let prompt = tandem_runtime_system_prompt(
7169 &HostRuntimeContext {
7170 os: HostOs::Windows,
7171 arch: "x86_64".to_string(),
7172 shell_family: ShellFamily::Powershell,
7173 path_style: PathStyle::Windows,
7174 },
7175 &[],
7176 );
7177 assert!(prompt.contains("[Execution Environment]"));
7178 assert!(prompt.contains("Host OS: windows"));
7179 assert!(prompt.contains("Shell: powershell"));
7180 assert!(prompt.contains("Path style: windows"));
7181 }
7182
7183 #[test]
7184 fn runtime_prompt_includes_connected_integrations_block() {
7185 let prompt = tandem_runtime_system_prompt(
7186 &HostRuntimeContext {
7187 os: HostOs::Linux,
7188 arch: "x86_64".to_string(),
7189 shell_family: ShellFamily::Posix,
7190 path_style: PathStyle::Posix,
7191 },
7192 &["notion".to_string(), "github".to_string()],
7193 );
7194 assert!(prompt.contains("[Connected Integrations]"));
7195 assert!(prompt.contains("- notion"));
7196 assert!(prompt.contains("- github"));
7197 }
7198
7199 #[test]
7200 fn detects_web_research_prompt_keywords() {
7201 assert!(requires_web_research_prompt(
7202 "research todays top news stories and include links"
7203 ));
7204 assert!(!requires_web_research_prompt(
7205 "say hello and summarize this text"
7206 ));
7207 }
7208
7209 #[test]
7210 fn detects_email_delivery_prompt_keywords() {
7211 assert!(requires_email_delivery_prompt(
7212 "send a full report with links to evan@example.com"
7213 ));
7214 assert!(!requires_email_delivery_prompt("draft a summary for later"));
7215 }
7216
7217 #[test]
7218 fn completion_claim_detector_flags_sent_language() {
7219 assert!(completion_claims_email_sent(
7220 "Email Status: Sent to evan@example.com."
7221 ));
7222 assert!(!completion_claims_email_sent(
7223 "I could not send email in this run."
7224 ));
7225 }
7226
7227 #[test]
7228 fn email_tool_detector_finds_mcp_gmail_tools() {
7229 let schemas = vec![
7230 ToolSchema {
7231 name: "read".to_string(),
7232 description: String::new(),
7233 input_schema: json!({}),
7234 },
7235 ToolSchema {
7236 name: "mcp.composio.gmail_send_email".to_string(),
7237 description: String::new(),
7238 input_schema: json!({}),
7239 },
7240 ];
7241 assert!(has_email_action_tools(&schemas));
7242 }
7243
7244 #[test]
7245 fn extract_mcp_auth_required_metadata_parses_expected_shape() {
7246 let metadata = json!({
7247 "server": "arcade",
7248 "mcpAuth": {
7249 "required": true,
7250 "challengeId": "abc123",
7251 "authorizationUrl": "https://example.com/oauth",
7252 "message": "Authorize first",
7253 "pending": true,
7254 "blocked": true,
7255 "retryAfterMs": 8000
7256 }
7257 });
7258 let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
7259 assert_eq!(parsed.challenge_id, "abc123");
7260 assert_eq!(parsed.authorization_url, "https://example.com/oauth");
7261 assert_eq!(parsed.message, "Authorize first");
7262 assert_eq!(parsed.server.as_deref(), Some("arcade"));
7263 assert!(parsed.pending);
7264 assert!(parsed.blocked);
7265 assert_eq!(parsed.retry_after_ms, Some(8000));
7266 }
7267
7268 #[test]
7269 fn auth_required_output_detector_matches_auth_text() {
7270 assert!(is_auth_required_tool_output(
7271 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
7272 ));
7273 assert!(is_auth_required_tool_output(
7274 "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
7275 ));
7276 assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
7277 }
7278
7279 #[test]
7280 fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
7281 assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
7282 assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
7283 assert!(!is_productive_tool_output(
7284 "write",
7285 "Tool `write` result:\nWRITE_CONTENT_MISSING"
7286 ));
7287 assert!(!is_productive_tool_output(
7288 "edit",
7289 "Tool `edit` result:\nFILE_PATH_MISSING"
7290 ));
7291 assert!(!is_productive_tool_output(
7292 "write",
7293 "Tool `write` result:\ninvalid_function_parameters"
7294 ));
7295 }
7296
7297 #[test]
7298 fn productive_tool_output_detector_accepts_real_tool_results() {
7299 assert!(is_productive_tool_output(
7300 "write",
7301 "Tool `write` result:\nWrote /tmp/probe.html"
7302 ));
7303 assert!(!is_productive_tool_output(
7304 "write",
7305 "Authorization required for `write`.\nAuthorize here: https://example.com"
7306 ));
7307 }
7308
7309 #[test]
7310 fn guard_budget_output_detector_matches_expected_text() {
7311 assert!(is_guard_budget_tool_output(
7312 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
7313 ));
7314 assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
7315 }
7316
7317 #[test]
7318 fn summarize_guard_budget_outputs_returns_run_scoped_message() {
7319 let outputs = vec![
7320 "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
7321 .to_string(),
7322 "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
7323 .to_string(),
7324 ];
7325 let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
7326 assert!(summary.contains("per-run tool guard budget"));
7327 assert!(summary.contains("fresh run"));
7328 }
7329
7330 #[test]
7331 fn duplicate_signature_output_detector_matches_expected_text() {
7332 assert!(is_duplicate_signature_limit_output(
7333 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
7334 ));
7335 assert!(!is_duplicate_signature_limit_output(
7336 "Tool `read` result: ok"
7337 ));
7338 }
7339
7340 #[test]
7341 fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
7342 let outputs = vec![
7343 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
7344 .to_string(),
7345 "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
7346 .to_string(),
7347 ];
7348 let summary =
7349 summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
7350 assert!(summary.contains("same tool call kept repeating"));
7351 assert!(summary.contains("clearer command target"));
7352 }
7353
7354 #[test]
7355 fn required_tool_mode_unsatisfied_completion_includes_marker() {
7356 let message =
7357 required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
7358 assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
7359 assert!(message.contains("NO_TOOL_CALL_EMITTED"));
7360 assert!(message.contains("tool_mode=required"));
7361 }
7362
7363 #[test]
7364 fn required_tool_retry_context_mentions_offered_tools() {
7365 let prompt = build_required_tool_retry_context(
7366 "read, write, apply_patch",
7367 RequiredToolFailureKind::ToolCallInvalidArgs,
7368 );
7369 assert!(prompt.contains("Tool access is mandatory"));
7370 assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
7371 assert!(prompt.contains("full `content`"));
7372 assert!(prompt.contains("write, edit, or apply_patch"));
7373 }
7374
7375 #[test]
7376 fn required_tool_retry_context_requires_write_after_read_only_pass() {
7377 let prompt = build_required_tool_retry_context(
7378 "glob, read, write, edit, apply_patch",
7379 RequiredToolFailureKind::WriteRequiredNotSatisfied,
7380 );
7381 assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
7382 assert!(prompt.contains("Inspection is complete"));
7383 assert!(prompt.contains("write, edit, or apply_patch"));
7384 }
7385
7386 #[test]
7387 fn classify_required_tool_failure_detects_invalid_args() {
7388 let reason = classify_required_tool_failure(
7389 &[String::from("WRITE_CONTENT_MISSING")],
7390 true,
7391 1,
7392 false,
7393 false,
7394 );
7395 assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
7396 }
7397
7398 #[test]
7399 fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
7400 assert!(looks_like_unparsed_tool_payload(
7401 r#"{"content":[{"type":"tool_call","name":"write"}]}"#
7402 ));
7403 assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
7404 }
7405
7406 #[test]
7407 fn workspace_write_tool_detection_is_limited_to_mutations() {
7408 assert!(is_workspace_write_tool("write"));
7409 assert!(is_workspace_write_tool("edit"));
7410 assert!(is_workspace_write_tool("apply_patch"));
7411 assert!(!is_workspace_write_tool("read"));
7412 assert!(!is_workspace_write_tool("glob"));
7413 }
7414
7415 #[test]
7416 fn infer_required_output_target_path_reads_prompt_json_block() {
7417 let prompt = r#"Execute task.
7418
7419Required output target:
7420{
7421 "path": "src/game.html",
7422 "kind": "source",
7423 "operation": "create"
7424}
7425"#;
7426 assert_eq!(
7427 infer_required_output_target_path_from_text(prompt).as_deref(),
7428 Some("src/game.html")
7429 );
7430 }
7431
7432 #[test]
7433 fn infer_required_output_target_path_accepts_extensionless_target() {
7434 let prompt = r#"Execute task.
7435
7436Required output target:
7437{
7438 "path": "Dockerfile",
7439 "kind": "source",
7440 "operation": "create"
7441}
7442"#;
7443 assert_eq!(
7444 infer_required_output_target_path_from_text(prompt).as_deref(),
7445 Some("Dockerfile")
7446 );
7447 }
7448
7449 #[test]
7450 fn infer_write_file_path_from_text_rejects_workspace_root() {
7451 let prompt = "Workspace: /home/evan/game\nCreate the scaffold in the workspace now.";
7452 assert_eq!(infer_write_file_path_from_text(prompt), None);
7453 }
7454
7455 #[test]
7456 fn duplicate_signature_limit_defaults_to_200_for_all_tools() {
7457 let _guard = env_test_lock();
7458 unsafe {
7459 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
7460 }
7461 assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
7462 assert_eq!(duplicate_signature_limit_for("bash"), 200);
7463 assert_eq!(duplicate_signature_limit_for("write"), 200);
7464 }
7465
7466 #[test]
7467 fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
7468 let parsed = parse_streamed_tool_args("write", "path=game.html content");
7469 assert_ne!(parsed, json!({}));
7470 }
7471
7472 #[test]
7473 fn parse_streamed_tool_args_preserves_large_write_payload() {
7474 let content = "x".repeat(4096);
7475 let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
7476 let parsed = parse_streamed_tool_args("write", &raw_args);
7477 assert_eq!(
7478 parsed.get("path").and_then(|value| value.as_str()),
7479 Some("game.html")
7480 );
7481 assert_eq!(
7482 parsed.get("content").and_then(|value| value.as_str()),
7483 Some(content.as_str())
7484 );
7485 }
7486
7487 #[test]
7488 fn parse_streamed_tool_args_recovers_truncated_write_json() {
7489 let raw_args = concat!(
7490 r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
7491 r#"<html lang=\"en\"><body>Neon Drift"#
7492 );
7493 let parsed = parse_streamed_tool_args("write", raw_args);
7494 assert_eq!(
7495 parsed,
7496 json!({
7497 "path": "game.html",
7498 "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
7499 })
7500 );
7501 }
7502
7503 #[test]
7504 fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
7505 let raw_args = concat!(
7506 r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
7507 r#"<html lang=\"en\"><body>Neon Drift"#
7508 );
7509 let parsed = parse_streamed_tool_args("write", raw_args);
7510 assert_eq!(parsed.get("path"), None);
7511 assert_eq!(
7512 parsed.get("content").and_then(|value| value.as_str()),
7513 Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
7514 );
7515 }
7516
7517 #[test]
7518 fn duplicate_signature_limit_env_override_respects_minimum_floor() {
7519 let _guard = env_test_lock();
7520 unsafe {
7521 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
7522 }
7523 assert_eq!(duplicate_signature_limit_for("write"), 200);
7524 assert_eq!(duplicate_signature_limit_for("bash"), 200);
7525 unsafe {
7526 std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
7527 }
7528 assert_eq!(duplicate_signature_limit_for("bash"), 250);
7529 unsafe {
7530 std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
7531 }
7532 }
7533
7534 #[test]
7535 fn websearch_duplicate_signature_limit_is_unset_by_default() {
7536 let _guard = env_test_lock();
7537 unsafe {
7538 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
7539 }
7540 assert_eq!(websearch_duplicate_signature_limit(), None);
7541 }
7542
7543 #[test]
7544 fn websearch_duplicate_signature_limit_reads_env() {
7545 let _guard = env_test_lock();
7546 unsafe {
7547 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
7548 }
7549 assert_eq!(websearch_duplicate_signature_limit(), Some(200));
7550 unsafe {
7551 std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
7552 }
7553 assert_eq!(websearch_duplicate_signature_limit(), Some(300));
7554 unsafe {
7555 std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
7556 }
7557 }
7558
7559 #[test]
7560 fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
7561 let outputs = vec![
7562 "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
7563 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
7564 ];
7565 let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
7566 assert!(summary.contains("Authorization is required before I can continue"));
7567 assert!(summary.contains("gmail_sendemail"));
7568 assert!(summary.contains("gmail_whoami"));
7569 }
7570
7571 #[test]
7572 fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
7573 let outputs = vec![
7574 "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
7575 "Tool `read` result:\nok".to_string(),
7576 ];
7577 assert!(summarize_auth_pending_outputs(&outputs).is_none());
7578 }
7579
7580 #[test]
7581 fn invalid_tool_args_retry_context_handles_missing_bash_command() {
7582 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
7583 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
7584 .expect("retry expected");
7585 assert!(message.contains("required `command` field"));
7586 assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
7587 }
7588
7589 #[test]
7590 fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
7591 let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
7592 let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
7593 .expect("retry expected");
7594 assert!(message.contains("Do not repeat an empty bash call"));
7595 }
7596
7597 #[test]
7598 fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
7599 let outputs = vec!["Tool `read` result:\nok".to_string()];
7600 assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
7601 }
7602
7603 #[test]
7604 fn parse_budget_override_zero_disables_budget() {
7605 unsafe {
7606 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
7607 }
7608 assert_eq!(
7609 parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
7610 Some(usize::MAX)
7611 );
7612 unsafe {
7613 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
7614 }
7615 }
7616
7617 #[test]
7618 fn disable_tool_guard_budgets_env_overrides_all_budgets() {
7619 unsafe {
7620 std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
7621 }
7622 assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
7623 assert_eq!(tool_budget_for("websearch"), usize::MAX);
7624 unsafe {
7625 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
7626 }
7627 }
7628
7629 #[test]
7630 fn tool_budget_defaults_to_200_calls() {
7631 let _guard = env_test_lock();
7632 unsafe {
7633 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
7634 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
7635 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
7636 std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
7637 }
7638 assert_eq!(tool_budget_for("bash"), 200);
7639 assert_eq!(tool_budget_for("websearch"), 200);
7640 assert_eq!(tool_budget_for("read"), 200);
7641 }
7642
7643 #[test]
7644 fn tool_budget_env_override_respects_minimum_floor() {
7645 let _guard = env_test_lock();
7646 unsafe {
7647 std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
7648 std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
7649 std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
7650 }
7651 assert_eq!(tool_budget_for("bash"), 200);
7652 assert_eq!(tool_budget_for("websearch"), 250);
7653 unsafe {
7654 std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
7655 std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
7656 }
7657 }
7658}