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