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