Skip to main content

tandem_core/
engine_loop.rs

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