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, PrewriteCoverageMode, PrewriteRequirements,
15    SendMessageRequest, SharedToolProgressSink, ShellFamily, ToolMode, ToolProgressEvent,
16    ToolProgressSink, ToolSchema,
17};
18use tandem_wire::WireMessagePart;
19use tokio_util::sync::CancellationToken;
20use tracing::Level;
21
22mod loop_guards;
23mod prewrite_gate;
24
25use loop_guards::{
26    duplicate_signature_limit_for, tool_budget_for, websearch_duplicate_signature_limit,
27};
28#[cfg(test)]
29use loop_guards::{parse_budget_override, HARD_TOOL_CALL_CEILING};
30use prewrite_gate::{
31    describe_unmet_prewrite_requirements_for_prompt, evaluate_prewrite_gate, PrewriteProgress,
32};
33
34use crate::tool_router::{
35    classify_intent, default_mode_name, is_short_simple_prompt, max_tools_per_call_expanded,
36    select_tool_subset, should_escalate_auto_tools, tool_router_enabled, ToolIntent,
37    ToolRoutingDecision,
38};
39use crate::{
40    any_policy_matches, derive_session_title_from_prompt, title_needs_repair,
41    tool_name_matches_policy, AgentDefinition, AgentRegistry, CancellationRegistry, EventBus,
42    PermissionAction, PermissionManager, PluginRegistry, Storage,
43};
44use crate::{
45    build_tool_effect_ledger_record, finalize_mutation_checkpoint_record,
46    mutation_checkpoint_event, prepare_mutation_checkpoint, tool_effect_ledger_event,
47    MutationCheckpointOutcome, ToolEffectLedgerPhase, ToolEffectLedgerStatus,
48};
49use tokio::sync::RwLock;
50
51#[derive(Default)]
52struct StreamedToolCall {
53    name: String,
54    args: String,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum RawToolArgsState {
59    Present,
60    Empty,
61    Unparseable,
62}
63
64impl RawToolArgsState {
65    fn as_str(self) -> &'static str {
66        match self {
67            Self::Present => "present",
68            Self::Empty => "empty",
69            Self::Unparseable => "unparseable",
70        }
71    }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75enum WritePathRecoveryMode {
76    Heuristic,
77    OutputTargetOnly,
78}
79
80#[derive(Debug, Clone)]
81pub struct SpawnAgentToolContext {
82    pub session_id: String,
83    pub message_id: String,
84    pub tool_call_id: Option<String>,
85    pub args: Value,
86}
87
88#[derive(Debug, Clone)]
89pub struct SpawnAgentToolResult {
90    pub output: String,
91    pub metadata: Value,
92}
93
94#[derive(Debug, Clone)]
95pub struct ToolPolicyContext {
96    pub session_id: String,
97    pub message_id: String,
98    pub tool: String,
99    pub args: Value,
100}
101
102#[derive(Debug, Clone)]
103pub struct ToolPolicyDecision {
104    pub allowed: bool,
105    pub reason: Option<String>,
106}
107
108#[derive(Clone)]
109struct EngineToolProgressSink {
110    event_bus: EventBus,
111    session_id: String,
112    message_id: String,
113    tool_call_id: Option<String>,
114    source_tool: String,
115}
116
117impl ToolProgressSink for EngineToolProgressSink {
118    fn publish(&self, event: ToolProgressEvent) {
119        let properties = merge_tool_progress_properties(
120            event.properties,
121            &self.session_id,
122            &self.message_id,
123            self.tool_call_id.as_deref(),
124            &self.source_tool,
125        );
126        self.event_bus
127            .publish(EngineEvent::new(event.event_type, properties));
128    }
129}
130
131fn merge_tool_progress_properties(
132    properties: Value,
133    session_id: &str,
134    message_id: &str,
135    tool_call_id: Option<&str>,
136    source_tool: &str,
137) -> Value {
138    let mut base = Map::new();
139    base.insert(
140        "sessionID".to_string(),
141        Value::String(session_id.to_string()),
142    );
143    base.insert(
144        "messageID".to_string(),
145        Value::String(message_id.to_string()),
146    );
147    base.insert(
148        "sourceTool".to_string(),
149        Value::String(source_tool.to_string()),
150    );
151    if let Some(tool_call_id) = tool_call_id {
152        base.insert(
153            "toolCallID".to_string(),
154            Value::String(tool_call_id.to_string()),
155        );
156    }
157    match properties {
158        Value::Object(mut map) => {
159            for (key, value) in base {
160                map.insert(key, value);
161            }
162            Value::Object(map)
163        }
164        other => {
165            base.insert("data".to_string(), other);
166            Value::Object(base)
167        }
168    }
169}
170
171pub trait SpawnAgentHook: Send + Sync {
172    fn spawn_agent(
173        &self,
174        ctx: SpawnAgentToolContext,
175    ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
176}
177
178pub trait ToolPolicyHook: Send + Sync {
179    fn evaluate_tool(
180        &self,
181        ctx: ToolPolicyContext,
182    ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
183}
184
185#[derive(Debug, Clone)]
186pub struct PromptContextHookContext {
187    pub session_id: String,
188    pub message_id: String,
189    pub provider_id: String,
190    pub model_id: String,
191    pub iteration: usize,
192}
193
194pub trait PromptContextHook: Send + Sync {
195    fn augment_provider_messages(
196        &self,
197        ctx: PromptContextHookContext,
198        messages: Vec<ChatMessage>,
199    ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
200}
201
202#[derive(Clone)]
203pub struct EngineLoop {
204    storage: std::sync::Arc<Storage>,
205    event_bus: EventBus,
206    providers: ProviderRegistry,
207    plugins: PluginRegistry,
208    agents: AgentRegistry,
209    permissions: PermissionManager,
210    tools: ToolRegistry,
211    cancellations: CancellationRegistry,
212    host_runtime_context: HostRuntimeContext,
213    workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
214    session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
215    session_auto_approve_permissions: std::sync::Arc<RwLock<HashMap<String, bool>>>,
216    spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
217    tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
218    prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
219}
220
221impl EngineLoop {
222    #[allow(clippy::too_many_arguments)]
223    pub fn new(
224        storage: std::sync::Arc<Storage>,
225        event_bus: EventBus,
226        providers: ProviderRegistry,
227        plugins: PluginRegistry,
228        agents: AgentRegistry,
229        permissions: PermissionManager,
230        tools: ToolRegistry,
231        cancellations: CancellationRegistry,
232        host_runtime_context: HostRuntimeContext,
233    ) -> Self {
234        Self {
235            storage,
236            event_bus,
237            providers,
238            plugins,
239            agents,
240            permissions,
241            tools,
242            cancellations,
243            host_runtime_context,
244            workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
245            session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
246            session_auto_approve_permissions: std::sync::Arc::new(RwLock::new(HashMap::new())),
247            spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
248            tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
249            prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
250        }
251    }
252
253    pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
254        *self.spawn_agent_hook.write().await = Some(hook);
255    }
256
257    pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
258        *self.tool_policy_hook.write().await = Some(hook);
259    }
260
261    pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
262        *self.prompt_context_hook.write().await = Some(hook);
263    }
264
265    pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
266        let normalized = allowed_tools
267            .into_iter()
268            .map(|tool| normalize_tool_name(&tool))
269            .filter(|tool| !tool.trim().is_empty())
270            .collect::<Vec<_>>();
271        self.session_allowed_tools
272            .write()
273            .await
274            .insert(session_id.to_string(), normalized);
275    }
276
277    pub async fn clear_session_allowed_tools(&self, session_id: &str) {
278        self.session_allowed_tools.write().await.remove(session_id);
279    }
280
281    pub async fn set_session_auto_approve_permissions(&self, session_id: &str, enabled: bool) {
282        if enabled {
283            self.session_auto_approve_permissions
284                .write()
285                .await
286                .insert(session_id.to_string(), true);
287        } else {
288            self.session_auto_approve_permissions
289                .write()
290                .await
291                .remove(session_id);
292        }
293    }
294
295    pub async fn clear_session_auto_approve_permissions(&self, session_id: &str) {
296        self.session_auto_approve_permissions
297            .write()
298            .await
299            .remove(session_id);
300    }
301
302    pub async fn grant_workspace_override_for_session(
303        &self,
304        session_id: &str,
305        ttl_seconds: u64,
306    ) -> u64 {
307        // Cap the override TTL to prevent indefinite sandbox bypass.
308        const MAX_WORKSPACE_OVERRIDE_TTL_SECONDS: u64 = 600; // 10 minutes
309        let capped_ttl = ttl_seconds.min(MAX_WORKSPACE_OVERRIDE_TTL_SECONDS);
310        if capped_ttl < ttl_seconds {
311            tracing::warn!(
312                session_id = %session_id,
313                requested_ttl_s = %ttl_seconds,
314                capped_ttl_s = %capped_ttl,
315                "workspace override TTL capped to maximum allowed value"
316            );
317        }
318        let expires_at = chrono::Utc::now()
319            .timestamp_millis()
320            .max(0)
321            .saturating_add((capped_ttl as i64).saturating_mul(1000))
322            as u64;
323        self.workspace_overrides
324            .write()
325            .await
326            .insert(session_id.to_string(), expires_at);
327        self.event_bus.publish(EngineEvent::new(
328            "workspace.override.activated",
329            json!({
330                "sessionID": session_id,
331                "requestedTtlSeconds": ttl_seconds,
332                "cappedTtlSeconds": capped_ttl,
333                "expiresAt": expires_at,
334            }),
335        ));
336        expires_at
337    }
338
339    pub async fn run_prompt_async(
340        &self,
341        session_id: String,
342        req: SendMessageRequest,
343    ) -> anyhow::Result<()> {
344        self.run_prompt_async_with_context(session_id, req, None)
345            .await
346    }
347
348    pub async fn run_prompt_async_with_context(
349        &self,
350        session_id: String,
351        req: SendMessageRequest,
352        correlation_id: Option<String>,
353    ) -> anyhow::Result<()> {
354        let session_model = self
355            .storage
356            .get_session(&session_id)
357            .await
358            .and_then(|s| s.model);
359        let (provider_id, model_id_value) =
360            resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
361                anyhow::anyhow!(
362                "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
363            )
364            })?;
365        let correlation_ref = correlation_id.as_deref();
366        let model_id = Some(model_id_value.as_str());
367        let cancel = self.cancellations.create(&session_id).await;
368        emit_event(
369            Level::INFO,
370            ProcessKind::Engine,
371            ObservabilityEvent {
372                event: "provider.call.start",
373                component: "engine.loop",
374                correlation_id: correlation_ref,
375                session_id: Some(&session_id),
376                run_id: None,
377                message_id: None,
378                provider_id: Some(provider_id.as_str()),
379                model_id,
380                status: Some("start"),
381                error_code: None,
382                detail: Some("run_prompt_async dispatch"),
383            },
384        );
385        self.event_bus.publish(EngineEvent::new(
386            "session.status",
387            json!({"sessionID": session_id, "status":"running"}),
388        ));
389        let request_parts = req.parts.clone();
390        let requested_tool_mode = req.tool_mode.clone().unwrap_or(ToolMode::Auto);
391        let requested_context_mode = req.context_mode.clone().unwrap_or(ContextMode::Auto);
392        let requested_write_required = req.write_required.unwrap_or(false);
393        let requested_prewrite_requirements = req.prewrite_requirements.clone().unwrap_or_default();
394        let request_tool_allowlist = req
395            .tool_allowlist
396            .clone()
397            .unwrap_or_default()
398            .into_iter()
399            .map(|tool| normalize_tool_name(&tool))
400            .filter(|tool| !tool.trim().is_empty())
401            .collect::<HashSet<_>>();
402        let text = req
403            .parts
404            .iter()
405            .map(|p| match p {
406                MessagePartInput::Text { text } => text.clone(),
407                MessagePartInput::File {
408                    mime,
409                    filename,
410                    url,
411                } => format!(
412                    "[file mime={} name={} url={}]",
413                    mime,
414                    filename.clone().unwrap_or_else(|| "unknown".to_string()),
415                    url
416                ),
417            })
418            .collect::<Vec<_>>()
419            .join("\n");
420        let runtime_attachments = build_runtime_attachments(&provider_id, &request_parts).await;
421        self.auto_rename_session_from_user_text(&session_id, &text)
422            .await;
423        let active_agent = self.agents.get(req.agent.as_deref()).await;
424        let mut user_message_id = self
425            .find_recent_matching_user_message_id(&session_id, &text)
426            .await;
427        if user_message_id.is_none() {
428            let user_message = Message::new(
429                MessageRole::User,
430                vec![MessagePart::Text { text: text.clone() }],
431            );
432            let created_message_id = user_message.id.clone();
433            self.storage
434                .append_message(&session_id, user_message)
435                .await?;
436
437            let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
438            self.event_bus.publish(EngineEvent::new(
439                "message.part.updated",
440                json!({
441                    "part": user_part,
442                    "delta": text,
443                    "agent": active_agent.name
444                }),
445            ));
446            user_message_id = Some(created_message_id);
447        }
448        let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
449
450        if cancel.is_cancelled() {
451            self.event_bus.publish(EngineEvent::new(
452                "session.status",
453                json!({"sessionID": session_id, "status":"cancelled"}),
454            ));
455            self.cancellations.remove(&session_id).await;
456            return Ok(());
457        }
458
459        let mut question_tool_used = false;
460        let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
461            if normalize_tool_name(&tool) == "question" {
462                question_tool_used = true;
463            }
464            if !agent_can_use_tool(&active_agent, &tool) {
465                format!(
466                    "Tool `{tool}` is not enabled for agent `{}`.",
467                    active_agent.name
468                )
469            } else {
470                self.execute_tool_with_permission(
471                    &session_id,
472                    &user_message_id,
473                    tool.clone(),
474                    args,
475                    None,
476                    active_agent.skills.as_deref(),
477                    &text,
478                    requested_write_required,
479                    None,
480                    cancel.clone(),
481                )
482                .await?
483                .unwrap_or_default()
484            }
485        } else {
486            let mut completion = String::new();
487            let mut max_iterations = max_tool_iterations();
488            let mut followup_context: Option<String> = None;
489            let mut last_tool_outputs: Vec<String> = Vec::new();
490            let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
491            let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
492            let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
493            let mut mutable_signature_counts: HashMap<String, usize> = HashMap::new();
494            let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
495            let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
496            let mut websearch_query_blocked = false;
497            let websearch_duplicate_signature_limit = websearch_duplicate_signature_limit();
498            let mut pack_builder_executed = false;
499            let mut auto_workspace_probe_attempted = false;
500            let mut productive_tool_calls_total = 0usize;
501            let mut productive_write_tool_calls_total = 0usize;
502            let mut productive_workspace_inspection_total = 0usize;
503            let mut productive_web_research_total = 0usize;
504            let mut productive_concrete_read_total = 0usize;
505            let mut successful_web_research_total = 0usize;
506            let mut required_tool_retry_count = 0usize;
507            let mut required_write_retry_count = 0usize;
508            let mut unmet_prewrite_repair_retry_count = 0usize;
509            let mut empty_completion_retry_count = 0usize;
510            let mut prewrite_gate_waived = false;
511            let mut invalid_tool_args_retry_count = 0usize;
512            let strict_write_retry_max_attempts = strict_write_retry_max_attempts();
513            let mut required_tool_unsatisfied_emitted = false;
514            let mut latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
515            let email_delivery_requested = requires_email_delivery_prompt(&text);
516            let web_research_requested = requires_web_research_prompt(&text);
517            let code_workflow_requested = infer_code_workflow_from_text(&text);
518            let mut email_action_executed = false;
519            let mut latest_email_action_note: Option<String> = None;
520            let intent = classify_intent(&text);
521            let router_enabled = tool_router_enabled();
522            let retrieval_enabled = semantic_tool_retrieval_enabled();
523            let retrieval_k = semantic_tool_retrieval_k();
524            let mcp_server_names = if mcp_catalog_in_system_prompt_enabled() {
525                self.tools.mcp_server_names().await
526            } else {
527                Vec::new()
528            };
529            let mut auto_tools_escalated = matches!(requested_tool_mode, ToolMode::Required);
530            let context_is_auto_compact = matches!(requested_context_mode, ContextMode::Auto)
531                && runtime_attachments.is_empty()
532                && is_short_simple_prompt(&text)
533                && matches!(intent, ToolIntent::Chitchat | ToolIntent::Knowledge);
534
535            while max_iterations > 0 && !cancel.is_cancelled() {
536                let iteration = 26usize.saturating_sub(max_iterations);
537                max_iterations -= 1;
538                let context_profile = if matches!(requested_context_mode, ContextMode::Full) {
539                    ChatHistoryProfile::Full
540                } else if matches!(requested_context_mode, ContextMode::Compact)
541                    || context_is_auto_compact
542                {
543                    ChatHistoryProfile::Compact
544                } else {
545                    ChatHistoryProfile::Standard
546                };
547                let mut messages =
548                    load_chat_history(self.storage.clone(), &session_id, context_profile).await;
549                if iteration == 1 && !runtime_attachments.is_empty() {
550                    attach_to_last_user_message(&mut messages, &runtime_attachments);
551                }
552                let history_char_count = messages.iter().map(|m| m.content.len()).sum::<usize>();
553                self.event_bus.publish(EngineEvent::new(
554                    "context.profile.selected",
555                    json!({
556                        "sessionID": session_id,
557                        "messageID": user_message_id,
558                        "iteration": iteration,
559                        "contextMode": format_context_mode(&requested_context_mode, context_is_auto_compact),
560                        "historyMessageCount": messages.len(),
561                        "historyCharCount": history_char_count,
562                        "memoryInjected": false
563                    }),
564                ));
565                let mut system_parts = vec![tandem_runtime_system_prompt(
566                    &self.host_runtime_context,
567                    &mcp_server_names,
568                )];
569                if let Some(system) = active_agent.system_prompt.as_ref() {
570                    system_parts.push(system.clone());
571                }
572                messages.insert(
573                    0,
574                    ChatMessage {
575                        role: "system".to_string(),
576                        content: system_parts.join("\n\n"),
577                        attachments: Vec::new(),
578                    },
579                );
580                if let Some(extra) = followup_context.take() {
581                    messages.push(ChatMessage {
582                        role: "user".to_string(),
583                        content: extra,
584                        attachments: Vec::new(),
585                    });
586                }
587                if let Some(hook) = self.prompt_context_hook.read().await.clone() {
588                    let ctx = PromptContextHookContext {
589                        session_id: session_id.clone(),
590                        message_id: user_message_id.clone(),
591                        provider_id: provider_id.clone(),
592                        model_id: model_id_value.clone(),
593                        iteration,
594                    };
595                    let hook_timeout =
596                        Duration::from_millis(prompt_context_hook_timeout_ms() as u64);
597                    match tokio::time::timeout(
598                        hook_timeout,
599                        hook.augment_provider_messages(ctx, messages.clone()),
600                    )
601                    .await
602                    {
603                        Ok(Ok(augmented)) => {
604                            messages = augmented;
605                        }
606                        Ok(Err(err)) => {
607                            self.event_bus.publish(EngineEvent::new(
608                                "memory.context.error",
609                                json!({
610                                    "sessionID": session_id,
611                                    "messageID": user_message_id,
612                                    "iteration": iteration,
613                                    "error": truncate_text(&err.to_string(), 500),
614                                }),
615                            ));
616                        }
617                        Err(_) => {
618                            self.event_bus.publish(EngineEvent::new(
619                                "memory.context.error",
620                                json!({
621                                    "sessionID": session_id,
622                                    "messageID": user_message_id,
623                                    "iteration": iteration,
624                                    "error": format!(
625                                        "prompt context hook timeout after {} ms",
626                                        hook_timeout.as_millis()
627                                    ),
628                                }),
629                            ));
630                        }
631                    }
632                }
633                let all_tools = self.tools.list().await;
634                let mut retrieval_fallback_reason: Option<&'static str> = None;
635                let mut candidate_tools = if retrieval_enabled {
636                    self.tools.retrieve(&text, retrieval_k).await
637                } else {
638                    all_tools.clone()
639                };
640                if retrieval_enabled {
641                    if candidate_tools.is_empty() && !all_tools.is_empty() {
642                        candidate_tools = all_tools.clone();
643                        retrieval_fallback_reason = Some("retrieval_empty_result");
644                    } else if web_research_requested
645                        && has_web_research_tools(&all_tools)
646                        && !has_web_research_tools(&candidate_tools)
647                        && required_write_retry_count == 0
648                    {
649                        candidate_tools = all_tools.clone();
650                        retrieval_fallback_reason = Some("missing_web_tools_for_research_prompt");
651                    } else if email_delivery_requested
652                        && has_email_action_tools(&all_tools)
653                        && !has_email_action_tools(&candidate_tools)
654                    {
655                        candidate_tools = all_tools.clone();
656                        retrieval_fallback_reason = Some("missing_email_tools_for_delivery_prompt");
657                    }
658                }
659                let mut tool_schemas = if !router_enabled {
660                    candidate_tools
661                } else {
662                    match requested_tool_mode {
663                        ToolMode::None => Vec::new(),
664                        ToolMode::Required => select_tool_subset(
665                            candidate_tools,
666                            intent,
667                            &request_tool_allowlist,
668                            iteration > 1,
669                        ),
670                        ToolMode::Auto => {
671                            if !auto_tools_escalated {
672                                Vec::new()
673                            } else {
674                                select_tool_subset(
675                                    candidate_tools,
676                                    intent,
677                                    &request_tool_allowlist,
678                                    iteration > 1,
679                                )
680                            }
681                        }
682                    }
683                };
684                let mut policy_patterns =
685                    request_tool_allowlist.iter().cloned().collect::<Vec<_>>();
686                if let Some(agent_tools) = active_agent.tools.as_ref() {
687                    policy_patterns
688                        .extend(agent_tools.iter().map(|tool| normalize_tool_name(tool)));
689                }
690                let session_allowed_tools = self
691                    .session_allowed_tools
692                    .read()
693                    .await
694                    .get(&session_id)
695                    .cloned()
696                    .unwrap_or_default();
697                policy_patterns.extend(session_allowed_tools.iter().cloned());
698                if !policy_patterns.is_empty() {
699                    let mut included = tool_schemas
700                        .iter()
701                        .map(|schema| normalize_tool_name(&schema.name))
702                        .collect::<HashSet<_>>();
703                    for schema in &all_tools {
704                        let normalized = normalize_tool_name(&schema.name);
705                        if policy_patterns
706                            .iter()
707                            .any(|pattern| tool_name_matches_policy(pattern, &normalized))
708                            && included.insert(normalized)
709                        {
710                            tool_schemas.push(schema.clone());
711                        }
712                    }
713                }
714                if !request_tool_allowlist.is_empty() {
715                    tool_schemas.retain(|schema| {
716                        let tool = normalize_tool_name(&schema.name);
717                        request_tool_allowlist
718                            .iter()
719                            .any(|pattern| tool_name_matches_policy(pattern, &tool))
720                    });
721                }
722                let prewrite_gate = evaluate_prewrite_gate(
723                    requested_write_required,
724                    &requested_prewrite_requirements,
725                    PrewriteProgress {
726                        productive_write_tool_calls_total,
727                        productive_workspace_inspection_total,
728                        productive_concrete_read_total,
729                        productive_web_research_total,
730                        successful_web_research_total,
731                        required_write_retry_count,
732                        unmet_prewrite_repair_retry_count,
733                        prewrite_gate_waived,
734                    },
735                );
736                let _prewrite_satisfied = prewrite_gate.prewrite_satisfied;
737                let prewrite_gate_write = prewrite_gate.gate_write;
738                let force_write_only_retry = prewrite_gate.force_write_only_retry;
739                let allow_repair_tools = prewrite_gate.allow_repair_tools;
740                if prewrite_gate_write {
741                    tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
742                }
743                if requested_prewrite_requirements.repair_on_unmet_requirements
744                    && productive_write_tool_calls_total >= 3
745                {
746                    tool_schemas.retain(|schema| !is_workspace_write_tool(&schema.name));
747                }
748                if allow_repair_tools {
749                    let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
750                    let repair_tools = tool_schemas
751                        .iter()
752                        .filter(|schema| {
753                            tool_matches_unmet_prewrite_repair_requirement(
754                                &schema.name,
755                                &unmet_prewrite_codes,
756                            )
757                        })
758                        .cloned()
759                        .collect::<Vec<_>>();
760                    if !repair_tools.is_empty() {
761                        tool_schemas = repair_tools;
762                    }
763                }
764                if force_write_only_retry && !allow_repair_tools {
765                    tool_schemas.retain(|schema| is_workspace_write_tool(&schema.name));
766                }
767                if active_agent.tools.is_some() {
768                    tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
769                }
770                tool_schemas.retain(|schema| {
771                    let normalized = normalize_tool_name(&schema.name);
772                    if let Some(server) = mcp_server_from_tool_name(&normalized) {
773                        !blocked_mcp_servers.contains(server)
774                    } else {
775                        true
776                    }
777                });
778                if let Some(allowed_tools) = self
779                    .session_allowed_tools
780                    .read()
781                    .await
782                    .get(&session_id)
783                    .cloned()
784                {
785                    if !allowed_tools.is_empty() {
786                        tool_schemas.retain(|schema| {
787                            let normalized = normalize_tool_name(&schema.name);
788                            any_policy_matches(&allowed_tools, &normalized)
789                        });
790                    }
791                }
792                if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
793                    let detail = validation_err.to_string();
794                    emit_event(
795                        Level::ERROR,
796                        ProcessKind::Engine,
797                        ObservabilityEvent {
798                            event: "provider.call.error",
799                            component: "engine.loop",
800                            correlation_id: correlation_ref,
801                            session_id: Some(&session_id),
802                            run_id: None,
803                            message_id: Some(&user_message_id),
804                            provider_id: Some(provider_id.as_str()),
805                            model_id,
806                            status: Some("failed"),
807                            error_code: Some("TOOL_SCHEMA_INVALID"),
808                            detail: Some(&detail),
809                        },
810                    );
811                    anyhow::bail!("{detail}");
812                }
813                let routing_decision = ToolRoutingDecision {
814                    pass: if auto_tools_escalated { 2 } else { 1 },
815                    mode: match requested_tool_mode {
816                        ToolMode::Auto => default_mode_name(),
817                        ToolMode::None => "none",
818                        ToolMode::Required => "required",
819                    },
820                    intent,
821                    selected_count: tool_schemas.len(),
822                    total_available_count: all_tools.len(),
823                    mcp_included: tool_schemas
824                        .iter()
825                        .any(|schema| normalize_tool_name(&schema.name).starts_with("mcp.")),
826                };
827                self.event_bus.publish(EngineEvent::new(
828                    "tool.routing.decision",
829                    json!({
830                        "sessionID": session_id,
831                        "messageID": user_message_id,
832                        "iteration": iteration,
833                        "pass": routing_decision.pass,
834                        "mode": routing_decision.mode,
835                        "intent": format!("{:?}", routing_decision.intent).to_ascii_lowercase(),
836                        "selectedToolCount": routing_decision.selected_count,
837                        "totalAvailableTools": routing_decision.total_available_count,
838                        "mcpIncluded": routing_decision.mcp_included,
839                        "retrievalEnabled": retrieval_enabled,
840                        "retrievalK": retrieval_k,
841                        "fallbackToFullTools": retrieval_fallback_reason.is_some(),
842                        "fallbackReason": retrieval_fallback_reason
843                    }),
844                ));
845                let allowed_tool_names = tool_schemas
846                    .iter()
847                    .map(|schema| normalize_tool_name(&schema.name))
848                    .collect::<HashSet<_>>();
849                let offered_tool_preview = tool_schemas
850                    .iter()
851                    .take(8)
852                    .map(|schema| normalize_tool_name(&schema.name))
853                    .collect::<Vec<_>>()
854                    .join(", ");
855                self.event_bus.publish(EngineEvent::new(
856                    "provider.call.iteration.start",
857                    json!({
858                        "sessionID": session_id,
859                        "messageID": user_message_id,
860                        "iteration": iteration,
861                        "selectedToolCount": allowed_tool_names.len(),
862                    }),
863                ));
864                let provider_connect_timeout =
865                    Duration::from_millis(provider_stream_connect_timeout_ms() as u64);
866                let stream_result = tokio::time::timeout(
867                    provider_connect_timeout,
868                    self.providers.stream_for_provider(
869                        Some(provider_id.as_str()),
870                        Some(model_id_value.as_str()),
871                        messages,
872                        requested_tool_mode.clone(),
873                        Some(tool_schemas),
874                        cancel.clone(),
875                    ),
876                )
877                .await
878                .map_err(|_| {
879                    anyhow::anyhow!(
880                        "provider stream connect timeout after {} ms",
881                        provider_connect_timeout.as_millis()
882                    )
883                })
884                .and_then(|result| result);
885                let stream = match stream_result {
886                    Ok(stream) => stream,
887                    Err(err) => {
888                        let error_text = err.to_string();
889                        let error_code = provider_error_code(&error_text);
890                        let detail = truncate_text(&error_text, 500);
891                        emit_event(
892                            Level::ERROR,
893                            ProcessKind::Engine,
894                            ObservabilityEvent {
895                                event: "provider.call.error",
896                                component: "engine.loop",
897                                correlation_id: correlation_ref,
898                                session_id: Some(&session_id),
899                                run_id: None,
900                                message_id: Some(&user_message_id),
901                                provider_id: Some(provider_id.as_str()),
902                                model_id,
903                                status: Some("failed"),
904                                error_code: Some(error_code),
905                                detail: Some(&detail),
906                            },
907                        );
908                        self.event_bus.publish(EngineEvent::new(
909                            "provider.call.iteration.error",
910                            json!({
911                                "sessionID": session_id,
912                                "messageID": user_message_id,
913                                "iteration": iteration,
914                                "error": detail,
915                            }),
916                        ));
917                        return Err(err);
918                    }
919                };
920                tokio::pin!(stream);
921                completion.clear();
922                let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
923                let mut provider_usage: Option<TokenUsage> = None;
924                let mut accepted_tool_calls_in_cycle = 0usize;
925                let provider_idle_timeout =
926                    Duration::from_millis(provider_stream_idle_timeout_ms() as u64);
927                loop {
928                    let next_chunk_result =
929                        tokio::time::timeout(provider_idle_timeout, stream.next())
930                            .await
931                            .map_err(|_| {
932                                anyhow::anyhow!(
933                                    "provider stream idle timeout after {} ms",
934                                    provider_idle_timeout.as_millis()
935                                )
936                            });
937                    let next_chunk = match next_chunk_result {
938                        Ok(next_chunk) => next_chunk,
939                        Err(err) => {
940                            self.event_bus.publish(EngineEvent::new(
941                                "provider.call.iteration.error",
942                                json!({
943                                    "sessionID": session_id,
944                                    "messageID": user_message_id,
945                                    "iteration": iteration,
946                                    "error": truncate_text(&err.to_string(), 500),
947                                }),
948                            ));
949                            return Err(err);
950                        }
951                    };
952                    let Some(chunk) = next_chunk else {
953                        break;
954                    };
955                    let chunk = match chunk {
956                        Ok(chunk) => chunk,
957                        Err(err) => {
958                            let error_text = err.to_string();
959                            let error_code = provider_error_code(&error_text);
960                            let detail = truncate_text(&error_text, 500);
961                            emit_event(
962                                Level::ERROR,
963                                ProcessKind::Engine,
964                                ObservabilityEvent {
965                                    event: "provider.call.error",
966                                    component: "engine.loop",
967                                    correlation_id: correlation_ref,
968                                    session_id: Some(&session_id),
969                                    run_id: None,
970                                    message_id: Some(&user_message_id),
971                                    provider_id: Some(provider_id.as_str()),
972                                    model_id,
973                                    status: Some("failed"),
974                                    error_code: Some(error_code),
975                                    detail: Some(&detail),
976                                },
977                            );
978                            self.event_bus.publish(EngineEvent::new(
979                                "provider.call.iteration.error",
980                                json!({
981                                    "sessionID": session_id,
982                                    "messageID": user_message_id,
983                                    "iteration": iteration,
984                                    "error": detail,
985                                }),
986                            ));
987                            return Err(anyhow::anyhow!(
988                                "provider stream chunk error: {error_text}"
989                            ));
990                        }
991                    };
992                    match chunk {
993                        StreamChunk::TextDelta(delta) => {
994                            let delta = strip_model_control_markers(&delta);
995                            if delta.trim().is_empty() {
996                                continue;
997                            }
998                            if completion.is_empty() {
999                                emit_event(
1000                                    Level::INFO,
1001                                    ProcessKind::Engine,
1002                                    ObservabilityEvent {
1003                                        event: "provider.call.first_byte",
1004                                        component: "engine.loop",
1005                                        correlation_id: correlation_ref,
1006                                        session_id: Some(&session_id),
1007                                        run_id: None,
1008                                        message_id: Some(&user_message_id),
1009                                        provider_id: Some(provider_id.as_str()),
1010                                        model_id,
1011                                        status: Some("streaming"),
1012                                        error_code: None,
1013                                        detail: Some("first text delta"),
1014                                    },
1015                                );
1016                            }
1017                            completion.push_str(&delta);
1018                            let delta = truncate_text(&delta, 4_000);
1019                            let delta_part =
1020                                WireMessagePart::text(&session_id, &user_message_id, delta.clone());
1021                            self.event_bus.publish(EngineEvent::new(
1022                                "message.part.updated",
1023                                json!({"part": delta_part, "delta": delta}),
1024                            ));
1025                        }
1026                        StreamChunk::ReasoningDelta(_reasoning) => {}
1027                        StreamChunk::Done {
1028                            finish_reason: _,
1029                            usage,
1030                        } => {
1031                            if usage.is_some() {
1032                                provider_usage = usage;
1033                            }
1034                            break;
1035                        }
1036                        StreamChunk::ToolCallStart { id, name } => {
1037                            let entry = streamed_tool_calls.entry(id).or_default();
1038                            if entry.name.is_empty() {
1039                                entry.name = name;
1040                            }
1041                        }
1042                        StreamChunk::ToolCallDelta { id, args_delta } => {
1043                            let entry = streamed_tool_calls.entry(id.clone()).or_default();
1044                            entry.args.push_str(&args_delta);
1045                            let tool_name = if entry.name.trim().is_empty() {
1046                                "tool".to_string()
1047                            } else {
1048                                normalize_tool_name(&entry.name)
1049                            };
1050                            let parsed_preview = if entry.name.trim().is_empty() {
1051                                Value::String(truncate_text(&entry.args, 1_000))
1052                            } else {
1053                                parse_streamed_tool_args(&tool_name, &entry.args)
1054                            };
1055                            let mut tool_part = WireMessagePart::tool_invocation(
1056                                &session_id,
1057                                &user_message_id,
1058                                tool_name.clone(),
1059                                parsed_preview.clone(),
1060                            );
1061                            tool_part.id = Some(id.clone());
1062                            if tool_name == "write" {
1063                                tracing::info!(
1064                                    session_id = %session_id,
1065                                    message_id = %user_message_id,
1066                                    tool_call_id = %id,
1067                                    args_delta_len = args_delta.len(),
1068                                    accumulated_args_len = entry.args.len(),
1069                                    parsed_preview_empty = parsed_preview.is_null()
1070                                        || parsed_preview.as_object().is_some_and(|value| value.is_empty())
1071                                        || parsed_preview
1072                                            .as_str()
1073                                            .map(|value| value.trim().is_empty())
1074                                            .unwrap_or(false),
1075                                    "streamed write tool args delta received"
1076                                );
1077                            }
1078                            self.event_bus.publish(EngineEvent::new(
1079                                "message.part.updated",
1080                                json!({
1081                                    "part": tool_part,
1082                                    "toolCallDelta": {
1083                                        "id": id,
1084                                        "tool": tool_name,
1085                                        "argsDelta": truncate_text(&args_delta, 1_000),
1086                                        "rawArgsPreview": truncate_text(&entry.args, 2_000),
1087                                        "parsedArgsPreview": parsed_preview
1088                                    }
1089                                }),
1090                            ));
1091                        }
1092                        StreamChunk::ToolCallEnd { id: _ } => {}
1093                    }
1094                    if cancel.is_cancelled() {
1095                        break;
1096                    }
1097                }
1098
1099                let streamed_tool_call_count = streamed_tool_calls.len();
1100                let streamed_tool_call_parse_failed = streamed_tool_calls
1101                    .values()
1102                    .any(|call| !call.args.trim().is_empty() && call.name.trim().is_empty());
1103                let mut tool_calls = streamed_tool_calls
1104                    .into_iter()
1105                    .filter_map(|(call_id, call)| {
1106                        if call.name.trim().is_empty() {
1107                            return None;
1108                        }
1109                        let tool_name = normalize_tool_name(&call.name);
1110                        let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
1111                        Some(ParsedToolCall {
1112                            tool: tool_name,
1113                            args: parsed_args,
1114                            call_id: Some(call_id),
1115                        })
1116                    })
1117                    .collect::<Vec<_>>();
1118                if tool_calls.is_empty() {
1119                    tool_calls = parse_tool_invocations_from_response(&completion)
1120                        .into_iter()
1121                        .map(|(tool, args)| ParsedToolCall {
1122                            tool,
1123                            args,
1124                            call_id: None,
1125                        })
1126                        .collect::<Vec<_>>();
1127                }
1128                let provider_tool_parse_failed = tool_calls.is_empty()
1129                    && (streamed_tool_call_parse_failed
1130                        || (streamed_tool_call_count > 0
1131                            && looks_like_unparsed_tool_payload(&completion))
1132                        || looks_like_unparsed_tool_payload(&completion));
1133                if provider_tool_parse_failed {
1134                    latest_required_tool_failure_kind =
1135                        RequiredToolFailureKind::ToolCallParseFailed;
1136                } else if tool_calls.is_empty() {
1137                    latest_required_tool_failure_kind = RequiredToolFailureKind::NoToolCallEmitted;
1138                }
1139                if router_enabled
1140                    && matches!(requested_tool_mode, ToolMode::Auto)
1141                    && !auto_tools_escalated
1142                    && iteration == 1
1143                    && should_escalate_auto_tools(intent, &text, &completion)
1144                {
1145                    auto_tools_escalated = true;
1146                    followup_context = Some(
1147                        "Tool access is now enabled for this request. Use only necessary tools and then answer concisely."
1148                            .to_string(),
1149                    );
1150                    self.event_bus.publish(EngineEvent::new(
1151                        "provider.call.iteration.finish",
1152                        json!({
1153                            "sessionID": session_id,
1154                            "messageID": user_message_id,
1155                            "iteration": iteration,
1156                            "finishReason": "auto_escalate",
1157                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
1158                            "rejectedToolCalls": 0,
1159                        }),
1160                    ));
1161                    continue;
1162                }
1163                if tool_calls.is_empty()
1164                    && !auto_workspace_probe_attempted
1165                    && should_force_workspace_probe(&text, &completion)
1166                    && allowed_tool_names.contains("glob")
1167                {
1168                    auto_workspace_probe_attempted = true;
1169                    tool_calls = vec![ParsedToolCall {
1170                        tool: "glob".to_string(),
1171                        args: json!({ "pattern": "*" }),
1172                        call_id: None,
1173                    }];
1174                }
1175                if !tool_calls.is_empty() {
1176                    let saw_tool_call_candidate = true;
1177                    let mut outputs = Vec::new();
1178                    let mut executed_productive_tool = false;
1179                    let mut write_tool_attempted_in_cycle = false;
1180                    let mut auth_required_hit_in_cycle = false;
1181                    let mut guard_budget_hit_in_cycle = false;
1182                    let mut duplicate_signature_hit_in_cycle = false;
1183                    let mut rejected_tool_call_in_cycle = false;
1184                    for ParsedToolCall {
1185                        tool,
1186                        args,
1187                        call_id,
1188                    } in tool_calls
1189                    {
1190                        if !agent_can_use_tool(&active_agent, &tool) {
1191                            rejected_tool_call_in_cycle = true;
1192                            continue;
1193                        }
1194                        let tool_key = normalize_tool_name(&tool);
1195                        if is_workspace_write_tool(&tool_key) {
1196                            write_tool_attempted_in_cycle = true;
1197                        }
1198                        if !allowed_tool_names.contains(&tool_key) {
1199                            rejected_tool_call_in_cycle = true;
1200                            let note = if offered_tool_preview.is_empty() {
1201                                format!(
1202                                    "Tool `{}` call skipped: it is not available in this turn.",
1203                                    tool_key
1204                                )
1205                            } else {
1206                                format!(
1207                                    "Tool `{}` call skipped: it is not available in this turn. Available tools: {}.",
1208                                    tool_key, offered_tool_preview
1209                                )
1210                            };
1211                            self.event_bus.publish(EngineEvent::new(
1212                                "tool.call.rejected_unoffered",
1213                                json!({
1214                                    "sessionID": session_id,
1215                                    "messageID": user_message_id,
1216                                    "iteration": iteration,
1217                                    "tool": tool_key,
1218                                    "offeredToolCount": allowed_tool_names.len()
1219                                }),
1220                            ));
1221                            if tool_name_looks_like_email_action(&tool_key) {
1222                                latest_email_action_note = Some(note.clone());
1223                            }
1224                            outputs.push(note);
1225                            continue;
1226                        }
1227                        if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1228                            if blocked_mcp_servers.contains(server) {
1229                                rejected_tool_call_in_cycle = true;
1230                                outputs.push(format!(
1231                                    "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
1232                                    tool_key, server
1233                                ));
1234                                continue;
1235                            }
1236                        }
1237                        if tool_key == "question" {
1238                            question_tool_used = true;
1239                        }
1240                        if tool_key == "pack_builder" && pack_builder_executed {
1241                            rejected_tool_call_in_cycle = true;
1242                            outputs.push(
1243                                "Tool `pack_builder` call skipped: already executed in this run. Provide a final response or ask any required follow-up question."
1244                                    .to_string(),
1245                            );
1246                            continue;
1247                        }
1248                        if websearch_query_blocked && tool_key == "websearch" {
1249                            rejected_tool_call_in_cycle = true;
1250                            outputs.push(
1251                                "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
1252                                    .to_string(),
1253                            );
1254                            continue;
1255                        }
1256                        let mut effective_args = args.clone();
1257                        if tool_key == "todo_write" {
1258                            effective_args = normalize_todo_write_args(effective_args, &completion);
1259                            if is_empty_todo_write_args(&effective_args) {
1260                                rejected_tool_call_in_cycle = true;
1261                                outputs.push(
1262                                    "Tool `todo_write` call skipped: empty todo payload."
1263                                        .to_string(),
1264                                );
1265                                continue;
1266                            }
1267                        }
1268                        let signature = if tool_key == "batch" {
1269                            batch_tool_signature(&args)
1270                                .unwrap_or_else(|| tool_signature(&tool_key, &args))
1271                        } else {
1272                            tool_signature(&tool_key, &args)
1273                        };
1274                        if is_shell_tool_name(&tool_key)
1275                            && shell_mismatch_signatures.contains(&signature)
1276                        {
1277                            rejected_tool_call_in_cycle = true;
1278                            outputs.push(
1279                                "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
1280                                    .to_string(),
1281                            );
1282                            continue;
1283                        }
1284                        let mut signature_count = 1usize;
1285                        if is_read_only_tool(&tool_key)
1286                            || (tool_key == "batch" && is_read_only_batch_call(&args))
1287                        {
1288                            let count = readonly_signature_counts
1289                                .entry(signature.clone())
1290                                .and_modify(|v| *v = v.saturating_add(1))
1291                                .or_insert(1);
1292                            signature_count = *count;
1293                            if tool_key == "websearch" {
1294                                if let Some(limit) = websearch_duplicate_signature_limit {
1295                                    if *count > limit {
1296                                        rejected_tool_call_in_cycle = true;
1297                                        self.event_bus.publish(EngineEvent::new(
1298                                            "tool.loop_guard.triggered",
1299                                            json!({
1300                                                "sessionID": session_id,
1301                                                "messageID": user_message_id,
1302                                                "tool": tool_key,
1303                                                "reason": "duplicate_signature_retry_exhausted",
1304                                                "duplicateLimit": limit,
1305                                                "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
1306                                                "loop_guard_triggered": true
1307                                            }),
1308                                        ));
1309                                        outputs.push(
1310                                            "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
1311                                                .to_string(),
1312                                        );
1313                                        continue;
1314                                    }
1315                                }
1316                            }
1317                            if tool_key != "websearch" && *count > 1 {
1318                                rejected_tool_call_in_cycle = true;
1319                                if let Some(cached) = readonly_tool_cache.get(&signature) {
1320                                    outputs.push(cached.clone());
1321                                } else {
1322                                    outputs.push(format!(
1323                                        "Tool `{}` call skipped: duplicate call signature detected.",
1324                                        tool_key
1325                                    ));
1326                                }
1327                                continue;
1328                            }
1329                        }
1330                        let is_read_only_signature = is_read_only_tool(&tool_key)
1331                            || (tool_key == "batch" && is_read_only_batch_call(&args));
1332                        if !is_read_only_signature {
1333                            let duplicate_limit = duplicate_signature_limit_for(&tool_key);
1334                            let seen = mutable_signature_counts
1335                                .entry(signature.clone())
1336                                .and_modify(|v| *v = v.saturating_add(1))
1337                                .or_insert(1);
1338                            if *seen > duplicate_limit {
1339                                rejected_tool_call_in_cycle = true;
1340                                self.event_bus.publish(EngineEvent::new(
1341                                    "tool.loop_guard.triggered",
1342                                    json!({
1343                                        "sessionID": session_id,
1344                                        "messageID": user_message_id,
1345                                        "tool": tool_key,
1346                                        "reason": "duplicate_signature_retry_exhausted",
1347                                        "signatureHash": stable_hash(&signature),
1348                                        "duplicateLimit": duplicate_limit,
1349                                        "loop_guard_triggered": true
1350                                    }),
1351                                ));
1352                                outputs.push(format!(
1353                                    "Tool `{}` call skipped: duplicate call signature retry limit reached ({}).",
1354                                    tool_key, duplicate_limit
1355                                ));
1356                                duplicate_signature_hit_in_cycle = true;
1357                                continue;
1358                            }
1359                        }
1360                        let budget = tool_budget_for(&tool_key);
1361                        let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
1362                        if *entry >= budget {
1363                            rejected_tool_call_in_cycle = true;
1364                            outputs.push(format!(
1365                                "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
1366                                tool_key, budget
1367                            ));
1368                            guard_budget_hit_in_cycle = true;
1369                            continue;
1370                        }
1371                        let mut finalized_part = WireMessagePart::tool_invocation(
1372                            &session_id,
1373                            &user_message_id,
1374                            tool.clone(),
1375                            effective_args.clone(),
1376                        );
1377                        if let Some(call_id) = call_id.clone() {
1378                            finalized_part.id = Some(call_id);
1379                        }
1380                        finalized_part.state = Some("pending".to_string());
1381                        self.event_bus.publish(EngineEvent::new(
1382                            "message.part.updated",
1383                            json!({"part": finalized_part}),
1384                        ));
1385                        *entry += 1;
1386                        accepted_tool_calls_in_cycle =
1387                            accepted_tool_calls_in_cycle.saturating_add(1);
1388                        if let Some(output) = self
1389                            .execute_tool_with_permission(
1390                                &session_id,
1391                                &user_message_id,
1392                                tool,
1393                                effective_args,
1394                                call_id,
1395                                active_agent.skills.as_deref(),
1396                                &text,
1397                                requested_write_required,
1398                                Some(&completion),
1399                                cancel.clone(),
1400                            )
1401                            .await?
1402                        {
1403                            let productive = is_productive_tool_output(&tool_key, &output);
1404                            if output.contains("WEBSEARCH_QUERY_MISSING") {
1405                                websearch_query_blocked = true;
1406                            }
1407                            if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
1408                            {
1409                                shell_mismatch_signatures.insert(signature.clone());
1410                            }
1411                            if is_read_only_tool(&tool_key)
1412                                && tool_key != "websearch"
1413                                && signature_count == 1
1414                            {
1415                                readonly_tool_cache.insert(signature, output.clone());
1416                            }
1417                            if productive {
1418                                productive_tool_calls_total =
1419                                    productive_tool_calls_total.saturating_add(1);
1420                                if is_workspace_write_tool(&tool_key) {
1421                                    productive_write_tool_calls_total =
1422                                        productive_write_tool_calls_total.saturating_add(1);
1423                                }
1424                                if is_workspace_inspection_tool(&tool_key) {
1425                                    productive_workspace_inspection_total =
1426                                        productive_workspace_inspection_total.saturating_add(1);
1427                                }
1428                                if tool_key == "read" {
1429                                    productive_concrete_read_total =
1430                                        productive_concrete_read_total.saturating_add(1);
1431                                }
1432                                if is_web_research_tool(&tool_key) {
1433                                    productive_web_research_total =
1434                                        productive_web_research_total.saturating_add(1);
1435                                    if is_successful_web_research_output(&tool_key, &output) {
1436                                        successful_web_research_total =
1437                                            successful_web_research_total.saturating_add(1);
1438                                    }
1439                                }
1440                                executed_productive_tool = true;
1441                                if tool_key == "pack_builder" {
1442                                    pack_builder_executed = true;
1443                                }
1444                            }
1445                            if tool_name_looks_like_email_action(&tool_key) {
1446                                if productive {
1447                                    email_action_executed = true;
1448                                } else {
1449                                    latest_email_action_note =
1450                                        Some(truncate_text(&output, 280).replace('\n', " "));
1451                                }
1452                            }
1453                            if is_auth_required_tool_output(&output) {
1454                                if let Some(server) = mcp_server_from_tool_name(&tool_key) {
1455                                    blocked_mcp_servers.insert(server.to_string());
1456                                }
1457                                auth_required_hit_in_cycle = true;
1458                            }
1459                            outputs.push(output);
1460                            if auth_required_hit_in_cycle {
1461                                break;
1462                            }
1463                            if guard_budget_hit_in_cycle {
1464                                break;
1465                            }
1466                        }
1467                    }
1468                    if !outputs.is_empty() {
1469                        last_tool_outputs = outputs.clone();
1470                        if matches!(requested_tool_mode, ToolMode::Required)
1471                            && productive_tool_calls_total == 0
1472                        {
1473                            latest_required_tool_failure_kind = classify_required_tool_failure(
1474                                &outputs,
1475                                saw_tool_call_candidate,
1476                                accepted_tool_calls_in_cycle,
1477                                provider_tool_parse_failed,
1478                                rejected_tool_call_in_cycle,
1479                            );
1480                            if requested_write_required
1481                                && write_tool_attempted_in_cycle
1482                                && productive_write_tool_calls_total == 0
1483                                && is_write_invalid_args_failure_kind(
1484                                    latest_required_tool_failure_kind,
1485                                )
1486                            {
1487                                if required_write_retry_count + 1 < strict_write_retry_max_attempts
1488                                {
1489                                    required_write_retry_count += 1;
1490                                    required_tool_retry_count += 1;
1491                                    followup_context = Some(build_write_required_retry_context(
1492                                        &offered_tool_preview,
1493                                        latest_required_tool_failure_kind,
1494                                        &text,
1495                                        &requested_prewrite_requirements,
1496                                        productive_workspace_inspection_total > 0,
1497                                        productive_concrete_read_total > 0,
1498                                        productive_web_research_total > 0,
1499                                        successful_web_research_total > 0,
1500                                    ));
1501                                    self.event_bus.publish(EngineEvent::new(
1502                                        "provider.call.iteration.finish",
1503                                        json!({
1504                                            "sessionID": session_id,
1505                                            "messageID": user_message_id,
1506                                            "iteration": iteration,
1507                                            "finishReason": "required_write_invalid_retry",
1508                                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
1509                                            "rejectedToolCalls": 0,
1510                                            "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1511                                        }),
1512                                    ));
1513                                    continue;
1514                                }
1515                            }
1516                            let progress_made_in_cycle = productive_workspace_inspection_total > 0
1517                                || productive_concrete_read_total > 0
1518                                || productive_web_research_total > 0
1519                                || successful_web_research_total > 0;
1520                            if should_retry_nonproductive_required_tool_cycle(
1521                                requested_write_required,
1522                                write_tool_attempted_in_cycle,
1523                                progress_made_in_cycle,
1524                                required_tool_retry_count,
1525                            ) {
1526                                required_tool_retry_count += 1;
1527                                followup_context =
1528                                    Some(build_required_tool_retry_context_for_task(
1529                                        &offered_tool_preview,
1530                                        latest_required_tool_failure_kind,
1531                                        &text,
1532                                    ));
1533                                self.event_bus.publish(EngineEvent::new(
1534                                    "provider.call.iteration.finish",
1535                                    json!({
1536                                        "sessionID": session_id,
1537                                        "messageID": user_message_id,
1538                                        "iteration": iteration,
1539                                        "finishReason": "required_tool_retry",
1540                                        "acceptedToolCalls": accepted_tool_calls_in_cycle,
1541                                        "rejectedToolCalls": 0,
1542                                        "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1543                                    }),
1544                                ));
1545                                continue;
1546                            }
1547                            completion = required_tool_mode_unsatisfied_completion(
1548                                latest_required_tool_failure_kind,
1549                            );
1550                            if !required_tool_unsatisfied_emitted {
1551                                required_tool_unsatisfied_emitted = true;
1552                                self.event_bus.publish(EngineEvent::new(
1553                                    "tool.mode.required.unsatisfied",
1554                                    json!({
1555                                        "sessionID": session_id,
1556                                        "messageID": user_message_id,
1557                                        "iteration": iteration,
1558                                        "selectedToolCount": allowed_tool_names.len(),
1559                                        "offeredToolsPreview": offered_tool_preview,
1560                                        "reason": latest_required_tool_failure_kind.code(),
1561                                    }),
1562                                ));
1563                            }
1564                            self.event_bus.publish(EngineEvent::new(
1565                                "provider.call.iteration.finish",
1566                                json!({
1567                                    "sessionID": session_id,
1568                                    "messageID": user_message_id,
1569                                    "iteration": iteration,
1570                                    "finishReason": "required_tool_unsatisfied",
1571                                    "acceptedToolCalls": accepted_tool_calls_in_cycle,
1572                                    "rejectedToolCalls": 0,
1573                                    "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1574                                }),
1575                            ));
1576                            break;
1577                        }
1578                        let prewrite_gate = evaluate_prewrite_gate(
1579                            requested_write_required,
1580                            &requested_prewrite_requirements,
1581                            PrewriteProgress {
1582                                productive_write_tool_calls_total,
1583                                productive_workspace_inspection_total,
1584                                productive_concrete_read_total,
1585                                productive_web_research_total,
1586                                successful_web_research_total,
1587                                required_write_retry_count,
1588                                unmet_prewrite_repair_retry_count,
1589                                prewrite_gate_waived,
1590                            },
1591                        );
1592                        let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1593                        let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1594                        if requested_write_required
1595                            && productive_tool_calls_total > 0
1596                            && productive_write_tool_calls_total == 0
1597                        {
1598                            if should_start_prewrite_repair_before_first_write(
1599                                requested_prewrite_requirements.repair_on_unmet_requirements,
1600                                productive_write_tool_calls_total,
1601                                prewrite_satisfied,
1602                                code_workflow_requested,
1603                            ) {
1604                                if unmet_prewrite_repair_retry_count
1605                                    < prewrite_repair_retry_max_attempts()
1606                                {
1607                                    unmet_prewrite_repair_retry_count += 1;
1608                                    let repair_attempt = unmet_prewrite_repair_retry_count;
1609                                    let repair_attempts_remaining =
1610                                        prewrite_repair_retry_max_attempts()
1611                                            .saturating_sub(repair_attempt);
1612                                    followup_context = Some(build_prewrite_repair_retry_context(
1613                                        &offered_tool_preview,
1614                                        latest_required_tool_failure_kind,
1615                                        &text,
1616                                        &requested_prewrite_requirements,
1617                                        productive_workspace_inspection_total > 0,
1618                                        productive_concrete_read_total > 0,
1619                                        productive_web_research_total > 0,
1620                                        successful_web_research_total > 0,
1621                                    ));
1622                                    self.event_bus.publish(EngineEvent::new(
1623                                        "provider.call.iteration.finish",
1624                                        json!({
1625                                            "sessionID": session_id,
1626                                            "messageID": user_message_id,
1627                                            "iteration": iteration,
1628                                            "finishReason": "prewrite_repair_retry",
1629                                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
1630                                            "rejectedToolCalls": 0,
1631                                            "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1632                                            "repair": prewrite_repair_event_payload(
1633                                                repair_attempt,
1634                                                repair_attempts_remaining,
1635                                                &unmet_prewrite_codes,
1636                                                false,
1637                                            ),
1638                                        }),
1639                                    ));
1640                                    continue;
1641                                }
1642                                if !prewrite_gate_waived {
1643                                    if prewrite_gate_strict_mode() {
1644                                        // Strict mode: refuse to waive; emit blocked event and
1645                                        // continue so the gate retains control.
1646                                        self.event_bus.publish(EngineEvent::new(
1647                                            "prewrite.gate.strict_mode.blocked",
1648                                            json!({
1649                                                "sessionID": session_id,
1650                                                "messageID": user_message_id,
1651                                                "iteration": iteration,
1652                                                "unmetCodes": unmet_prewrite_codes,
1653                                            }),
1654                                        ));
1655                                        continue;
1656                                    }
1657                                    prewrite_gate_waived = true;
1658                                    let repair_attempt = unmet_prewrite_repair_retry_count;
1659                                    let repair_attempts_remaining =
1660                                        prewrite_repair_retry_max_attempts()
1661                                            .saturating_sub(repair_attempt);
1662                                    followup_context = Some(build_prewrite_waived_write_context(
1663                                        &text,
1664                                        &unmet_prewrite_codes,
1665                                    ));
1666                                    self.event_bus.publish(EngineEvent::new(
1667                                        "prewrite.gate.waived.write_executed",
1668                                        json!({
1669                                            "sessionID": session_id,
1670                                            "messageID": user_message_id,
1671                                            "unmetCodes": unmet_prewrite_codes,
1672                                        }),
1673                                    ));
1674                                    self.event_bus.publish(EngineEvent::new(
1675                                        "provider.call.iteration.finish",
1676                                        json!({
1677                                            "sessionID": session_id,
1678                                            "messageID": user_message_id,
1679                                            "iteration": iteration,
1680                                            "finishReason": "prewrite_gate_waived",
1681                                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
1682                                            "rejectedToolCalls": 0,
1683                                            "prewriteGateWaived": true,
1684                                            "repair": prewrite_repair_event_payload(
1685                                                repair_attempt,
1686                                                repair_attempts_remaining,
1687                                                &unmet_prewrite_codes,
1688                                                true,
1689                                            ),
1690                                        }),
1691                                    ));
1692                                    continue;
1693                                }
1694                            }
1695                            latest_required_tool_failure_kind =
1696                                RequiredToolFailureKind::WriteRequiredNotSatisfied;
1697                            if required_write_retry_count + 1 < strict_write_retry_max_attempts {
1698                                required_write_retry_count += 1;
1699                                followup_context = Some(build_write_required_retry_context(
1700                                    &offered_tool_preview,
1701                                    latest_required_tool_failure_kind,
1702                                    &text,
1703                                    &requested_prewrite_requirements,
1704                                    productive_workspace_inspection_total > 0,
1705                                    productive_concrete_read_total > 0,
1706                                    productive_web_research_total > 0,
1707                                    successful_web_research_total > 0,
1708                                ));
1709                                self.event_bus.publish(EngineEvent::new(
1710                                    "provider.call.iteration.finish",
1711                                    json!({
1712                                        "sessionID": session_id,
1713                                        "messageID": user_message_id,
1714                                        "iteration": iteration,
1715                                        "finishReason": "required_write_retry",
1716                                        "acceptedToolCalls": accepted_tool_calls_in_cycle,
1717                                        "rejectedToolCalls": 0,
1718                                        "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1719                                    }),
1720                                ));
1721                                continue;
1722                            }
1723                            completion = required_tool_mode_unsatisfied_completion(
1724                                latest_required_tool_failure_kind,
1725                            );
1726                            if !required_tool_unsatisfied_emitted {
1727                                required_tool_unsatisfied_emitted = true;
1728                                self.event_bus.publish(EngineEvent::new(
1729                                    "tool.mode.required.unsatisfied",
1730                                    json!({
1731                                        "sessionID": session_id,
1732                                        "messageID": user_message_id,
1733                                        "iteration": iteration,
1734                                        "selectedToolCount": allowed_tool_names.len(),
1735                                        "offeredToolsPreview": offered_tool_preview,
1736                                        "reason": latest_required_tool_failure_kind.code(),
1737                                    }),
1738                                ));
1739                            }
1740                            self.event_bus.publish(EngineEvent::new(
1741                                "provider.call.iteration.finish",
1742                                json!({
1743                                    "sessionID": session_id,
1744                                    "messageID": user_message_id,
1745                                    "iteration": iteration,
1746                                    "finishReason": "required_write_unsatisfied",
1747                                    "acceptedToolCalls": accepted_tool_calls_in_cycle,
1748                                    "rejectedToolCalls": 0,
1749                                    "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1750                                }),
1751                            ));
1752                            break;
1753                        }
1754                        if invalid_tool_args_retry_count < invalid_tool_args_retry_max_attempts() {
1755                            if let Some(retry_context) =
1756                                build_invalid_tool_args_retry_context_from_outputs(
1757                                    &outputs,
1758                                    invalid_tool_args_retry_count,
1759                                )
1760                            {
1761                                invalid_tool_args_retry_count += 1;
1762                                followup_context = Some(format!(
1763                                    "Previous tool call arguments were invalid. {}",
1764                                    retry_context
1765                                ));
1766                                self.event_bus.publish(EngineEvent::new(
1767                                    "provider.call.iteration.finish",
1768                                    json!({
1769                                        "sessionID": session_id,
1770                                        "messageID": user_message_id,
1771                                        "iteration": iteration,
1772                                        "finishReason": "invalid_tool_args_retry",
1773                                        "acceptedToolCalls": accepted_tool_calls_in_cycle,
1774                                        "rejectedToolCalls": 0,
1775                                    }),
1776                                ));
1777                                continue;
1778                            }
1779                        }
1780                        let guard_budget_hit =
1781                            outputs.iter().any(|o| is_guard_budget_tool_output(o));
1782                        if executed_productive_tool {
1783                            let prewrite_gate = evaluate_prewrite_gate(
1784                                requested_write_required,
1785                                &requested_prewrite_requirements,
1786                                PrewriteProgress {
1787                                    productive_write_tool_calls_total,
1788                                    productive_workspace_inspection_total,
1789                                    productive_concrete_read_total,
1790                                    productive_web_research_total,
1791                                    successful_web_research_total,
1792                                    required_write_retry_count,
1793                                    unmet_prewrite_repair_retry_count,
1794                                    prewrite_gate_waived,
1795                                },
1796                            );
1797                            let prewrite_satisfied = prewrite_gate.prewrite_satisfied;
1798                            let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
1799                            if requested_write_required
1800                                && productive_write_tool_calls_total > 0
1801                                && requested_prewrite_requirements.repair_on_unmet_requirements
1802                                && unmet_prewrite_repair_retry_count
1803                                    < prewrite_repair_retry_max_attempts()
1804                                && !prewrite_satisfied
1805                            {
1806                                unmet_prewrite_repair_retry_count += 1;
1807                                let repair_attempt = unmet_prewrite_repair_retry_count;
1808                                let repair_attempts_remaining =
1809                                    prewrite_repair_retry_max_attempts()
1810                                        .saturating_sub(repair_attempt);
1811                                followup_context = Some(build_prewrite_repair_retry_context(
1812                                    &offered_tool_preview,
1813                                    latest_required_tool_failure_kind,
1814                                    &text,
1815                                    &requested_prewrite_requirements,
1816                                    productive_workspace_inspection_total > 0,
1817                                    productive_concrete_read_total > 0,
1818                                    productive_web_research_total > 0,
1819                                    successful_web_research_total > 0,
1820                                ));
1821                                self.event_bus.publish(EngineEvent::new(
1822                                    "provider.call.iteration.finish",
1823                                    json!({
1824                                        "sessionID": session_id,
1825                                        "messageID": user_message_id,
1826                                        "iteration": iteration,
1827                                        "finishReason": "prewrite_repair_retry",
1828                                        "acceptedToolCalls": accepted_tool_calls_in_cycle,
1829                                        "rejectedToolCalls": 0,
1830                                        "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1831                                        "repair": prewrite_repair_event_payload(
1832                                            repair_attempt,
1833                                            repair_attempts_remaining,
1834                                            &unmet_prewrite_codes,
1835                                            false,
1836                                        ),
1837                                    }),
1838                                ));
1839                                continue;
1840                            }
1841                            followup_context = Some(format!(
1842                                "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
1843                                summarize_tool_outputs(&outputs)
1844                            ));
1845                            self.event_bus.publish(EngineEvent::new(
1846                                "provider.call.iteration.finish",
1847                                json!({
1848                                    "sessionID": session_id,
1849                                    "messageID": user_message_id,
1850                                    "iteration": iteration,
1851                                    "finishReason": "tool_followup",
1852                                    "acceptedToolCalls": accepted_tool_calls_in_cycle,
1853                                    "rejectedToolCalls": 0,
1854                                }),
1855                            ));
1856                            continue;
1857                        }
1858                        if guard_budget_hit {
1859                            completion = summarize_guard_budget_outputs(&outputs)
1860                                .unwrap_or_else(|| {
1861                                    "This run hit the per-run tool guard budget, so tool execution was paused to avoid retries. Send a new message to start a fresh run.".to_string()
1862                                });
1863                        } else if duplicate_signature_hit_in_cycle {
1864                            completion = summarize_duplicate_signature_outputs(&outputs)
1865                                .unwrap_or_else(|| {
1866                                    "This run paused because the same tool call kept repeating. Rephrase the request or provide a different command target and retry.".to_string()
1867                                });
1868                        } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
1869                            completion = summary;
1870                        } else {
1871                            completion.clear();
1872                        }
1873                        self.event_bus.publish(EngineEvent::new(
1874                            "provider.call.iteration.finish",
1875                            json!({
1876                                "sessionID": session_id,
1877                                "messageID": user_message_id,
1878                                "iteration": iteration,
1879                                "finishReason": "tool_summary",
1880                                "acceptedToolCalls": accepted_tool_calls_in_cycle,
1881                                "rejectedToolCalls": 0,
1882                            }),
1883                        ));
1884                        break;
1885                    } else if matches!(requested_tool_mode, ToolMode::Required) {
1886                        latest_required_tool_failure_kind = classify_required_tool_failure(
1887                            &outputs,
1888                            saw_tool_call_candidate,
1889                            accepted_tool_calls_in_cycle,
1890                            provider_tool_parse_failed,
1891                            rejected_tool_call_in_cycle,
1892                        );
1893                    }
1894                }
1895
1896                if let Some(usage) = provider_usage {
1897                    self.event_bus.publish(EngineEvent::new(
1898                        "provider.usage",
1899                        json!({
1900                            "sessionID": session_id,
1901                            "correlationID": correlation_ref,
1902                            "messageID": user_message_id,
1903                            "promptTokens": usage.prompt_tokens,
1904                            "completionTokens": usage.completion_tokens,
1905                            "totalTokens": usage.total_tokens,
1906                        }),
1907                    ));
1908                }
1909
1910                if matches!(requested_tool_mode, ToolMode::Required)
1911                    && productive_tool_calls_total == 0
1912                {
1913                    if requested_write_required
1914                        && required_write_retry_count > 0
1915                        && productive_write_tool_calls_total == 0
1916                        && !is_write_invalid_args_failure_kind(latest_required_tool_failure_kind)
1917                    {
1918                        latest_required_tool_failure_kind =
1919                            RequiredToolFailureKind::WriteRequiredNotSatisfied;
1920                    }
1921                    if requested_write_required
1922                        && required_write_retry_count + 1 < strict_write_retry_max_attempts
1923                    {
1924                        required_write_retry_count += 1;
1925                        followup_context = Some(build_write_required_retry_context(
1926                            &offered_tool_preview,
1927                            latest_required_tool_failure_kind,
1928                            &text,
1929                            &requested_prewrite_requirements,
1930                            productive_workspace_inspection_total > 0,
1931                            productive_concrete_read_total > 0,
1932                            productive_web_research_total > 0,
1933                            successful_web_research_total > 0,
1934                        ));
1935                        continue;
1936                    }
1937                    let progress_made_in_cycle = productive_workspace_inspection_total > 0
1938                        || productive_concrete_read_total > 0
1939                        || productive_web_research_total > 0
1940                        || successful_web_research_total > 0;
1941                    if should_retry_nonproductive_required_tool_cycle(
1942                        requested_write_required,
1943                        false,
1944                        progress_made_in_cycle,
1945                        required_tool_retry_count,
1946                    ) {
1947                        required_tool_retry_count += 1;
1948                        followup_context = Some(build_required_tool_retry_context_for_task(
1949                            &offered_tool_preview,
1950                            latest_required_tool_failure_kind,
1951                            &text,
1952                        ));
1953                        continue;
1954                    }
1955                    completion = required_tool_mode_unsatisfied_completion(
1956                        latest_required_tool_failure_kind,
1957                    );
1958                    if !required_tool_unsatisfied_emitted {
1959                        required_tool_unsatisfied_emitted = true;
1960                        self.event_bus.publish(EngineEvent::new(
1961                            "tool.mode.required.unsatisfied",
1962                            json!({
1963                                "sessionID": session_id,
1964                                "messageID": user_message_id,
1965                                "iteration": iteration,
1966                                "selectedToolCount": allowed_tool_names.len(),
1967                                "offeredToolsPreview": offered_tool_preview,
1968                                "reason": latest_required_tool_failure_kind.code(),
1969                            }),
1970                        ));
1971                    }
1972                    self.event_bus.publish(EngineEvent::new(
1973                        "provider.call.iteration.finish",
1974                        json!({
1975                            "sessionID": session_id,
1976                            "messageID": user_message_id,
1977                            "iteration": iteration,
1978                            "finishReason": "required_tool_unsatisfied",
1979                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
1980                            "rejectedToolCalls": 0,
1981                            "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
1982                        }),
1983                    ));
1984                } else {
1985                    if completion.trim().is_empty()
1986                        && !last_tool_outputs.is_empty()
1987                        && requested_write_required
1988                        && empty_completion_retry_count == 0
1989                    {
1990                        empty_completion_retry_count += 1;
1991                        followup_context = Some(build_empty_completion_retry_context(
1992                            &offered_tool_preview,
1993                            &text,
1994                            &requested_prewrite_requirements,
1995                            productive_workspace_inspection_total > 0,
1996                            productive_concrete_read_total > 0,
1997                            productive_web_research_total > 0,
1998                            successful_web_research_total > 0,
1999                        ));
2000                        self.event_bus.publish(EngineEvent::new(
2001                            "provider.call.iteration.finish",
2002                            json!({
2003                                "sessionID": session_id,
2004                                "messageID": user_message_id,
2005                                "iteration": iteration,
2006                                "finishReason": "empty_completion_retry",
2007                                "acceptedToolCalls": accepted_tool_calls_in_cycle,
2008                                "rejectedToolCalls": 0,
2009                            }),
2010                        ));
2011                        continue;
2012                    }
2013                    let prewrite_gate = evaluate_prewrite_gate(
2014                        requested_write_required,
2015                        &requested_prewrite_requirements,
2016                        PrewriteProgress {
2017                            productive_write_tool_calls_total,
2018                            productive_workspace_inspection_total,
2019                            productive_concrete_read_total,
2020                            productive_web_research_total,
2021                            successful_web_research_total,
2022                            required_write_retry_count,
2023                            unmet_prewrite_repair_retry_count,
2024                            prewrite_gate_waived,
2025                        },
2026                    );
2027                    if should_start_prewrite_repair_before_first_write(
2028                        requested_prewrite_requirements.repair_on_unmet_requirements,
2029                        productive_write_tool_calls_total,
2030                        prewrite_gate.prewrite_satisfied,
2031                        code_workflow_requested,
2032                    ) && !prewrite_gate_waived
2033                    {
2034                        let unmet_prewrite_codes = prewrite_gate.unmet_codes.clone();
2035                        if unmet_prewrite_repair_retry_count < prewrite_repair_retry_max_attempts()
2036                        {
2037                            unmet_prewrite_repair_retry_count += 1;
2038                            let repair_attempt = unmet_prewrite_repair_retry_count;
2039                            let repair_attempts_remaining =
2040                                prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
2041                            followup_context = Some(build_prewrite_repair_retry_context(
2042                                &offered_tool_preview,
2043                                latest_required_tool_failure_kind,
2044                                &text,
2045                                &requested_prewrite_requirements,
2046                                productive_workspace_inspection_total > 0,
2047                                productive_concrete_read_total > 0,
2048                                productive_web_research_total > 0,
2049                                successful_web_research_total > 0,
2050                            ));
2051                            self.event_bus.publish(EngineEvent::new(
2052                                "provider.call.iteration.finish",
2053                                json!({
2054                                    "sessionID": session_id,
2055                                    "messageID": user_message_id,
2056                                    "iteration": iteration,
2057                                    "finishReason": "prewrite_repair_retry",
2058                                    "acceptedToolCalls": accepted_tool_calls_in_cycle,
2059                                    "rejectedToolCalls": 0,
2060                                    "requiredToolFailureReason": latest_required_tool_failure_kind.code(),
2061                                    "repair": prewrite_repair_event_payload(
2062                                        repair_attempt,
2063                                        repair_attempts_remaining,
2064                                        &unmet_prewrite_codes,
2065                                        false,
2066                                    ),
2067                                }),
2068                            ));
2069                            continue;
2070                        }
2071                        if prewrite_gate_strict_mode() {
2072                            self.event_bus.publish(EngineEvent::new(
2073                                "prewrite.gate.strict_mode.blocked",
2074                                json!({
2075                                    "sessionID": session_id,
2076                                    "messageID": user_message_id,
2077                                    "iteration": iteration,
2078                                    "unmetCodes": unmet_prewrite_codes,
2079                                }),
2080                            ));
2081                            continue;
2082                        }
2083                        prewrite_gate_waived = true;
2084                        let repair_attempt = unmet_prewrite_repair_retry_count;
2085                        let repair_attempts_remaining =
2086                            prewrite_repair_retry_max_attempts().saturating_sub(repair_attempt);
2087                        followup_context = Some(build_prewrite_waived_write_context(
2088                            &text,
2089                            &unmet_prewrite_codes,
2090                        ));
2091                        self.event_bus.publish(EngineEvent::new(
2092                            "prewrite.gate.waived.write_executed",
2093                            json!({
2094                                "sessionID": session_id,
2095                                "messageID": user_message_id,
2096                                "unmetCodes": unmet_prewrite_codes,
2097                            }),
2098                        ));
2099                        self.event_bus.publish(EngineEvent::new(
2100                            "provider.call.iteration.finish",
2101                            json!({
2102                                "sessionID": session_id,
2103                                "messageID": user_message_id,
2104                                "iteration": iteration,
2105                                "finishReason": "prewrite_gate_waived",
2106                                "acceptedToolCalls": accepted_tool_calls_in_cycle,
2107                                "rejectedToolCalls": 0,
2108                                "prewriteGateWaived": true,
2109                                "repair": prewrite_repair_event_payload(
2110                                    repair_attempt,
2111                                    repair_attempts_remaining,
2112                                    &unmet_prewrite_codes,
2113                                    true,
2114                                ),
2115                            }),
2116                        ));
2117                        continue;
2118                    }
2119                    if prewrite_gate_waived
2120                        && requested_write_required
2121                        && productive_write_tool_calls_total == 0
2122                        && required_write_retry_count + 1 < strict_write_retry_max_attempts
2123                    {
2124                        required_write_retry_count += 1;
2125                        followup_context = Some(build_write_required_retry_context(
2126                            &offered_tool_preview,
2127                            latest_required_tool_failure_kind,
2128                            &text,
2129                            &requested_prewrite_requirements,
2130                            productive_workspace_inspection_total > 0,
2131                            productive_concrete_read_total > 0,
2132                            productive_web_research_total > 0,
2133                            successful_web_research_total > 0,
2134                        ));
2135                        self.event_bus.publish(EngineEvent::new(
2136                            "provider.call.iteration.finish",
2137                            json!({
2138                                "sessionID": session_id,
2139                                "messageID": user_message_id,
2140                                "iteration": iteration,
2141                                "finishReason": "waived_write_retry",
2142                                "acceptedToolCalls": accepted_tool_calls_in_cycle,
2143                                "rejectedToolCalls": 0,
2144                            }),
2145                        ));
2146                        continue;
2147                    }
2148                    self.event_bus.publish(EngineEvent::new(
2149                        "provider.call.iteration.finish",
2150                        json!({
2151                            "sessionID": session_id,
2152                            "messageID": user_message_id,
2153                            "iteration": iteration,
2154                            "finishReason": "provider_completion",
2155                            "acceptedToolCalls": accepted_tool_calls_in_cycle,
2156                            "rejectedToolCalls": 0,
2157                        }),
2158                    ));
2159                }
2160                break;
2161            }
2162            if matches!(requested_tool_mode, ToolMode::Required) && productive_tool_calls_total == 0
2163            {
2164                completion =
2165                    required_tool_mode_unsatisfied_completion(latest_required_tool_failure_kind);
2166                if !required_tool_unsatisfied_emitted {
2167                    self.event_bus.publish(EngineEvent::new(
2168                        "tool.mode.required.unsatisfied",
2169                        json!({
2170                            "sessionID": session_id,
2171                            "messageID": user_message_id,
2172                            "selectedToolCount": tool_call_counts.len(),
2173                            "reason": latest_required_tool_failure_kind.code(),
2174                        }),
2175                    ));
2176                }
2177            }
2178            if completion.trim().is_empty()
2179                && !last_tool_outputs.is_empty()
2180                && requested_write_required
2181                && productive_write_tool_calls_total > 0
2182            {
2183                let final_prewrite_satisfied = evaluate_prewrite_gate(
2184                    requested_write_required,
2185                    &requested_prewrite_requirements,
2186                    PrewriteProgress {
2187                        productive_write_tool_calls_total,
2188                        productive_workspace_inspection_total,
2189                        productive_concrete_read_total,
2190                        productive_web_research_total,
2191                        successful_web_research_total,
2192                        required_write_retry_count,
2193                        unmet_prewrite_repair_retry_count,
2194                        prewrite_gate_waived,
2195                    },
2196                )
2197                .prewrite_satisfied;
2198                completion = synthesize_artifact_write_completion_from_tool_state(
2199                    &text,
2200                    final_prewrite_satisfied,
2201                    prewrite_gate_waived,
2202                );
2203            }
2204            if completion.trim().is_empty()
2205                && !last_tool_outputs.is_empty()
2206                && should_generate_post_tool_final_narrative(
2207                    requested_tool_mode,
2208                    productive_tool_calls_total,
2209                )
2210            {
2211                if let Some(narrative) = self
2212                    .generate_final_narrative_without_tools(
2213                        &session_id,
2214                        &active_agent,
2215                        Some(provider_id.as_str()),
2216                        Some(model_id_value.as_str()),
2217                        cancel.clone(),
2218                        &last_tool_outputs,
2219                    )
2220                    .await
2221                {
2222                    completion = narrative;
2223                }
2224            }
2225            if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
2226                if let Some(summary) = summarize_auth_pending_outputs(&last_tool_outputs) {
2227                    completion = summary;
2228                } else if let Some(hint) =
2229                    summarize_terminal_tool_failure_for_user(&last_tool_outputs)
2230                {
2231                    completion = hint;
2232                } else {
2233                    let preview = summarize_user_visible_tool_outputs(&last_tool_outputs);
2234                    if preview.trim().is_empty() {
2235                        completion = "I used tools for this request, but I couldn't turn the results into a clean final answer. Please retry with the docs page URL, docs path, or exact search query you want me to use.".to_string();
2236                    } else {
2237                        completion = format!(
2238                            "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
2239                            preview
2240                        );
2241                    }
2242                }
2243            }
2244            if completion.trim().is_empty() {
2245                completion =
2246                    "I couldn't produce a final response for that run. Please retry your request."
2247                        .to_string();
2248            }
2249            // M-3: Gate fires unconditionally when email was requested but no email
2250            // action tool was executed. The completion text is NOT consulted — this
2251            // prevents the model from bypassing the gate by rephrasing, and prevents
2252            // false positives on legitimate text containing email keywords.
2253            if email_delivery_requested && !email_action_executed {
2254                let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
2255                    .to_string();
2256                if let Some(note) = latest_email_action_note.as_ref() {
2257                    fallback.push_str("\n\nLast email tool status: ");
2258                    fallback.push_str(note);
2259                }
2260                fallback.push_str(
2261                    "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
2262                );
2263                completion = fallback;
2264            }
2265            completion = strip_model_control_markers(&completion);
2266            truncate_text(&completion, 16_000)
2267        };
2268        emit_event(
2269            Level::INFO,
2270            ProcessKind::Engine,
2271            ObservabilityEvent {
2272                event: "provider.call.finish",
2273                component: "engine.loop",
2274                correlation_id: correlation_ref,
2275                session_id: Some(&session_id),
2276                run_id: None,
2277                message_id: Some(&user_message_id),
2278                provider_id: Some(provider_id.as_str()),
2279                model_id,
2280                status: Some("ok"),
2281                error_code: None,
2282                detail: Some("provider stream complete"),
2283            },
2284        );
2285        if active_agent.name.eq_ignore_ascii_case("plan") {
2286            emit_plan_todo_fallback(
2287                self.storage.clone(),
2288                &self.event_bus,
2289                &session_id,
2290                &user_message_id,
2291                &completion,
2292            )
2293            .await;
2294            let todos_after_fallback = self.storage.get_todos(&session_id).await;
2295            if todos_after_fallback.is_empty() && !question_tool_used {
2296                emit_plan_question_fallback(
2297                    self.storage.clone(),
2298                    &self.event_bus,
2299                    &session_id,
2300                    &user_message_id,
2301                    &completion,
2302                )
2303                .await;
2304            }
2305        }
2306        if cancel.is_cancelled() {
2307            self.event_bus.publish(EngineEvent::new(
2308                "session.status",
2309                json!({"sessionID": session_id, "status":"cancelled"}),
2310            ));
2311            self.cancellations.remove(&session_id).await;
2312            return Ok(());
2313        }
2314        let assistant = Message::new(
2315            MessageRole::Assistant,
2316            vec![MessagePart::Text {
2317                text: completion.clone(),
2318            }],
2319        );
2320        let assistant_message_id = assistant.id.clone();
2321        self.storage.append_message(&session_id, assistant).await?;
2322        let final_part = WireMessagePart::text(
2323            &session_id,
2324            &assistant_message_id,
2325            truncate_text(&completion, 16_000),
2326        );
2327        self.event_bus.publish(EngineEvent::new(
2328            "message.part.updated",
2329            json!({"part": final_part}),
2330        ));
2331        self.event_bus.publish(EngineEvent::new(
2332            "session.updated",
2333            json!({"sessionID": session_id, "status":"idle"}),
2334        ));
2335        self.event_bus.publish(EngineEvent::new(
2336            "session.status",
2337            json!({"sessionID": session_id, "status":"idle"}),
2338        ));
2339        self.cancellations.remove(&session_id).await;
2340        Ok(())
2341    }
2342
2343    pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
2344        self.providers.default_complete(&prompt).await
2345    }
2346
2347    pub async fn run_oneshot_for_provider(
2348        &self,
2349        prompt: String,
2350        provider_id: Option<&str>,
2351    ) -> anyhow::Result<String> {
2352        self.providers
2353            .complete_for_provider(provider_id, &prompt, None)
2354            .await
2355    }
2356
2357    #[allow(clippy::too_many_arguments)]
2358    async fn execute_tool_with_permission(
2359        &self,
2360        session_id: &str,
2361        message_id: &str,
2362        tool: String,
2363        args: Value,
2364        initial_tool_call_id: Option<String>,
2365        equipped_skills: Option<&[String]>,
2366        latest_user_text: &str,
2367        write_required: bool,
2368        latest_assistant_context: Option<&str>,
2369        cancel: CancellationToken,
2370    ) -> anyhow::Result<Option<String>> {
2371        let tool = normalize_tool_name(&tool);
2372        let raw_args = args.clone();
2373        let publish_tool_effect = |tool_call_id: Option<&str>,
2374                                   phase: ToolEffectLedgerPhase,
2375                                   status: ToolEffectLedgerStatus,
2376                                   args: &Value,
2377                                   metadata: Option<&Value>,
2378                                   output: Option<&str>,
2379                                   error: Option<&str>| {
2380            self.event_bus
2381                .publish(tool_effect_ledger_event(build_tool_effect_ledger_record(
2382                    session_id,
2383                    message_id,
2384                    tool_call_id,
2385                    &tool,
2386                    phase,
2387                    status,
2388                    args,
2389                    metadata,
2390                    output,
2391                    error,
2392                )));
2393        };
2394        let normalized = normalize_tool_args_with_mode(
2395            &tool,
2396            args,
2397            latest_user_text,
2398            latest_assistant_context.unwrap_or_default(),
2399            if write_required {
2400                WritePathRecoveryMode::OutputTargetOnly
2401            } else {
2402                WritePathRecoveryMode::Heuristic
2403            },
2404        );
2405        let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
2406        let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
2407        self.event_bus.publish(EngineEvent::new(
2408            "tool.args.normalized",
2409            json!({
2410                "sessionID": session_id,
2411                "messageID": message_id,
2412                "tool": tool,
2413                "argsSource": normalized.args_source,
2414                "argsIntegrity": normalized.args_integrity,
2415                "rawArgsState": normalized.raw_args_state.as_str(),
2416                "rawArgsPreview": raw_args_preview,
2417                "normalizedArgsPreview": normalized_args_preview,
2418                "query": normalized.query,
2419                "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2420                "requestID": Value::Null
2421            }),
2422        ));
2423        if normalized.args_integrity == "recovered" {
2424            self.event_bus.publish(EngineEvent::new(
2425                "tool.args.recovered",
2426                json!({
2427                    "sessionID": session_id,
2428                    "messageID": message_id,
2429                    "tool": tool,
2430                    "argsSource": normalized.args_source,
2431                    "rawArgsPreview": raw_args_preview,
2432                    "normalizedArgsPreview": normalized_args_preview,
2433                    "query": normalized.query,
2434                    "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2435                    "requestID": Value::Null
2436                }),
2437            ));
2438        }
2439        if normalized.missing_terminal {
2440            let missing_reason = normalized
2441                .missing_terminal_reason
2442                .clone()
2443                .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
2444            let latest_user_preview = truncate_text(latest_user_text, 500);
2445            let latest_assistant_preview =
2446                truncate_text(latest_assistant_context.unwrap_or_default(), 500);
2447            self.event_bus.publish(EngineEvent::new(
2448                "tool.args.missing_terminal",
2449                json!({
2450                    "sessionID": session_id,
2451                    "messageID": message_id,
2452                    "tool": tool,
2453                    "argsSource": normalized.args_source,
2454                    "argsIntegrity": normalized.args_integrity,
2455                    "rawArgsState": normalized.raw_args_state.as_str(),
2456                    "requestID": Value::Null,
2457                    "error": missing_reason,
2458                    "rawArgsPreview": raw_args_preview,
2459                    "normalizedArgsPreview": normalized_args_preview,
2460                    "latestUserPreview": latest_user_preview,
2461                    "latestAssistantPreview": latest_assistant_preview,
2462                }),
2463            ));
2464            if tool == "write" {
2465                tracing::warn!(
2466                    session_id = %session_id,
2467                    message_id = %message_id,
2468                    tool = %tool,
2469                    reason = %missing_reason,
2470                    args_source = %normalized.args_source,
2471                    args_integrity = %normalized.args_integrity,
2472                    raw_args_state = %normalized.raw_args_state.as_str(),
2473                    raw_args = %raw_args_preview,
2474                    normalized_args = %normalized_args_preview,
2475                    latest_user = %latest_user_preview,
2476                    latest_assistant = %latest_assistant_preview,
2477                    "write tool arguments missing terminal field"
2478                );
2479            }
2480            let best_effort_args = persisted_failed_tool_args(&raw_args, &normalized.args);
2481            let mut failed_part = WireMessagePart::tool_result(
2482                session_id,
2483                message_id,
2484                tool.clone(),
2485                Some(best_effort_args),
2486                json!(null),
2487            );
2488            failed_part.state = Some("failed".to_string());
2489            let surfaced_reason =
2490                provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
2491                    .unwrap_or_else(|| missing_reason.clone());
2492            failed_part.error = Some(surfaced_reason.clone());
2493            self.event_bus.publish(EngineEvent::new(
2494                "message.part.updated",
2495                json!({"part": failed_part}),
2496            ));
2497            publish_tool_effect(
2498                None,
2499                ToolEffectLedgerPhase::Outcome,
2500                ToolEffectLedgerStatus::Blocked,
2501                &normalized.args,
2502                None,
2503                None,
2504                Some(&surfaced_reason),
2505            );
2506            return Ok(Some(surfaced_reason));
2507        }
2508
2509        let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
2510            Ok(args) => args,
2511            Err(message) => {
2512                publish_tool_effect(
2513                    None,
2514                    ToolEffectLedgerPhase::Outcome,
2515                    ToolEffectLedgerStatus::Blocked,
2516                    &raw_args,
2517                    None,
2518                    None,
2519                    Some(&message),
2520                );
2521                return Ok(Some(message));
2522            }
2523        };
2524        if let Some(allowed_tools) = self
2525            .session_allowed_tools
2526            .read()
2527            .await
2528            .get(session_id)
2529            .cloned()
2530        {
2531            if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
2532                let reason = format!("Tool `{tool}` is not allowed for this run.");
2533                publish_tool_effect(
2534                    None,
2535                    ToolEffectLedgerPhase::Outcome,
2536                    ToolEffectLedgerStatus::Blocked,
2537                    &args,
2538                    None,
2539                    None,
2540                    Some(&reason),
2541                );
2542                return Ok(Some(reason));
2543            }
2544        }
2545        if let Some(hook) = self.tool_policy_hook.read().await.clone() {
2546            let decision = hook
2547                .evaluate_tool(ToolPolicyContext {
2548                    session_id: session_id.to_string(),
2549                    message_id: message_id.to_string(),
2550                    tool: tool.clone(),
2551                    args: args.clone(),
2552                })
2553                .await?;
2554            if !decision.allowed {
2555                let reason = decision
2556                    .reason
2557                    .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
2558                let mut blocked_part = WireMessagePart::tool_result(
2559                    session_id,
2560                    message_id,
2561                    tool.clone(),
2562                    Some(args.clone()),
2563                    json!(null),
2564                );
2565                blocked_part.state = Some("failed".to_string());
2566                blocked_part.error = Some(reason.clone());
2567                self.event_bus.publish(EngineEvent::new(
2568                    "message.part.updated",
2569                    json!({"part": blocked_part}),
2570                ));
2571                publish_tool_effect(
2572                    None,
2573                    ToolEffectLedgerPhase::Outcome,
2574                    ToolEffectLedgerStatus::Blocked,
2575                    &args,
2576                    None,
2577                    None,
2578                    Some(&reason),
2579                );
2580                return Ok(Some(reason));
2581            }
2582        }
2583        let mut tool_call_id: Option<String> = initial_tool_call_id;
2584        if let Some(violation) = self
2585            .workspace_sandbox_violation(session_id, &tool, &args)
2586            .await
2587        {
2588            let mut blocked_part = WireMessagePart::tool_result(
2589                session_id,
2590                message_id,
2591                tool.clone(),
2592                Some(args.clone()),
2593                json!(null),
2594            );
2595            blocked_part.state = Some("failed".to_string());
2596            blocked_part.error = Some(violation.clone());
2597            self.event_bus.publish(EngineEvent::new(
2598                "message.part.updated",
2599                json!({"part": blocked_part}),
2600            ));
2601            publish_tool_effect(
2602                tool_call_id.as_deref(),
2603                ToolEffectLedgerPhase::Outcome,
2604                ToolEffectLedgerStatus::Blocked,
2605                &args,
2606                None,
2607                None,
2608                Some(&violation),
2609            );
2610            return Ok(Some(violation));
2611        }
2612        let rule = self
2613            .plugins
2614            .permission_override(&tool)
2615            .await
2616            .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
2617        if matches!(rule, PermissionAction::Deny) {
2618            let reason = format!("Permission denied for tool `{tool}` by policy.");
2619            publish_tool_effect(
2620                tool_call_id.as_deref(),
2621                ToolEffectLedgerPhase::Outcome,
2622                ToolEffectLedgerStatus::Blocked,
2623                &args,
2624                None,
2625                None,
2626                Some(&reason),
2627            );
2628            return Ok(Some(reason));
2629        }
2630
2631        let mut effective_args = args.clone();
2632        if matches!(rule, PermissionAction::Ask) {
2633            let auto_approve_permissions = self
2634                .session_auto_approve_permissions
2635                .read()
2636                .await
2637                .get(session_id)
2638                .copied()
2639                .unwrap_or(false);
2640            if auto_approve_permissions {
2641                // Governance audit: if args were recovered via heuristics and the tool is
2642                // mutating, log a WARN so recovered writes are never silent in automation
2643                // mode. Does not block — operators must opt out via TANDEM_AUTO_APPROVE_RECOVERED_ARGS=false
2644                // if they want a hard block (reserved for strict automation policy).
2645                if normalized.args_integrity == "recovered" && is_workspace_write_tool(&tool) {
2646                    tracing::warn!(
2647                        session_id = %session_id,
2648                        message_id = %message_id,
2649                        tool = %tool,
2650                        args_source = %normalized.args_source,
2651                        "auto-approve granted for mutating tool with recovered args; verify intent"
2652                    );
2653                    self.event_bus.publish(EngineEvent::new(
2654                        "tool.args.recovered_write_auto_approved",
2655                        json!({
2656                            "sessionID": session_id,
2657                            "messageID": message_id,
2658                            "tool": tool,
2659                            "argsSource": normalized.args_source,
2660                            "argsIntegrity": normalized.args_integrity,
2661                        }),
2662                    ));
2663                }
2664                self.event_bus.publish(EngineEvent::new(
2665                    "permission.auto_approved",
2666                    json!({
2667                        "sessionID": session_id,
2668                        "messageID": message_id,
2669                        "tool": tool,
2670                    }),
2671                ));
2672                effective_args = args;
2673            } else {
2674                let pending = self
2675                    .permissions
2676                    .ask_for_session_with_context(
2677                        Some(session_id),
2678                        &tool,
2679                        args.clone(),
2680                        Some(crate::PermissionArgsContext {
2681                            args_source: normalized.args_source.clone(),
2682                            args_integrity: normalized.args_integrity.clone(),
2683                            query: normalized.query.clone(),
2684                        }),
2685                    )
2686                    .await;
2687                let mut pending_part = WireMessagePart::tool_invocation(
2688                    session_id,
2689                    message_id,
2690                    tool.clone(),
2691                    args.clone(),
2692                );
2693                pending_part.id = Some(pending.id.clone());
2694                tool_call_id = Some(pending.id.clone());
2695                pending_part.state = Some("pending".to_string());
2696                self.event_bus.publish(EngineEvent::new(
2697                    "message.part.updated",
2698                    json!({"part": pending_part}),
2699                ));
2700                let reply = self
2701                    .permissions
2702                    .wait_for_reply_with_timeout(
2703                        &pending.id,
2704                        cancel.clone(),
2705                        Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2706                    )
2707                    .await;
2708                let (reply, timed_out) = reply;
2709                if cancel.is_cancelled() {
2710                    return Ok(None);
2711                }
2712                if timed_out {
2713                    let timeout_ms = permission_wait_timeout_ms();
2714                    self.event_bus.publish(EngineEvent::new(
2715                        "permission.wait.timeout",
2716                        json!({
2717                            "sessionID": session_id,
2718                            "messageID": message_id,
2719                            "tool": tool,
2720                            "requestID": pending.id,
2721                            "timeoutMs": timeout_ms,
2722                        }),
2723                    ));
2724                    let mut timeout_part = WireMessagePart::tool_result(
2725                        session_id,
2726                        message_id,
2727                        tool.clone(),
2728                        Some(args.clone()),
2729                        json!(null),
2730                    );
2731                    timeout_part.id = Some(pending.id);
2732                    timeout_part.state = Some("failed".to_string());
2733                    timeout_part.error = Some(format!(
2734                        "Permission request timed out after {} ms",
2735                        timeout_ms
2736                    ));
2737                    self.event_bus.publish(EngineEvent::new(
2738                        "message.part.updated",
2739                        json!({"part": timeout_part}),
2740                    ));
2741                    let timeout_reason = format!(
2742                        "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2743                    );
2744                    publish_tool_effect(
2745                        tool_call_id.as_deref(),
2746                        ToolEffectLedgerPhase::Outcome,
2747                        ToolEffectLedgerStatus::Blocked,
2748                        &args,
2749                        None,
2750                        None,
2751                        Some(&timeout_reason),
2752                    );
2753                    return Ok(Some(format!(
2754                        "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2755                    )));
2756                }
2757                let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2758                if !approved {
2759                    let mut denied_part = WireMessagePart::tool_result(
2760                        session_id,
2761                        message_id,
2762                        tool.clone(),
2763                        Some(args.clone()),
2764                        json!(null),
2765                    );
2766                    denied_part.id = Some(pending.id);
2767                    denied_part.state = Some("denied".to_string());
2768                    denied_part.error = Some("Permission denied by user".to_string());
2769                    self.event_bus.publish(EngineEvent::new(
2770                        "message.part.updated",
2771                        json!({"part": denied_part}),
2772                    ));
2773                    let denied_reason = format!("Permission denied for tool `{tool}` by user.");
2774                    publish_tool_effect(
2775                        tool_call_id.as_deref(),
2776                        ToolEffectLedgerPhase::Outcome,
2777                        ToolEffectLedgerStatus::Blocked,
2778                        &args,
2779                        None,
2780                        None,
2781                        Some(&denied_reason),
2782                    );
2783                    return Ok(Some(format!(
2784                        "Permission denied for tool `{tool}` by user."
2785                    )));
2786                }
2787                effective_args = args;
2788            }
2789        }
2790
2791        let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2792        let session = self.storage.get_session(session_id).await;
2793        if let (Some(obj), Some(session)) = (args.as_object_mut(), session.as_ref()) {
2794            obj.insert(
2795                "__session_id".to_string(),
2796                Value::String(session_id.to_string()),
2797            );
2798            if let Some(project_id) = session.project_id.clone() {
2799                obj.insert(
2800                    "__project_id".to_string(),
2801                    Value::String(project_id.clone()),
2802                );
2803                if project_id.starts_with("channel-public::") {
2804                    obj.insert(
2805                        "__memory_max_visible_scope".to_string(),
2806                        Value::String("project".to_string()),
2807                    );
2808                }
2809            }
2810        }
2811        let tool_context = self.resolve_tool_execution_context(session_id).await;
2812        if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2813            args = rewrite_workspace_alias_tool_args(&tool, args, workspace_root);
2814            if let Some(obj) = args.as_object_mut() {
2815                obj.insert(
2816                    "__workspace_root".to_string(),
2817                    Value::String(workspace_root.clone()),
2818                );
2819                obj.insert(
2820                    "__effective_cwd".to_string(),
2821                    Value::String(effective_cwd.clone()),
2822                );
2823                obj.insert(
2824                    "__session_id".to_string(),
2825                    Value::String(session_id.to_string()),
2826                );
2827                if let Some(project_id) = project_id.clone() {
2828                    obj.insert("__project_id".to_string(), Value::String(project_id));
2829                }
2830            }
2831            tracing::info!(
2832                "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2833                session_id,
2834                tool,
2835                workspace_root,
2836                effective_cwd,
2837                project_id.clone().unwrap_or_default()
2838            );
2839        }
2840        let mut invoke_part =
2841            WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2842        if let Some(call_id) = tool_call_id.clone() {
2843            invoke_part.id = Some(call_id);
2844        }
2845        let invoke_part_id = invoke_part.id.clone();
2846        self.event_bus.publish(EngineEvent::new(
2847            "message.part.updated",
2848            json!({"part": invoke_part}),
2849        ));
2850        let args_for_side_events = args.clone();
2851        let mutation_checkpoint = prepare_mutation_checkpoint(&tool, &args_for_side_events);
2852        let progress_sink: SharedToolProgressSink = std::sync::Arc::new(EngineToolProgressSink {
2853            event_bus: self.event_bus.clone(),
2854            session_id: session_id.to_string(),
2855            message_id: message_id.to_string(),
2856            tool_call_id: invoke_part_id.clone(),
2857            source_tool: tool.clone(),
2858        });
2859        publish_tool_effect(
2860            invoke_part_id.as_deref(),
2861            ToolEffectLedgerPhase::Invocation,
2862            ToolEffectLedgerStatus::Started,
2863            &args_for_side_events,
2864            None,
2865            None,
2866            None,
2867        );
2868        let publish_mutation_checkpoint =
2869            |tool_call_id: Option<&str>, outcome: MutationCheckpointOutcome| {
2870                if let Some(baseline) = mutation_checkpoint.as_ref() {
2871                    self.event_bus.publish(mutation_checkpoint_event(
2872                        finalize_mutation_checkpoint_record(
2873                            session_id,
2874                            message_id,
2875                            tool_call_id,
2876                            baseline,
2877                            outcome,
2878                        ),
2879                    ));
2880                }
2881            };
2882        if tool == "spawn_agent" {
2883            let hook = self.spawn_agent_hook.read().await.clone();
2884            if let Some(hook) = hook {
2885                let spawned = hook
2886                    .spawn_agent(SpawnAgentToolContext {
2887                        session_id: session_id.to_string(),
2888                        message_id: message_id.to_string(),
2889                        tool_call_id: invoke_part_id.clone(),
2890                        args: args_for_side_events.clone(),
2891                    })
2892                    .await?;
2893                let output = self.plugins.transform_tool_output(spawned.output).await;
2894                let output = truncate_text(&output, 16_000);
2895                emit_tool_side_events(
2896                    self.storage.clone(),
2897                    &self.event_bus,
2898                    ToolSideEventContext {
2899                        session_id,
2900                        message_id,
2901                        tool: &tool,
2902                        args: &args_for_side_events,
2903                        metadata: &spawned.metadata,
2904                        workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2905                        effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2906                    },
2907                )
2908                .await;
2909                let mut result_part = WireMessagePart::tool_result(
2910                    session_id,
2911                    message_id,
2912                    tool.clone(),
2913                    Some(args_for_side_events.clone()),
2914                    json!(output.clone()),
2915                );
2916                result_part.id = invoke_part_id.clone();
2917                self.event_bus.publish(EngineEvent::new(
2918                    "message.part.updated",
2919                    json!({"part": result_part}),
2920                ));
2921                publish_tool_effect(
2922                    invoke_part_id.as_deref(),
2923                    ToolEffectLedgerPhase::Outcome,
2924                    ToolEffectLedgerStatus::Succeeded,
2925                    &args_for_side_events,
2926                    Some(&spawned.metadata),
2927                    Some(&output),
2928                    None,
2929                );
2930                publish_mutation_checkpoint(
2931                    invoke_part_id.as_deref(),
2932                    MutationCheckpointOutcome::Succeeded,
2933                );
2934                return Ok(Some(truncate_text(
2935                    &format!("Tool `{tool}` result:\n{output}"),
2936                    16_000,
2937                )));
2938            }
2939            let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2940            let mut failed_part = WireMessagePart::tool_result(
2941                session_id,
2942                message_id,
2943                tool.clone(),
2944                Some(args_for_side_events.clone()),
2945                json!(null),
2946            );
2947            failed_part.id = invoke_part_id.clone();
2948            failed_part.state = Some("failed".to_string());
2949            failed_part.error = Some(output.to_string());
2950            self.event_bus.publish(EngineEvent::new(
2951                "message.part.updated",
2952                json!({"part": failed_part}),
2953            ));
2954            publish_tool_effect(
2955                invoke_part_id.as_deref(),
2956                ToolEffectLedgerPhase::Outcome,
2957                ToolEffectLedgerStatus::Failed,
2958                &args_for_side_events,
2959                None,
2960                None,
2961                Some(output),
2962            );
2963            publish_mutation_checkpoint(
2964                invoke_part_id.as_deref(),
2965                MutationCheckpointOutcome::Failed,
2966            );
2967            return Ok(Some(output.to_string()));
2968        }
2969        // Batch governance: validate sub-calls against engine policy and inject execution context
2970        // before delegating to BatchTool. This ensures sub-calls cannot bypass permissions,
2971        // sandbox checks, or allowed-tool lists, and that they receive the correct workspace
2972        // context (__workspace_root, __effective_cwd, __session_id, __project_id).
2973        //
2974        // By this point `args` already has those keys injected (see context injection above).
2975        if tool == "batch" {
2976            let allowed_tools = self
2977                .session_allowed_tools
2978                .read()
2979                .await
2980                .get(session_id)
2981                .cloned()
2982                .unwrap_or_default();
2983
2984            // Extract parent execution context from already-injected batch args.
2985            let ctx_workspace_root = args
2986                .get("__workspace_root")
2987                .and_then(|v| v.as_str())
2988                .map(ToString::to_string);
2989            let ctx_effective_cwd = args
2990                .get("__effective_cwd")
2991                .and_then(|v| v.as_str())
2992                .map(ToString::to_string);
2993            let ctx_session_id = args
2994                .get("__session_id")
2995                .and_then(|v| v.as_str())
2996                .map(ToString::to_string);
2997            let ctx_project_id = args
2998                .get("__project_id")
2999                .and_then(|v| v.as_str())
3000                .map(ToString::to_string);
3001
3002            // Process each sub-call: check governance, inject context.
3003            let raw_calls = args
3004                .get("tool_calls")
3005                .and_then(|v| v.as_array())
3006                .cloned()
3007                .unwrap_or_default();
3008
3009            let mut governed_calls: Vec<Value> = Vec::new();
3010            for mut call in raw_calls {
3011                let (sub_tool, mut sub_args) = {
3012                    let obj = match call.as_object() {
3013                        Some(o) => o,
3014                        None => {
3015                            governed_calls.push(call);
3016                            continue;
3017                        }
3018                    };
3019                    let tool_raw = non_empty_string_at(obj, "tool")
3020                        .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3021                        .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3022                        .or_else(|| non_empty_string_at(obj, "name"));
3023                    let sub_tool = match tool_raw {
3024                        Some(t) => normalize_tool_name(t),
3025                        None => {
3026                            governed_calls.push(call);
3027                            continue;
3028                        }
3029                    };
3030                    let sub_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3031                    (sub_tool, sub_args)
3032                };
3033
3034                // 1. Allowed-tools check.
3035                if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &sub_tool) {
3036                    // Strip this sub-call: replace it with an explanatory result.
3037                    if let Some(obj) = call.as_object_mut() {
3038                        obj.insert(
3039                            "_blocked".to_string(),
3040                            Value::String(format!(
3041                                "batch sub-call skipped: tool `{sub_tool}` is not in the allowed list for this run"
3042                            )),
3043                        );
3044                    }
3045                    governed_calls.push(call);
3046                    continue;
3047                }
3048
3049                // 2. Workspace sandbox check.
3050                if let Some(violation) = self
3051                    .workspace_sandbox_violation(session_id, &sub_tool, &sub_args)
3052                    .await
3053                {
3054                    if let Some(obj) = call.as_object_mut() {
3055                        obj.insert(
3056                            "_blocked".to_string(),
3057                            Value::String(format!("batch sub-call skipped: {violation}")),
3058                        );
3059                    }
3060                    governed_calls.push(call);
3061                    continue;
3062                }
3063
3064                // 3. Inject parent execution context into sub-call args.
3065                if let Some(sub_obj) = sub_args.as_object_mut() {
3066                    if let Some(ref v) = ctx_workspace_root {
3067                        sub_obj
3068                            .entry("__workspace_root")
3069                            .or_insert_with(|| Value::String(v.clone()));
3070                    }
3071                    if let Some(ref v) = ctx_effective_cwd {
3072                        sub_obj
3073                            .entry("__effective_cwd")
3074                            .or_insert_with(|| Value::String(v.clone()));
3075                    }
3076                    if let Some(ref v) = ctx_session_id {
3077                        sub_obj
3078                            .entry("__session_id")
3079                            .or_insert_with(|| Value::String(v.clone()));
3080                    }
3081                    if let Some(ref v) = ctx_project_id {
3082                        sub_obj
3083                            .entry("__project_id")
3084                            .or_insert_with(|| Value::String(v.clone()));
3085                    }
3086                }
3087
3088                // Write enriched args back into the call object.
3089                if let Some(obj) = call.as_object_mut() {
3090                    obj.insert("args".to_string(), sub_args);
3091                }
3092                governed_calls.push(call);
3093            }
3094
3095            // Rebuild batch args with the governed sub-calls.
3096            if let Some(obj) = args.as_object_mut() {
3097                obj.insert("tool_calls".to_string(), Value::Array(governed_calls));
3098            }
3099        }
3100        let result = match self
3101            .execute_tool_with_timeout(&tool, args, cancel.clone(), Some(progress_sink))
3102            .await
3103        {
3104            Ok(result) => result,
3105            Err(err) => {
3106                let err_text = err.to_string();
3107                if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
3108                    let timeout_ms = tool_exec_timeout_ms();
3109                    let timeout_output = format!(
3110                        "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
3111                    );
3112                    let mut failed_part = WireMessagePart::tool_result(
3113                        session_id,
3114                        message_id,
3115                        tool.clone(),
3116                        Some(args_for_side_events.clone()),
3117                        json!(null),
3118                    );
3119                    failed_part.id = invoke_part_id.clone();
3120                    failed_part.state = Some("failed".to_string());
3121                    failed_part.error = Some(timeout_output.clone());
3122                    self.event_bus.publish(EngineEvent::new(
3123                        "message.part.updated",
3124                        json!({"part": failed_part}),
3125                    ));
3126                    publish_tool_effect(
3127                        invoke_part_id.as_deref(),
3128                        ToolEffectLedgerPhase::Outcome,
3129                        ToolEffectLedgerStatus::Failed,
3130                        &args_for_side_events,
3131                        None,
3132                        None,
3133                        Some(&timeout_output),
3134                    );
3135                    publish_mutation_checkpoint(
3136                        invoke_part_id.as_deref(),
3137                        MutationCheckpointOutcome::Failed,
3138                    );
3139                    return Ok(Some(timeout_output));
3140                }
3141                if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
3142                    self.event_bus.publish(EngineEvent::new(
3143                        "mcp.auth.required",
3144                        json!({
3145                            "sessionID": session_id,
3146                            "messageID": message_id,
3147                            "tool": tool.clone(),
3148                            "server": auth.server,
3149                            "authorizationUrl": auth.authorization_url,
3150                            "message": auth.message,
3151                            "challengeId": auth.challenge_id
3152                        }),
3153                    ));
3154                    let auth_output = format!(
3155                        "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3156                        tool, auth.message, auth.authorization_url
3157                    );
3158                    let mut result_part = WireMessagePart::tool_result(
3159                        session_id,
3160                        message_id,
3161                        tool.clone(),
3162                        Some(args_for_side_events.clone()),
3163                        json!(auth_output.clone()),
3164                    );
3165                    result_part.id = invoke_part_id.clone();
3166                    self.event_bus.publish(EngineEvent::new(
3167                        "message.part.updated",
3168                        json!({"part": result_part}),
3169                    ));
3170                    publish_tool_effect(
3171                        invoke_part_id.as_deref(),
3172                        ToolEffectLedgerPhase::Outcome,
3173                        ToolEffectLedgerStatus::Blocked,
3174                        &args_for_side_events,
3175                        None,
3176                        Some(&auth_output),
3177                        Some(&auth.message),
3178                    );
3179                    publish_mutation_checkpoint(
3180                        invoke_part_id.as_deref(),
3181                        MutationCheckpointOutcome::Blocked,
3182                    );
3183                    return Ok(Some(truncate_text(
3184                        &format!("Tool `{tool}` result:\n{auth_output}"),
3185                        16_000,
3186                    )));
3187                }
3188                let mut failed_part = WireMessagePart::tool_result(
3189                    session_id,
3190                    message_id,
3191                    tool.clone(),
3192                    Some(args_for_side_events.clone()),
3193                    json!(null),
3194                );
3195                failed_part.id = invoke_part_id.clone();
3196                failed_part.state = Some("failed".to_string());
3197                failed_part.error = Some(err_text.clone());
3198                self.event_bus.publish(EngineEvent::new(
3199                    "message.part.updated",
3200                    json!({"part": failed_part}),
3201                ));
3202                publish_tool_effect(
3203                    invoke_part_id.as_deref(),
3204                    ToolEffectLedgerPhase::Outcome,
3205                    ToolEffectLedgerStatus::Failed,
3206                    &args_for_side_events,
3207                    None,
3208                    None,
3209                    Some(&err_text),
3210                );
3211                publish_mutation_checkpoint(
3212                    invoke_part_id.as_deref(),
3213                    MutationCheckpointOutcome::Failed,
3214                );
3215                return Err(err);
3216            }
3217        };
3218        if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3219            let event_name = if auth.pending && auth.blocked {
3220                "mcp.auth.pending"
3221            } else {
3222                "mcp.auth.required"
3223            };
3224            self.event_bus.publish(EngineEvent::new(
3225                event_name,
3226                json!({
3227                    "sessionID": session_id,
3228                    "messageID": message_id,
3229                    "tool": tool.clone(),
3230                    "server": auth.server,
3231                    "authorizationUrl": auth.authorization_url,
3232                    "message": auth.message,
3233                    "challengeId": auth.challenge_id,
3234                    "pending": auth.pending,
3235                    "blocked": auth.blocked,
3236                    "retryAfterMs": auth.retry_after_ms
3237                }),
3238            ));
3239        }
3240        emit_tool_side_events(
3241            self.storage.clone(),
3242            &self.event_bus,
3243            ToolSideEventContext {
3244                session_id,
3245                message_id,
3246                tool: &tool,
3247                args: &args_for_side_events,
3248                metadata: &result.metadata,
3249                workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
3250                effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
3251            },
3252        )
3253        .await;
3254        let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3255            if auth.pending && auth.blocked {
3256                let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
3257                format!(
3258                    "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
3259                    tool, auth.message, auth.authorization_url, retry_after_secs
3260                )
3261            } else {
3262                format!(
3263                    "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3264                    tool, auth.message, auth.authorization_url
3265                )
3266            }
3267        } else {
3268            self.plugins.transform_tool_output(result.output).await
3269        };
3270        let output = truncate_text(&output, 16_000);
3271        let mut result_part = WireMessagePart::tool_result(
3272            session_id,
3273            message_id,
3274            tool.clone(),
3275            Some(args_for_side_events.clone()),
3276            json!(output.clone()),
3277        );
3278        result_part.id = invoke_part_id.clone();
3279        self.event_bus.publish(EngineEvent::new(
3280            "message.part.updated",
3281            json!({"part": result_part}),
3282        ));
3283        publish_tool_effect(
3284            invoke_part_id.as_deref(),
3285            ToolEffectLedgerPhase::Outcome,
3286            ToolEffectLedgerStatus::Succeeded,
3287            &args_for_side_events,
3288            Some(&result.metadata),
3289            Some(&output),
3290            None,
3291        );
3292        publish_mutation_checkpoint(
3293            invoke_part_id.as_deref(),
3294            MutationCheckpointOutcome::Succeeded,
3295        );
3296        Ok(Some(truncate_text(
3297            &format!("Tool `{tool}` result:\n{output}"),
3298            16_000,
3299        )))
3300    }
3301
3302    async fn execute_tool_with_timeout(
3303        &self,
3304        tool: &str,
3305        args: Value,
3306        cancel: CancellationToken,
3307        progress: Option<SharedToolProgressSink>,
3308    ) -> anyhow::Result<tandem_types::ToolResult> {
3309        let timeout_ms = tool_exec_timeout_ms() as u64;
3310        match tokio::time::timeout(
3311            Duration::from_millis(timeout_ms),
3312            self.tools
3313                .execute_with_cancel_and_progress(tool, args, cancel, progress),
3314        )
3315        .await
3316        {
3317            Ok(result) => result,
3318            Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
3319        }
3320    }
3321
3322    async fn find_recent_matching_user_message_id(
3323        &self,
3324        session_id: &str,
3325        text: &str,
3326    ) -> Option<String> {
3327        let session = self.storage.get_session(session_id).await?;
3328        let last = session.messages.last()?;
3329        if !matches!(last.role, MessageRole::User) {
3330            return None;
3331        }
3332        let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
3333        if age_ms > 10_000 {
3334            return None;
3335        }
3336        let last_text = last
3337            .parts
3338            .iter()
3339            .filter_map(|part| match part {
3340                MessagePart::Text { text } => Some(text.clone()),
3341                _ => None,
3342            })
3343            .collect::<Vec<_>>()
3344            .join("\n");
3345        if last_text == text {
3346            return Some(last.id.clone());
3347        }
3348        None
3349    }
3350
3351    async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
3352        let Some(mut session) = self.storage.get_session(session_id).await else {
3353            return;
3354        };
3355        if !title_needs_repair(&session.title) {
3356            return;
3357        }
3358
3359        let first_user_text = session.messages.iter().find_map(|message| {
3360            if !matches!(message.role, MessageRole::User) {
3361                return None;
3362            }
3363            message.parts.iter().find_map(|part| match part {
3364                MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
3365                _ => None,
3366            })
3367        });
3368
3369        let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
3370        let Some(title) = derive_session_title_from_prompt(&source, 60) else {
3371            return;
3372        };
3373
3374        session.title = title;
3375        session.time.updated = Utc::now();
3376        let _ = self.storage.save_session(session).await;
3377    }
3378
3379    async fn workspace_sandbox_violation(
3380        &self,
3381        session_id: &str,
3382        tool: &str,
3383        args: &Value,
3384    ) -> Option<String> {
3385        if self.workspace_override_active(session_id).await {
3386            return None;
3387        }
3388        // MCP tools: apply sandbox only if they supply path-like arguments.
3389        // Purely API-based MCP tools (no path args) are allowed through.
3390        // Operators can exempt specific MCP servers via TANDEM_MCP_SANDBOX_EXEMPT_SERVERS.
3391        if is_mcp_tool_name(tool) {
3392            if let Some(server) = mcp_server_from_tool_name(tool) {
3393                if is_mcp_sandbox_exempt_server(server) {
3394                    return None;
3395                }
3396            }
3397            let candidate_paths = extract_tool_candidate_paths(tool, args);
3398            if candidate_paths.is_empty() {
3399                // No path arguments — this is a remote/API-only call; allow it.
3400                return None;
3401            }
3402            // Has path args — apply workspace containment to those paths.
3403            let session = self.storage.get_session(session_id).await?;
3404            let workspace = session
3405                .workspace_root
3406                .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3407            let workspace_path = PathBuf::from(&workspace);
3408            if let Some(sensitive) = candidate_paths.iter().find(|path| {
3409                let raw = Path::new(path);
3410                let resolved = if raw.is_absolute() {
3411                    raw.to_path_buf()
3412                } else {
3413                    workspace_path.join(raw)
3414                };
3415                is_sensitive_path_candidate(&resolved)
3416            }) {
3417                return Some(format!(
3418                    "Sandbox blocked MCP tool `{tool}` path `{sensitive}` (sensitive path policy)."
3419                ));
3420            }
3421            let outside = candidate_paths.iter().find(|path| {
3422                let raw = Path::new(path);
3423                let resolved = if raw.is_absolute() {
3424                    raw.to_path_buf()
3425                } else {
3426                    workspace_path.join(raw)
3427                };
3428                !crate::is_within_workspace_root(&resolved, &workspace_path)
3429            })?;
3430            return Some(format!(
3431                "Sandbox blocked MCP tool `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3432            ));
3433        }
3434        let session = self.storage.get_session(session_id).await?;
3435        let workspace = session
3436            .workspace_root
3437            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3438        let workspace_path = PathBuf::from(&workspace);
3439        let candidate_paths = extract_tool_candidate_paths(tool, args);
3440        if candidate_paths.is_empty() {
3441            if is_shell_tool_name(tool) {
3442                if let Some(command) = extract_shell_command(args) {
3443                    if shell_command_targets_sensitive_path(&command) {
3444                        return Some(format!(
3445                            "Sandbox blocked `{tool}` command targeting sensitive paths."
3446                        ));
3447                    }
3448                }
3449            }
3450            return None;
3451        }
3452        if let Some(sensitive) = candidate_paths.iter().find(|path| {
3453            let raw = Path::new(path);
3454            let resolved = if raw.is_absolute() {
3455                raw.to_path_buf()
3456            } else {
3457                workspace_path.join(raw)
3458            };
3459            is_sensitive_path_candidate(&resolved)
3460        }) {
3461            return Some(format!(
3462                "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
3463            ));
3464        }
3465
3466        let outside = candidate_paths.iter().find(|path| {
3467            let raw = Path::new(path);
3468            let resolved = if raw.is_absolute() {
3469                raw.to_path_buf()
3470            } else {
3471                workspace_path.join(raw)
3472            };
3473            !crate::is_within_workspace_root(&resolved, &workspace_path)
3474        })?;
3475        Some(format!(
3476            "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3477        ))
3478    }
3479
3480    async fn resolve_tool_execution_context(
3481        &self,
3482        session_id: &str,
3483    ) -> Option<(String, String, Option<String>)> {
3484        let session = self.storage.get_session(session_id).await?;
3485        let workspace_root = session
3486            .workspace_root
3487            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3488        let effective_cwd = if session.directory.trim().is_empty()
3489            || session.directory.trim() == "."
3490        {
3491            workspace_root.clone()
3492        } else {
3493            crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
3494        };
3495        let project_id = session
3496            .project_id
3497            .clone()
3498            .or_else(|| crate::workspace_project_id(&workspace_root));
3499        Some((workspace_root, effective_cwd, project_id))
3500    }
3501
3502    async fn workspace_override_active(&self, session_id: &str) -> bool {
3503        let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
3504        let mut overrides = self.workspace_overrides.write().await;
3505        // Collect expired session IDs for audit events before pruning.
3506        let expired: Vec<String> = overrides
3507            .iter()
3508            .filter_map(|(id, &exp)| if exp <= now { Some(id.clone()) } else { None })
3509            .collect();
3510        overrides.retain(|_, expires_at| *expires_at > now);
3511        drop(overrides);
3512        for expired_id in expired {
3513            self.event_bus.publish(EngineEvent::new(
3514                "workspace.override.expired",
3515                json!({ "sessionID": expired_id }),
3516            ));
3517        }
3518        self.workspace_overrides
3519            .read()
3520            .await
3521            .get(session_id)
3522            .map(|expires_at| *expires_at > now)
3523            .unwrap_or(false)
3524    }
3525
3526    async fn generate_final_narrative_without_tools(
3527        &self,
3528        session_id: &str,
3529        active_agent: &AgentDefinition,
3530        provider_hint: Option<&str>,
3531        model_id: Option<&str>,
3532        cancel: CancellationToken,
3533        tool_outputs: &[String],
3534    ) -> Option<String> {
3535        if cancel.is_cancelled() {
3536            return None;
3537        }
3538        let mut messages = load_chat_history(
3539            self.storage.clone(),
3540            session_id,
3541            ChatHistoryProfile::Standard,
3542        )
3543        .await;
3544        let mut system_parts = vec![tandem_runtime_system_prompt(
3545            &self.host_runtime_context,
3546            &[],
3547        )];
3548        if let Some(system) = active_agent.system_prompt.as_ref() {
3549            system_parts.push(system.clone());
3550        }
3551        messages.insert(
3552            0,
3553            ChatMessage {
3554                role: "system".to_string(),
3555                content: system_parts.join("\n\n"),
3556                attachments: Vec::new(),
3557            },
3558        );
3559        messages.push(ChatMessage {
3560            role: "user".to_string(),
3561            content: build_post_tool_final_narrative_prompt(tool_outputs),
3562            attachments: Vec::new(),
3563        });
3564        let stream = self
3565            .providers
3566            .stream_for_provider(
3567                provider_hint,
3568                model_id,
3569                messages,
3570                ToolMode::None,
3571                None,
3572                cancel.clone(),
3573            )
3574            .await
3575            .ok()?;
3576        tokio::pin!(stream);
3577        let mut completion = String::new();
3578        while let Some(chunk) = stream.next().await {
3579            if cancel.is_cancelled() {
3580                return None;
3581            }
3582            match chunk {
3583                Ok(StreamChunk::TextDelta(delta)) => {
3584                    let delta = strip_model_control_markers(&delta);
3585                    if !delta.trim().is_empty() {
3586                        completion.push_str(&delta);
3587                    }
3588                }
3589                Ok(StreamChunk::Done { .. }) => break,
3590                Ok(_) => {}
3591                Err(_) => return None,
3592            }
3593        }
3594        let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
3595        if completion.trim().is_empty() {
3596            None
3597        } else {
3598            Some(completion)
3599        }
3600    }
3601}
3602
3603fn resolve_model_route(
3604    request_model: Option<&ModelSpec>,
3605    session_model: Option<&ModelSpec>,
3606) -> Option<(String, String)> {
3607    fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
3608        let provider_id = spec.provider_id.trim();
3609        let model_id = spec.model_id.trim();
3610        if provider_id.is_empty() || model_id.is_empty() {
3611            return None;
3612        }
3613        Some((provider_id.to_string(), model_id.to_string()))
3614    }
3615
3616    request_model
3617        .and_then(normalize)
3618        .or_else(|| session_model.and_then(normalize))
3619}
3620
3621fn strip_model_control_markers(input: &str) -> String {
3622    let mut cleaned = input.to_string();
3623    for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
3624        if cleaned.contains(marker) {
3625            cleaned = cleaned.replace(marker, "");
3626        }
3627    }
3628    cleaned
3629}
3630
3631fn truncate_text(input: &str, max_len: usize) -> String {
3632    if input.len() <= max_len {
3633        return input.to_string();
3634    }
3635    let mut end = 0usize;
3636    for (idx, ch) in input.char_indices() {
3637        let next = idx + ch.len_utf8();
3638        if next > max_len {
3639            break;
3640        }
3641        end = next;
3642    }
3643    let mut out = input[..end].to_string();
3644    out.push_str("...<truncated>");
3645    out
3646}
3647
3648fn build_post_tool_final_narrative_prompt(tool_outputs: &[String]) -> String {
3649    format!(
3650        "Tool observations:\n{}\n\nUsing the tool observations and the existing conversation instructions, provide the required final answer now. Preserve any requested output contract, required JSON structure, required handoff fields, and required final status object from the original task. Do not call tools. Do not stop at a tool summary if the task requires a structured final response.",
3651        summarize_tool_outputs(tool_outputs)
3652    )
3653}
3654
3655fn provider_error_code(error_text: &str) -> &'static str {
3656    let lower = error_text.to_lowercase();
3657    if lower.contains("invalid_function_parameters")
3658        || lower.contains("array schema missing items")
3659        || lower.contains("tool schema")
3660    {
3661        return "TOOL_SCHEMA_INVALID";
3662    }
3663    if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
3664    {
3665        return "RATE_LIMIT_EXCEEDED";
3666    }
3667    if lower.contains("context length")
3668        || lower.contains("max tokens")
3669        || lower.contains("token limit")
3670    {
3671        return "CONTEXT_LENGTH_EXCEEDED";
3672    }
3673    if lower.contains("unauthorized")
3674        || lower.contains("authentication")
3675        || lower.contains("401")
3676        || lower.contains("403")
3677    {
3678        return "AUTHENTICATION_ERROR";
3679    }
3680    if lower.contains("timeout") || lower.contains("timed out") {
3681        return "TIMEOUT";
3682    }
3683    if lower.contains("server error")
3684        || lower.contains("500")
3685        || lower.contains("502")
3686        || lower.contains("503")
3687        || lower.contains("504")
3688    {
3689        return "PROVIDER_SERVER_ERROR";
3690    }
3691    "PROVIDER_REQUEST_FAILED"
3692}
3693
3694fn normalize_tool_name(name: &str) -> String {
3695    let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
3696    for prefix in [
3697        "default_api:",
3698        "default_api.",
3699        "functions.",
3700        "function.",
3701        "tools.",
3702        "tool.",
3703        "builtin:",
3704        "builtin.",
3705    ] {
3706        if let Some(rest) = normalized.strip_prefix(prefix) {
3707            let trimmed = rest.trim();
3708            if !trimmed.is_empty() {
3709                normalized = trimmed.to_string();
3710                break;
3711            }
3712        }
3713    }
3714    match normalized.as_str() {
3715        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
3716        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
3717        other => other.to_string(),
3718    }
3719}
3720
3721fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
3722    let mut parts = tool_name.split('.');
3723    let prefix = parts.next()?;
3724    if prefix != "mcp" {
3725        return None;
3726    }
3727    parts.next().filter(|server| !server.is_empty())
3728}
3729
3730fn requires_web_research_prompt(input: &str) -> bool {
3731    let lower = input.to_ascii_lowercase();
3732    [
3733        "research",
3734        "top news",
3735        "today's news",
3736        "todays news",
3737        "with links",
3738        "latest headlines",
3739        "current events",
3740    ]
3741    .iter()
3742    .any(|needle| lower.contains(needle))
3743}
3744
3745fn requires_email_delivery_prompt(input: &str) -> bool {
3746    let lower = input.to_ascii_lowercase();
3747    (lower.contains("send") && lower.contains("email"))
3748        || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
3749        || lower.contains("email to")
3750}
3751
3752fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
3753    schemas.iter().any(|schema| {
3754        let name = normalize_tool_name(&schema.name);
3755        name == "websearch" || name == "webfetch" || name == "webfetch_html"
3756    })
3757}
3758
3759fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
3760    schemas
3761        .iter()
3762        .map(|schema| normalize_tool_name(&schema.name))
3763        .any(|name| tool_name_looks_like_email_action(&name))
3764}
3765
3766fn tool_name_looks_like_email_action(name: &str) -> bool {
3767    let normalized = normalize_tool_name(name);
3768    if normalized.starts_with("mcp.") {
3769        return normalized.contains("gmail")
3770            || normalized.contains("mail")
3771            || normalized.contains("email");
3772    }
3773    normalized.contains("mail") || normalized.contains("email")
3774}
3775
3776fn completion_claims_email_sent(text: &str) -> bool {
3777    let lower = text.to_ascii_lowercase();
3778    let has_email_marker = lower.contains("email status")
3779        || lower.contains("emailed")
3780        || lower.contains("email sent")
3781        || lower.contains("sent to");
3782    has_email_marker
3783        && (lower.contains("sent")
3784            || lower.contains("delivered")
3785            || lower.contains("has been sent"))
3786}
3787
3788fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
3789    let Some(obj) = args.as_object() else {
3790        return Vec::new();
3791    };
3792    // For MCP tools, probe a wider set of path-like keys since MCP schemas vary by server.
3793    let mcp_path_keys: &[&str] = &[
3794        "path",
3795        "file_path",
3796        "filePath",
3797        "filepath",
3798        "filename",
3799        "directory",
3800        "dir",
3801        "cwd",
3802        "target",
3803        "source",
3804        "dest",
3805        "destination",
3806    ];
3807    let keys: &[&str] = if tool.starts_with("mcp.") {
3808        mcp_path_keys
3809    } else {
3810        match tool {
3811            "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
3812            "glob" => &["pattern"],
3813            "lsp" => &["filePath", "path"],
3814            "bash" => &["cwd"],
3815            "apply_patch" => &[],
3816            _ => &["path", "cwd"],
3817        }
3818    };
3819    keys.iter()
3820        .filter_map(|key| obj.get(*key))
3821        .filter_map(|value| value.as_str())
3822        .filter(|s| {
3823            let t = s.trim();
3824            // Exclude placeholder/empty strings or obvious non-paths
3825            !t.is_empty()
3826                && (t.starts_with('/')
3827                    || t.starts_with('.')
3828                    || t.starts_with('~')
3829                    || t.contains('/'))
3830        })
3831        .map(ToString::to_string)
3832        .collect()
3833}
3834
3835/// Returns true if the MCP server name is in the operator-configured exemption list.
3836/// Set `TANDEM_MCP_SANDBOX_EXEMPT_SERVERS` to a comma-separated list of server names
3837/// (e.g. `composio,github`) to exempt those servers from workspace path containment.
3838fn is_mcp_sandbox_exempt_server(server_name: &str) -> bool {
3839    let Ok(raw) = std::env::var("TANDEM_MCP_SANDBOX_EXEMPT_SERVERS") else {
3840        return false;
3841    };
3842    raw.split(',')
3843        .any(|s| s.trim().eq_ignore_ascii_case(server_name))
3844}
3845
3846fn is_mcp_tool_name(tool_name: &str) -> bool {
3847    let normalized = normalize_tool_name(tool_name);
3848    normalized == "mcp_list" || normalized.starts_with("mcp.")
3849}
3850
3851fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
3852    let target = normalize_tool_name(tool_name);
3853    match agent.tools.as_ref() {
3854        None => true,
3855        Some(list) => {
3856            let normalized = list
3857                .iter()
3858                .map(|t| normalize_tool_name(t))
3859                .collect::<Vec<_>>();
3860            any_policy_matches(&normalized, &target)
3861        }
3862    }
3863}
3864
3865fn enforce_skill_scope(
3866    tool_name: &str,
3867    args: Value,
3868    equipped_skills: Option<&[String]>,
3869) -> Result<Value, String> {
3870    if normalize_tool_name(tool_name) != "skill" {
3871        return Ok(args);
3872    }
3873    let Some(configured) = equipped_skills else {
3874        return Ok(args);
3875    };
3876
3877    let mut allowed = configured
3878        .iter()
3879        .map(|s| s.trim().to_string())
3880        .filter(|s| !s.is_empty())
3881        .collect::<Vec<_>>();
3882    if allowed
3883        .iter()
3884        .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
3885    {
3886        return Ok(args);
3887    }
3888    allowed.sort();
3889    allowed.dedup();
3890    if allowed.is_empty() {
3891        return Err("No skills are equipped for this agent.".to_string());
3892    }
3893
3894    let requested = args
3895        .get("name")
3896        .and_then(|v| v.as_str())
3897        .map(|v| v.trim().to_string())
3898        .unwrap_or_default();
3899    if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
3900        return Err(format!(
3901            "Skill '{}' is not equipped for this agent. Equipped skills: {}",
3902            requested,
3903            allowed.join(", ")
3904        ));
3905    }
3906
3907    let mut out = if let Some(obj) = args.as_object() {
3908        Value::Object(obj.clone())
3909    } else {
3910        json!({})
3911    };
3912    if let Some(obj) = out.as_object_mut() {
3913        obj.insert("allowed_skills".to_string(), json!(allowed));
3914    }
3915    Ok(out)
3916}
3917
3918fn is_read_only_tool(tool_name: &str) -> bool {
3919    matches!(
3920        normalize_tool_name(tool_name).as_str(),
3921        "glob"
3922            | "read"
3923            | "grep"
3924            | "search"
3925            | "codesearch"
3926            | "list"
3927            | "ls"
3928            | "lsp"
3929            | "websearch"
3930            | "webfetch"
3931            | "webfetch_html"
3932    )
3933}
3934
3935fn is_workspace_write_tool(tool_name: &str) -> bool {
3936    matches!(
3937        normalize_tool_name(tool_name).as_str(),
3938        "write" | "edit" | "apply_patch"
3939    )
3940}
3941
3942fn should_start_prewrite_repair_before_first_write(
3943    repair_on_unmet_requirements: bool,
3944    productive_write_tool_calls_total: usize,
3945    prewrite_satisfied: bool,
3946    code_workflow_requested: bool,
3947) -> bool {
3948    (repair_on_unmet_requirements || code_workflow_requested)
3949        && productive_write_tool_calls_total == 0
3950        && !prewrite_satisfied
3951}
3952
3953fn is_batch_wrapper_tool_name(name: &str) -> bool {
3954    matches!(
3955        normalize_tool_name(name).as_str(),
3956        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
3957    )
3958}
3959
3960fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
3961    obj.get(key)
3962        .and_then(|v| v.as_str())
3963        .map(str::trim)
3964        .filter(|s| !s.is_empty())
3965}
3966
3967fn nested_non_empty_string_at<'a>(
3968    obj: &'a Map<String, Value>,
3969    parent: &str,
3970    key: &str,
3971) -> Option<&'a str> {
3972    obj.get(parent)
3973        .and_then(|v| v.as_object())
3974        .and_then(|nested| nested.get(key))
3975        .and_then(|v| v.as_str())
3976        .map(str::trim)
3977        .filter(|s| !s.is_empty())
3978}
3979
3980fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
3981    let calls = args
3982        .get("tool_calls")
3983        .and_then(|v| v.as_array())
3984        .cloned()
3985        .unwrap_or_default();
3986    calls
3987        .into_iter()
3988        .filter_map(|call| {
3989            let obj = call.as_object()?;
3990            let tool_raw = non_empty_string_at(obj, "tool")
3991                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3992                .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
3993                .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
3994                .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
3995            let name_raw = non_empty_string_at(obj, "name")
3996                .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3997                .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
3998                .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
3999                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
4000            let effective = match (tool_raw, name_raw) {
4001                (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
4002                (Some(t), _) => t,
4003                (None, Some(n)) => n,
4004                (None, None) => return None,
4005            };
4006            let normalized = normalize_tool_name(effective);
4007            let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
4008            Some((normalized, call_args))
4009        })
4010        .collect()
4011}
4012
4013fn is_read_only_batch_call(args: &Value) -> bool {
4014    let calls = extract_batch_calls(args);
4015    !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
4016}
4017
4018fn batch_tool_signature(args: &Value) -> Option<String> {
4019    let calls = extract_batch_calls(args);
4020    if calls.is_empty() {
4021        return None;
4022    }
4023    let parts = calls
4024        .into_iter()
4025        .map(|(tool, call_args)| tool_signature(&tool, &call_args))
4026        .collect::<Vec<_>>();
4027    Some(format!("batch:{}", parts.join("|")))
4028}
4029
4030fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
4031    let normalized_tool = normalize_tool_name(tool_name);
4032    if normalized_tool == "batch" && is_non_productive_batch_output(output) {
4033        return false;
4034    }
4035    if is_auth_required_tool_output(output) {
4036        return false;
4037    }
4038    if normalized_tool == "glob" {
4039        return true;
4040    }
4041    let Some(result_body) = extract_tool_result_body(output) else {
4042        return false;
4043    };
4044    !is_non_productive_tool_result_body(result_body)
4045}
4046
4047fn is_successful_web_research_output(tool_name: &str, output: &str) -> bool {
4048    if !is_web_research_tool(tool_name) {
4049        return false;
4050    }
4051    let Some(result_body) = extract_tool_result_body(output) else {
4052        return false;
4053    };
4054    if is_non_productive_tool_result_body(result_body) {
4055        return false;
4056    }
4057    let lower = result_body.to_ascii_lowercase();
4058    !(lower.contains("search timed out")
4059        || lower.contains("timed out")
4060        || lower.contains("no results received")
4061        || lower.contains("no search results")
4062        || lower.contains("no relevant results"))
4063}
4064
4065fn extract_tool_result_body(output: &str) -> Option<&str> {
4066    let trimmed = output.trim();
4067    let rest = trimmed.strip_prefix("Tool `")?;
4068    let (_, result_body) = rest.split_once("` result:")?;
4069    Some(result_body.trim())
4070}
4071
4072fn is_non_productive_tool_result_body(output: &str) -> bool {
4073    let trimmed = output.trim();
4074    if trimmed.is_empty() {
4075        return true;
4076    }
4077    let lower = trimmed.to_ascii_lowercase();
4078    lower.starts_with("unknown tool:")
4079        || lower.contains("call skipped")
4080        || lower.contains("guard budget exceeded")
4081        || lower.contains("invalid_function_parameters")
4082        || is_terminal_tool_error_reason(trimmed)
4083}
4084
4085fn is_terminal_tool_error_reason(output: &str) -> bool {
4086    let first_line = output.lines().next().unwrap_or_default().trim();
4087    if first_line.is_empty() {
4088        return false;
4089    }
4090    let normalized = first_line.to_ascii_uppercase();
4091    matches!(
4092        normalized.as_str(),
4093        "TOOL_ARGUMENTS_MISSING"
4094            | "WEBSEARCH_QUERY_MISSING"
4095            | "BASH_COMMAND_MISSING"
4096            | "FILE_PATH_MISSING"
4097            | "WRITE_CONTENT_MISSING"
4098            | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
4099            | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
4100            | "WEBFETCH_URL_MISSING"
4101            | "PACK_BUILDER_PLAN_ID_MISSING"
4102            | "PACK_BUILDER_GOAL_MISSING"
4103            | "PROVIDER_REQUEST_FAILED"
4104            | "AUTHENTICATION_ERROR"
4105            | "CONTEXT_LENGTH_EXCEEDED"
4106            | "RATE_LIMIT_EXCEEDED"
4107    ) || normalized.ends_with("_MISSING")
4108        || normalized.ends_with("_ERROR")
4109}
4110
4111fn is_non_productive_batch_output(output: &str) -> bool {
4112    let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
4113        return false;
4114    };
4115    let Some(items) = value.as_array() else {
4116        return false;
4117    };
4118    if items.is_empty() {
4119        return true;
4120    }
4121    items.iter().all(|item| {
4122        let text = item
4123            .get("output")
4124            .and_then(|v| v.as_str())
4125            .map(str::trim)
4126            .unwrap_or_default()
4127            .to_ascii_lowercase();
4128        text.is_empty()
4129            || text.starts_with("unknown tool:")
4130            || text.contains("call skipped")
4131            || text.contains("guard budget exceeded")
4132    })
4133}
4134
4135fn is_auth_required_tool_output(output: &str) -> bool {
4136    let lower = output.to_ascii_lowercase();
4137    (lower.contains("authorization required")
4138        || lower.contains("requires authorization")
4139        || lower.contains("authorization pending"))
4140        && (lower.contains("authorize here") || lower.contains("http"))
4141}
4142
4143#[derive(Debug, Clone)]
4144struct McpAuthRequiredMetadata {
4145    challenge_id: String,
4146    authorization_url: String,
4147    message: String,
4148    server: Option<String>,
4149    pending: bool,
4150    blocked: bool,
4151    retry_after_ms: Option<u64>,
4152}
4153
4154fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
4155    let auth = metadata.get("mcpAuth")?;
4156    if !auth
4157        .get("required")
4158        .and_then(|v| v.as_bool())
4159        .unwrap_or(false)
4160    {
4161        return None;
4162    }
4163    let authorization_url = auth
4164        .get("authorizationUrl")
4165        .and_then(|v| v.as_str())
4166        .map(str::trim)
4167        .filter(|v| !v.is_empty())?
4168        .to_string();
4169    let message = auth
4170        .get("message")
4171        .and_then(|v| v.as_str())
4172        .map(str::trim)
4173        .filter(|v| !v.is_empty())
4174        .unwrap_or("This tool requires authorization before it can run.")
4175        .to_string();
4176    let challenge_id = auth
4177        .get("challengeId")
4178        .and_then(|v| v.as_str())
4179        .map(str::trim)
4180        .filter(|v| !v.is_empty())
4181        .unwrap_or("unknown")
4182        .to_string();
4183    let server = metadata
4184        .get("server")
4185        .and_then(|v| v.as_str())
4186        .map(str::trim)
4187        .filter(|v| !v.is_empty())
4188        .map(ToString::to_string);
4189    let pending = auth
4190        .get("pending")
4191        .and_then(|v| v.as_bool())
4192        .unwrap_or(false);
4193    let blocked = auth
4194        .get("blocked")
4195        .and_then(|v| v.as_bool())
4196        .unwrap_or(false);
4197    let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
4198    Some(McpAuthRequiredMetadata {
4199        challenge_id,
4200        authorization_url,
4201        message,
4202        server,
4203        pending,
4204        blocked,
4205        retry_after_ms,
4206    })
4207}
4208
4209fn extract_mcp_auth_required_from_error_text(
4210    tool_name: &str,
4211    error_text: &str,
4212) -> Option<McpAuthRequiredMetadata> {
4213    let lower = error_text.to_ascii_lowercase();
4214    let auth_hint = lower.contains("authorization")
4215        || lower.contains("oauth")
4216        || lower.contains("invalid oauth token")
4217        || lower.contains("requires authorization");
4218    if !auth_hint {
4219        return None;
4220    }
4221    let authorization_url = find_first_url(error_text)?;
4222    let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
4223    let server = tool_name
4224        .strip_prefix("mcp.")
4225        .and_then(|rest| rest.split('.').next())
4226        .filter(|s| !s.is_empty())
4227        .map(ToString::to_string);
4228    Some(McpAuthRequiredMetadata {
4229        challenge_id,
4230        authorization_url,
4231        message: "This integration requires authorization before this action can run.".to_string(),
4232        server,
4233        pending: false,
4234        blocked: false,
4235        retry_after_ms: None,
4236    })
4237}
4238
4239fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
4240    if outputs.is_empty()
4241        || !outputs
4242            .iter()
4243            .all(|output| is_auth_required_tool_output(output))
4244    {
4245        return None;
4246    }
4247    let mut auth_lines = outputs
4248        .iter()
4249        .filter_map(|output| {
4250            let trimmed = output.trim();
4251            if trimmed.is_empty() {
4252                None
4253            } else {
4254                Some(trimmed.to_string())
4255            }
4256        })
4257        .collect::<Vec<_>>();
4258    auth_lines.sort();
4259    auth_lines.dedup();
4260    if auth_lines.is_empty() {
4261        return None;
4262    }
4263    Some(format!(
4264        "Authorization is required before I can continue with this action.\n\n{}",
4265        auth_lines.join("\n\n")
4266    ))
4267}
4268
4269fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
4270    if outputs.is_empty()
4271        || !outputs
4272            .iter()
4273            .all(|output| is_guard_budget_tool_output(output))
4274    {
4275        return None;
4276    }
4277    let mut lines = outputs
4278        .iter()
4279        .filter_map(|output| {
4280            let trimmed = output.trim();
4281            if trimmed.is_empty() {
4282                None
4283            } else {
4284                Some(trimmed.to_string())
4285            }
4286        })
4287        .collect::<Vec<_>>();
4288    lines.sort();
4289    lines.dedup();
4290    if lines.is_empty() {
4291        return None;
4292    }
4293    Some(format!(
4294        "This run hit the per-run tool guard budget, so I paused tool execution to avoid runaway retries.\n\n{}\n\nSend a new message to start a fresh run.",
4295        lines.join("\n")
4296    ))
4297}
4298
4299fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
4300    if outputs.is_empty()
4301        || !outputs
4302            .iter()
4303            .all(|output| is_duplicate_signature_limit_output(output))
4304    {
4305        return None;
4306    }
4307    let mut lines = outputs
4308        .iter()
4309        .filter_map(|output| {
4310            let trimmed = output.trim();
4311            if trimmed.is_empty() {
4312                None
4313            } else {
4314                Some(trimmed.to_string())
4315            }
4316        })
4317        .collect::<Vec<_>>();
4318    lines.sort();
4319    lines.dedup();
4320    if lines.is_empty() {
4321        return None;
4322    }
4323    Some(format!(
4324        "This run paused because the same tool call kept repeating.\n\n{}\n\nRephrase the request or start a new message with a clearer command target.",
4325        lines.join("\n")
4326    ))
4327}
4328
4329const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
4330
4331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4332enum RequiredToolFailureKind {
4333    NoToolCallEmitted,
4334    ToolCallParseFailed,
4335    ToolCallInvalidArgs,
4336    WriteArgsEmptyFromProvider,
4337    WriteArgsUnparseableFromProvider,
4338    ToolCallRejectedByPolicy,
4339    ToolCallExecutedNonProductive,
4340    WriteRequiredNotSatisfied,
4341    PrewriteRequirementsExhausted,
4342}
4343
4344impl RequiredToolFailureKind {
4345    fn code(self) -> &'static str {
4346        match self {
4347            Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
4348            Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
4349            Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
4350            Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
4351            Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
4352            Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
4353            Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
4354            Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
4355            Self::PrewriteRequirementsExhausted => "PREWRITE_REQUIREMENTS_EXHAUSTED",
4356        }
4357    }
4358}
4359
4360fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
4361    format!(
4362        "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
4363        reason.code()
4364    )
4365}
4366
4367#[allow(dead_code)]
4368fn prewrite_requirements_exhausted_completion(
4369    unmet_codes: &[&'static str],
4370    repair_attempt: usize,
4371    repair_attempts_remaining: usize,
4372) -> String {
4373    let unmet = if unmet_codes.is_empty() {
4374        "none".to_string()
4375    } else {
4376        unmet_codes.join(", ")
4377    };
4378    format!(
4379        "TOOL_MODE_REQUIRED_NOT_SATISFIED: PREWRITE_REQUIREMENTS_EXHAUSTED: unmet prewrite requirements: {unmet}\n\n{{\"status\":\"blocked\",\"reason\":\"prewrite requirements exhausted before final artifact validation\",\"failureCode\":\"PREWRITE_REQUIREMENTS_EXHAUSTED\",\"repairAttempt\":{},\"repairAttemptsRemaining\":{},\"repairExhausted\":true,\"unmetRequirements\":{:?}}}",
4380        repair_attempt,
4381        repair_attempts_remaining,
4382        unmet_codes,
4383    )
4384}
4385
4386fn prewrite_repair_event_payload(
4387    repair_attempt: usize,
4388    repair_attempts_remaining: usize,
4389    unmet_codes: &[&'static str],
4390    repair_exhausted: bool,
4391) -> Value {
4392    json!({
4393        "repairAttempt": repair_attempt,
4394        "repairAttemptsRemaining": repair_attempts_remaining,
4395        "unmetRequirements": unmet_codes,
4396        "repairActive": repair_attempt > 0 && !repair_exhausted,
4397        "repairExhausted": repair_exhausted,
4398    })
4399}
4400
4401fn build_required_tool_retry_context(
4402    offered_tool_preview: &str,
4403    previous_reason: RequiredToolFailureKind,
4404) -> String {
4405    let offered = offered_tool_preview.trim();
4406    let available_tools = if offered.is_empty() {
4407        "Use one of the tools offered in this turn before you produce final text.".to_string()
4408    } else {
4409        format!("Use one of these offered tools before you produce final text: {offered}.")
4410    };
4411    let execution_instruction = if previous_reason
4412        == RequiredToolFailureKind::WriteRequiredNotSatisfied
4413    {
4414        "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
4415    } else if is_write_invalid_args_failure_kind(previous_reason) {
4416        "Previous tool call arguments were invalid. If you use write, include both `path` and the full `content`. If inspection is already complete, use write, edit, or apply_patch now.".to_string()
4417    } else {
4418        available_tools
4419    };
4420    format!(
4421        "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
4422        previous_reason.code(),
4423        execution_instruction
4424    )
4425}
4426
4427fn looks_like_code_target_path(path: &str) -> bool {
4428    let trimmed = path.trim();
4429    if trimmed.is_empty() {
4430        return false;
4431    }
4432    let normalized = trimmed.replace('\\', "/");
4433    let file_name = normalized
4434        .rsplit('/')
4435        .next()
4436        .unwrap_or(normalized.as_str())
4437        .to_ascii_lowercase();
4438    if matches!(
4439        file_name.as_str(),
4440        "cargo.toml"
4441            | "cargo.lock"
4442            | "package.json"
4443            | "pnpm-lock.yaml"
4444            | "package-lock.json"
4445            | "yarn.lock"
4446            | "makefile"
4447            | "dockerfile"
4448            | ".gitignore"
4449            | ".editorconfig"
4450            | "tsconfig.json"
4451            | "pyproject.toml"
4452            | "requirements.txt"
4453    ) {
4454        return true;
4455    }
4456    let extension = file_name.rsplit('.').next().unwrap_or_default();
4457    matches!(
4458        extension,
4459        "rs" | "ts"
4460            | "tsx"
4461            | "js"
4462            | "jsx"
4463            | "py"
4464            | "go"
4465            | "java"
4466            | "kt"
4467            | "kts"
4468            | "c"
4469            | "cc"
4470            | "cpp"
4471            | "h"
4472            | "hpp"
4473            | "cs"
4474            | "rb"
4475            | "php"
4476            | "swift"
4477            | "scala"
4478            | "sh"
4479            | "bash"
4480            | "zsh"
4481            | "toml"
4482            | "yaml"
4483            | "yml"
4484            | "json"
4485    )
4486}
4487
4488fn infer_code_workflow_from_text(text: &str) -> bool {
4489    let lowered = text.to_ascii_lowercase();
4490    if lowered.contains("code agent contract")
4491        || lowered.contains("inspect -> patch -> apply -> test -> repair")
4492        || lowered.contains("task kind: `code_change`")
4493        || lowered.contains("task kind: code_change")
4494        || lowered.contains("output contract kind: code_patch")
4495        || lowered.contains("verification expectation:")
4496        || lowered.contains("verification command:")
4497    {
4498        return true;
4499    }
4500    infer_required_output_target_path_from_text(text)
4501        .is_some_and(|path| looks_like_code_target_path(&path))
4502}
4503
4504fn infer_verification_command_from_text(text: &str) -> Option<String> {
4505    for marker in ["Verification expectation:", "verification expectation:"] {
4506        let Some(start) = text.find(marker) else {
4507            continue;
4508        };
4509        let remainder = text[start + marker.len()..].trim_start();
4510        let line = remainder.lines().next().unwrap_or_default().trim();
4511        if line.is_empty() {
4512            continue;
4513        }
4514        let cleaned = line
4515            .trim_matches('`')
4516            .trim_end_matches('.')
4517            .trim()
4518            .to_string();
4519        if !cleaned.is_empty() {
4520            return Some(cleaned);
4521        }
4522    }
4523    None
4524}
4525
4526fn build_required_tool_retry_context_for_task(
4527    offered_tool_preview: &str,
4528    previous_reason: RequiredToolFailureKind,
4529    latest_user_text: &str,
4530) -> String {
4531    let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
4532    if !infer_code_workflow_from_text(latest_user_text) {
4533        return prompt;
4534    }
4535    let output_target = infer_required_output_target_path_from_text(latest_user_text)
4536        .unwrap_or_else(|| "the declared source target".to_string());
4537    let verification = infer_verification_command_from_text(latest_user_text)
4538        .unwrap_or_else(|| "run the declared verification command with `bash`".to_string());
4539    prompt.push(' ');
4540    prompt.push_str(
4541        "This is a code workflow: follow inspect -> patch -> apply -> test -> repair before finalizing.",
4542    );
4543    prompt.push(' ');
4544    prompt.push_str(&format!(
4545        "Patch `{output_target}` using `apply_patch` (or `edit` for local edits); use `write` only when creating a brand-new file."
4546    ));
4547    prompt.push(' ');
4548    prompt.push_str(&format!(
4549        "After patching, run verification with `bash` (`{verification}`). If verification fails, repair the smallest root cause and re-run verification."
4550    ));
4551    prompt
4552}
4553
4554fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
4555    matches!(
4556        reason,
4557        RequiredToolFailureKind::ToolCallInvalidArgs
4558            | RequiredToolFailureKind::WriteArgsEmptyFromProvider
4559            | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
4560    )
4561}
4562
4563fn should_retry_nonproductive_required_tool_cycle(
4564    requested_write_required: bool,
4565    write_tool_attempted_in_cycle: bool,
4566    progress_made_in_cycle: bool,
4567    required_tool_retry_count: usize,
4568) -> bool {
4569    if write_tool_attempted_in_cycle {
4570        return required_tool_retry_count == 0 && !requested_write_required;
4571    }
4572    if progress_made_in_cycle {
4573        return required_tool_retry_count < 2;
4574    }
4575    required_tool_retry_count == 0 && (!requested_write_required || !write_tool_attempted_in_cycle)
4576}
4577
4578fn build_write_required_retry_context(
4579    offered_tool_preview: &str,
4580    previous_reason: RequiredToolFailureKind,
4581    latest_user_text: &str,
4582    prewrite_requirements: &PrewriteRequirements,
4583    workspace_inspection_satisfied: bool,
4584    concrete_read_satisfied: bool,
4585    web_research_satisfied: bool,
4586    successful_web_research_satisfied: bool,
4587) -> String {
4588    let mut prompt = build_required_tool_retry_context_for_task(
4589        offered_tool_preview,
4590        previous_reason,
4591        latest_user_text,
4592    );
4593    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4594        prewrite_requirements,
4595        workspace_inspection_satisfied,
4596        concrete_read_satisfied,
4597        web_research_satisfied,
4598        successful_web_research_satisfied,
4599    );
4600    if !unmet.is_empty() {
4601        prompt.push(' ');
4602        prompt.push_str(&format!(
4603            "Before the final write, you still need to {}.",
4604            unmet.join(" and ")
4605        ));
4606    }
4607    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4608        prompt.push(' ');
4609        prompt.push_str(&format!(
4610            "The required output target for this task is `{path}`. Write or update that file now."
4611        ));
4612        prompt.push(' ');
4613        prompt.push_str(
4614            "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4615        );
4616        prompt.push(' ');
4617        prompt.push_str(
4618            "You have already gathered research in this session. Now write the output file using the information from your previous tool calls. You may re-read a specific file if needed for accuracy.",
4619        );
4620    }
4621    prompt
4622}
4623
4624fn build_prewrite_repair_retry_context(
4625    offered_tool_preview: &str,
4626    previous_reason: RequiredToolFailureKind,
4627    latest_user_text: &str,
4628    prewrite_requirements: &PrewriteRequirements,
4629    workspace_inspection_satisfied: bool,
4630    concrete_read_satisfied: bool,
4631    web_research_satisfied: bool,
4632    successful_web_research_satisfied: bool,
4633) -> String {
4634    let mut prompt = build_required_tool_retry_context_for_task(
4635        offered_tool_preview,
4636        previous_reason,
4637        latest_user_text,
4638    );
4639    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4640        prewrite_requirements,
4641        workspace_inspection_satisfied,
4642        concrete_read_satisfied,
4643        web_research_satisfied,
4644        successful_web_research_satisfied,
4645    );
4646    if !unmet.is_empty() {
4647        prompt.push(' ');
4648        prompt.push_str(&format!(
4649            "Before the final write, you still need to {}.",
4650            unmet.join(" and ")
4651        ));
4652    }
4653    let mut repair_notes = Vec::new();
4654    if prewrite_requirements.concrete_read_required && !concrete_read_satisfied {
4655        repair_notes.push(
4656            "This task requires concrete `read` calls on relevant workspace files before you can write the output. Call `read` now on the files you discovered.",
4657        );
4658    }
4659    if prewrite_requirements.successful_web_research_required && !successful_web_research_satisfied
4660    {
4661        repair_notes.push(
4662            "Timed out or empty websearch attempts do not satisfy external-research requirements; call `websearch` with a concrete query now.",
4663        );
4664    }
4665    if !matches!(
4666        prewrite_requirements.coverage_mode,
4667        PrewriteCoverageMode::None
4668    ) {
4669        repair_notes.push(
4670            "Every path listed under `Files reviewed` must have been actually read in this run, and any relevant discovered file you did not read must appear under `Files not reviewed` with a reason.",
4671        );
4672    }
4673    if !repair_notes.is_empty() {
4674        prompt.push(' ');
4675        prompt.push_str("Do not skip this step. ");
4676        prompt.push_str(&repair_notes.join(" "));
4677    }
4678    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4679        if infer_code_workflow_from_text(latest_user_text) {
4680            prompt.push(' ');
4681            prompt.push_str(&format!(
4682                "Use `read` to confirm the concrete code context, then patch `{path}` with `apply_patch` or `edit` and run verification before finalizing."
4683            ));
4684            prompt.push(' ');
4685            prompt.push_str(
4686                "Do not return a prose-only completion before patch + verification steps run.",
4687            );
4688        } else {
4689            prompt.push(' ');
4690            prompt.push_str(&format!(
4691                "Use `read` and `websearch` now to gather evidence, then write the artifact to `{path}`."
4692            ));
4693            prompt.push(' ');
4694            prompt.push_str(&format!(
4695                "Do not declare the output blocked while `read` and `websearch` remain available. Call them now."
4696            ));
4697        }
4698    }
4699    prompt
4700}
4701
4702fn build_prewrite_waived_write_context(
4703    latest_user_text: &str,
4704    unmet_codes: &[&'static str],
4705) -> String {
4706    let mut prompt = String::from(
4707        "Research prerequisites could not be fully satisfied after multiple repair attempts. \
4708         You must still write the output file using whatever information you have gathered so far. \
4709         Do not write a blocked or placeholder file. Write the best possible output with the evidence available.",
4710    );
4711    if !unmet_codes.is_empty() {
4712        prompt.push_str(&format!(
4713            " (Unmet prerequisites waived: {}.)",
4714            unmet_codes.join(", ")
4715        ));
4716    }
4717    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4718        prompt.push_str(&format!(
4719            " The required output file is `{path}`. Call the `write` tool now to create it."
4720        ));
4721    }
4722    prompt
4723}
4724
4725fn build_empty_completion_retry_context(
4726    offered_tool_preview: &str,
4727    latest_user_text: &str,
4728    prewrite_requirements: &PrewriteRequirements,
4729    workspace_inspection_satisfied: bool,
4730    concrete_read_satisfied: bool,
4731    web_research_satisfied: bool,
4732    successful_web_research_satisfied: bool,
4733) -> String {
4734    let mut prompt = String::from(
4735        "You already used tools in this session, but returned no final output. Do not stop now.",
4736    );
4737    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4738        prewrite_requirements,
4739        workspace_inspection_satisfied,
4740        concrete_read_satisfied,
4741        web_research_satisfied,
4742        successful_web_research_satisfied,
4743    );
4744    if !unmet.is_empty() {
4745        prompt.push(' ');
4746        prompt.push_str(&format!(
4747            "You still need to {} before the final write.",
4748            unmet.join(" and ")
4749        ));
4750        prompt.push(' ');
4751        prompt.push_str(&build_required_tool_retry_context_for_task(
4752            offered_tool_preview,
4753            RequiredToolFailureKind::WriteRequiredNotSatisfied,
4754            latest_user_text,
4755        ));
4756    }
4757    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4758        prompt.push(' ');
4759        prompt.push_str(&format!("The required output target is `{path}`."));
4760        if unmet.is_empty() {
4761            prompt.push(' ');
4762            prompt.push_str(
4763                "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4764            );
4765        } else {
4766            prompt.push(' ');
4767            prompt.push_str(
4768                "After completing the missing requirement, immediately write that file instead of ending with prose.",
4769            );
4770        }
4771    }
4772    prompt
4773}
4774
4775fn synthesize_artifact_write_completion_from_tool_state(
4776    latest_user_text: &str,
4777    prewrite_satisfied: bool,
4778    prewrite_gate_waived: bool,
4779) -> String {
4780    let target = infer_required_output_target_path_from_text(latest_user_text)
4781        .unwrap_or_else(|| "the declared output artifact".to_string());
4782    let mut completion = format!("Completed the requested tool actions and wrote `{target}`.");
4783    if prewrite_gate_waived && !prewrite_satisfied {
4784        completion.push_str(
4785            "\n\nRuntime validation will decide whether the artifact can be accepted because some evidence requirements were waived in-run."
4786        );
4787    } else {
4788        completion
4789            .push_str("\n\nRuntime validation will verify the artifact and finalize node status.");
4790    }
4791    completion.push_str("\n\n{\"status\":\"completed\"}");
4792    completion
4793}
4794
4795fn should_generate_post_tool_final_narrative(
4796    requested_tool_mode: ToolMode,
4797    productive_tool_calls_total: usize,
4798) -> bool {
4799    !matches!(requested_tool_mode, ToolMode::Required) || productive_tool_calls_total > 0
4800}
4801
4802fn is_workspace_inspection_tool(tool_name: &str) -> bool {
4803    matches!(
4804        normalize_tool_name(tool_name).as_str(),
4805        "glob" | "read" | "grep" | "search" | "codesearch" | "ls" | "list"
4806    )
4807}
4808
4809fn is_web_research_tool(tool_name: &str) -> bool {
4810    matches!(
4811        normalize_tool_name(tool_name).as_str(),
4812        "websearch" | "webfetch" | "webfetch_html"
4813    )
4814}
4815
4816fn tool_matches_unmet_prewrite_repair_requirement(tool_name: &str, unmet_codes: &[&str]) -> bool {
4817    if is_workspace_write_tool(tool_name) {
4818        return false;
4819    }
4820    let normalized = normalize_tool_name(tool_name);
4821    let needs_workspace_inspection = unmet_codes.contains(&"workspace_inspection_required");
4822    let needs_concrete_read =
4823        unmet_codes.contains(&"concrete_read_required") || unmet_codes.contains(&"coverage_mode");
4824    let needs_web_research = unmet_codes.iter().any(|code| {
4825        matches!(
4826            *code,
4827            "web_research_required" | "successful_web_research_required"
4828        )
4829    });
4830    (needs_concrete_read && (normalized == "read" || normalized == "glob"))
4831        || (needs_workspace_inspection && is_workspace_inspection_tool(&normalized))
4832        || (needs_web_research && is_web_research_tool(&normalized))
4833}
4834
4835fn invalid_tool_args_retry_max_attempts() -> usize {
4836    2
4837}
4838
4839pub fn prewrite_repair_retry_max_attempts() -> usize {
4840    5
4841}
4842
4843/// When `TANDEM_PREWRITE_GATE_STRICT=true`, the engine refuses to waive the prewrite
4844/// evidence gate even after exhausting repair retries. Instead of proceeding with
4845/// an unverified write, it holds the gate and emits `prewrite.gate.strict_mode.blocked`.
4846pub(super) fn prewrite_gate_strict_mode() -> bool {
4847    std::env::var("TANDEM_PREWRITE_GATE_STRICT")
4848        .ok()
4849        .map(|v| {
4850            matches!(
4851                v.trim().to_ascii_lowercase().as_str(),
4852                "1" | "true" | "yes" | "on"
4853            )
4854        })
4855        .unwrap_or(false)
4856}
4857
4858fn build_invalid_tool_args_retry_context_from_outputs(
4859    outputs: &[String],
4860    previous_attempts: usize,
4861) -> Option<String> {
4862    if outputs
4863        .iter()
4864        .any(|output| output.contains("BASH_COMMAND_MISSING"))
4865    {
4866        let emphasis = if previous_attempts > 0 {
4867            "You already tried `bash` without a valid command. Do not repeat an empty bash call."
4868        } else {
4869            "If you use `bash`, include a full non-empty command string."
4870        };
4871        return Some(format!(
4872            "Previous bash tool call was invalid because it did not include the required `command` field. {emphasis} Good examples: `pwd`, `ls -la`, `find docs -maxdepth 2 -type f`, or `rg -n \"workflow\" docs src`. Prefer `ls`, `glob`, `search`, and `read` for repository inspection when they are sufficient."
4873        ));
4874    }
4875    if outputs
4876        .iter()
4877        .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
4878    {
4879        return Some(
4880            "Previous websearch tool call was invalid because it did not include a query. If you use `websearch`, include a specific non-empty search query.".to_string(),
4881        );
4882    }
4883    if outputs
4884        .iter()
4885        .any(|output| output.contains("WEBFETCH_URL_MISSING"))
4886    {
4887        return Some(
4888            "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
4889        );
4890    }
4891    if outputs
4892        .iter()
4893        .any(|output| output.contains("FILE_PATH_MISSING"))
4894    {
4895        return Some(
4896            "Previous file tool call was invalid because it did not include a `path`. If you use `read`, `write`, or `edit`, include the exact workspace-relative file path.".to_string(),
4897        );
4898    }
4899    if outputs
4900        .iter()
4901        .any(|output| output.contains("WRITE_CONTENT_MISSING"))
4902    {
4903        return Some(
4904            "Previous write tool call was invalid because it did not include `content`. If you use `write`, include both `path` and the full `content`.".to_string(),
4905        );
4906    }
4907    None
4908}
4909
4910fn looks_like_unparsed_tool_payload(output: &str) -> bool {
4911    let trimmed = output.trim();
4912    if trimmed.is_empty() {
4913        return false;
4914    }
4915    let lower = trimmed.to_ascii_lowercase();
4916    lower.contains("\"tool_calls\"")
4917        || lower.contains("\"function_call\"")
4918        || lower.contains("\"function\":{")
4919        || lower.contains("\"type\":\"tool_call\"")
4920        || lower.contains("\"type\":\"function_call\"")
4921        || lower.contains("\"type\":\"tool_use\"")
4922}
4923
4924fn is_policy_rejection_output(output: &str) -> bool {
4925    let lower = output.trim().to_ascii_lowercase();
4926    lower.contains("call skipped")
4927        || lower.contains("authorization required")
4928        || lower.contains("not allowed")
4929        || lower.contains("permission denied")
4930}
4931
4932fn classify_required_tool_failure(
4933    outputs: &[String],
4934    saw_tool_call_candidate: bool,
4935    accepted_tool_calls: usize,
4936    parse_failed: bool,
4937    rejected_by_policy: bool,
4938) -> RequiredToolFailureKind {
4939    if parse_failed {
4940        return RequiredToolFailureKind::ToolCallParseFailed;
4941    }
4942    if !saw_tool_call_candidate {
4943        return RequiredToolFailureKind::NoToolCallEmitted;
4944    }
4945    if accepted_tool_calls == 0 || rejected_by_policy {
4946        return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4947    }
4948    if outputs
4949        .iter()
4950        .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
4951    {
4952        return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
4953    }
4954    if outputs
4955        .iter()
4956        .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
4957    {
4958        return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
4959    }
4960    if outputs
4961        .iter()
4962        .any(|output| is_terminal_tool_error_reason(output))
4963    {
4964        return RequiredToolFailureKind::ToolCallInvalidArgs;
4965    }
4966    if outputs
4967        .iter()
4968        .any(|output| is_policy_rejection_output(output))
4969    {
4970        return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4971    }
4972    RequiredToolFailureKind::ToolCallExecutedNonProductive
4973}
4974
4975fn find_first_url(text: &str) -> Option<String> {
4976    text.split_whitespace().find_map(|token| {
4977        if token.starts_with("https://") || token.starts_with("http://") {
4978            let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
4979            if cleaned.len() > "https://".len() {
4980                return Some(cleaned.to_string());
4981            }
4982        }
4983        None
4984    })
4985}
4986
4987fn max_tool_iterations() -> usize {
4988    let default_iterations = 25usize;
4989    std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
4990        .ok()
4991        .and_then(|raw| raw.trim().parse::<usize>().ok())
4992        .filter(|value| *value > 0)
4993        .unwrap_or(default_iterations)
4994}
4995
4996fn strict_write_retry_max_attempts() -> usize {
4997    std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
4998        .ok()
4999        .and_then(|raw| raw.trim().parse::<usize>().ok())
5000        .filter(|value| *value > 0)
5001        .unwrap_or(3)
5002}
5003
5004fn provider_stream_connect_timeout_ms() -> usize {
5005    std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
5006        .ok()
5007        .and_then(|raw| raw.trim().parse::<usize>().ok())
5008        .filter(|value| *value > 0)
5009        .unwrap_or(90_000)
5010}
5011
5012fn provider_stream_idle_timeout_ms() -> usize {
5013    std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
5014        .ok()
5015        .and_then(|raw| raw.trim().parse::<usize>().ok())
5016        .filter(|value| *value > 0)
5017        .unwrap_or(90_000)
5018}
5019
5020fn prompt_context_hook_timeout_ms() -> usize {
5021    std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
5022        .ok()
5023        .and_then(|raw| raw.trim().parse::<usize>().ok())
5024        .filter(|value| *value > 0)
5025        .unwrap_or(5_000)
5026}
5027
5028fn permission_wait_timeout_ms() -> usize {
5029    std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
5030        .ok()
5031        .and_then(|raw| raw.trim().parse::<usize>().ok())
5032        .filter(|value| *value > 0)
5033        .unwrap_or(15_000)
5034}
5035
5036fn tool_exec_timeout_ms() -> usize {
5037    std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
5038        .ok()
5039        .and_then(|raw| raw.trim().parse::<usize>().ok())
5040        .filter(|value| *value > 0)
5041        .unwrap_or(45_000)
5042}
5043
5044fn is_guard_budget_tool_output(output: &str) -> bool {
5045    output
5046        .to_ascii_lowercase()
5047        .contains("per-run guard budget exceeded")
5048}
5049
5050fn is_duplicate_signature_limit_output(output: &str) -> bool {
5051    output
5052        .to_ascii_lowercase()
5053        .contains("duplicate call signature retry limit reached")
5054}
5055
5056fn is_sensitive_path_candidate(path: &Path) -> bool {
5057    let lowered = path.to_string_lossy().to_ascii_lowercase();
5058
5059    // SSH / GPG directories
5060    if lowered.contains("/.ssh/") || lowered.ends_with("/.ssh") {
5061        return true;
5062    }
5063    if lowered.contains("/.gnupg/") || lowered.ends_with("/.gnupg") {
5064        return true;
5065    }
5066
5067    // Cloud credential files
5068    if lowered.contains("/.aws/credentials")
5069        || lowered.contains("/.config/gcloud/")
5070        || lowered.contains("/.docker/config.json")
5071        || lowered.contains("/.kube/config")
5072        || lowered.contains("/.git-credentials")
5073    {
5074        return true;
5075    }
5076
5077    // Package manager / tool secrets
5078    if lowered.ends_with("/.npmrc") || lowered.ends_with("/.netrc") || lowered.ends_with("/.pypirc")
5079    {
5080        return true;
5081    }
5082
5083    // Known private key file names (use file_name() to avoid false positives on paths)
5084    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
5085        let n = name.to_ascii_lowercase();
5086        // .env files (but not .env.example — check no extra extension after .env)
5087        if n == ".env"
5088            || n.starts_with(".env.") && !n.ends_with(".example") && !n.ends_with(".sample")
5089        {
5090            return true;
5091        }
5092        // Key identity files
5093        if n.starts_with("id_rsa")
5094            || n.starts_with("id_ed25519")
5095            || n.starts_with("id_ecdsa")
5096            || n.starts_with("id_dsa")
5097        {
5098            return true;
5099        }
5100    }
5101
5102    // Certificate / private key extensions — use extension() to avoid substring false positives
5103    // e.g. keyboard.rs has no .key extension, so it won't match here.
5104    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
5105        let ext_lower = ext.to_ascii_lowercase();
5106        if matches!(
5107            ext_lower.as_str(),
5108            "pem" | "p12" | "pfx" | "key" | "keystore" | "jks"
5109        ) {
5110            return true;
5111        }
5112    }
5113
5114    false
5115}
5116
5117fn shell_command_targets_sensitive_path(command: &str) -> bool {
5118    let lower = command.to_ascii_lowercase();
5119    let patterns = [
5120        "/.ssh/",
5121        "/.gnupg/",
5122        "/.aws/credentials",
5123        "/.config/gcloud/",
5124        "/.docker/config.json",
5125        "/.kube/config",
5126        "/.git-credentials",
5127        "id_rsa",
5128        "id_ed25519",
5129        "id_ecdsa",
5130        "id_dsa",
5131        ".npmrc",
5132        ".netrc",
5133        ".pypirc",
5134    ];
5135    // Check structural path patterns
5136    if patterns.iter().any(|p| lower.contains(p)) {
5137        return true;
5138    }
5139    // Check .env (standalone, not .env.example)
5140    if let Some(pos) = lower.find(".env") {
5141        let after = &lower[pos + 4..];
5142        if after.is_empty() || after.starts_with(' ') || after.starts_with('/') {
5143            return true;
5144        }
5145    }
5146    false
5147}
5148
5149#[derive(Debug, Clone)]
5150struct NormalizedToolArgs {
5151    args: Value,
5152    args_source: String,
5153    args_integrity: String,
5154    raw_args_state: RawToolArgsState,
5155    query: Option<String>,
5156    missing_terminal: bool,
5157    missing_terminal_reason: Option<String>,
5158}
5159
5160#[derive(Debug, Clone)]
5161struct ParsedToolCall {
5162    tool: String,
5163    args: Value,
5164    call_id: Option<String>,
5165}
5166
5167#[cfg(test)]
5168fn normalize_tool_args(
5169    tool_name: &str,
5170    raw_args: Value,
5171    latest_user_text: &str,
5172    latest_assistant_context: &str,
5173) -> NormalizedToolArgs {
5174    normalize_tool_args_with_mode(
5175        tool_name,
5176        raw_args,
5177        latest_user_text,
5178        latest_assistant_context,
5179        WritePathRecoveryMode::Heuristic,
5180    )
5181}
5182
5183fn normalize_tool_args_with_mode(
5184    tool_name: &str,
5185    raw_args: Value,
5186    latest_user_text: &str,
5187    latest_assistant_context: &str,
5188    write_path_recovery_mode: WritePathRecoveryMode,
5189) -> NormalizedToolArgs {
5190    let normalized_tool = normalize_tool_name(tool_name);
5191    let original_args = raw_args.clone();
5192    let mut args = raw_args;
5193    let mut args_source = if args.is_string() {
5194        "provider_string".to_string()
5195    } else {
5196        "provider_json".to_string()
5197    };
5198    let mut args_integrity = "ok".to_string();
5199    let raw_args_state = classify_raw_tool_args_state(&args);
5200    let mut query = None;
5201    let mut missing_terminal = false;
5202    let mut missing_terminal_reason = None;
5203
5204    if normalized_tool == "websearch" {
5205        if let Some(found) = extract_websearch_query(&args) {
5206            query = Some(found);
5207            args = set_websearch_query_and_source(args, query.clone(), "tool_args");
5208        } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
5209            args_source = "inferred_from_user".to_string();
5210            args_integrity = "recovered".to_string();
5211            query = Some(inferred);
5212            args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
5213        } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
5214            args_source = "recovered_from_context".to_string();
5215            args_integrity = "recovered".to_string();
5216            query = Some(recovered);
5217            args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
5218        } else {
5219            args_source = "missing".to_string();
5220            args_integrity = "empty".to_string();
5221            missing_terminal = true;
5222            missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
5223        }
5224    } else if tool_name_requires_query_arg(&normalized_tool) {
5225        if let Some(found) = extract_query_arg(&args) {
5226            query = Some(found);
5227            args = set_query_arg(args, query.clone(), "tool_args");
5228        } else if let Some(inferred) = infer_query_from_text(latest_user_text) {
5229            args_source = "inferred_from_user".to_string();
5230            args_integrity = "recovered".to_string();
5231            query = Some(inferred);
5232            args = set_query_arg(args, query.clone(), "inferred_from_user");
5233        } else if let Some(recovered) = infer_query_from_text(latest_assistant_context) {
5234            args_source = "recovered_from_context".to_string();
5235            args_integrity = "recovered".to_string();
5236            query = Some(recovered);
5237            args = set_query_arg(args, query.clone(), "recovered_from_context");
5238        } else {
5239            args_source = "missing".to_string();
5240            args_integrity = "empty".to_string();
5241            missing_terminal = true;
5242            missing_terminal_reason = Some("QUERY_MISSING".to_string());
5243        }
5244    } else if tool_name_requires_doc_path_arg(&normalized_tool) {
5245        if let Some(path) = extract_doc_path_arg(&args) {
5246            args = set_doc_path_arg(args, path);
5247        } else if let Some(inferred) = infer_doc_path_from_text(latest_user_text) {
5248            args_source = "inferred_from_user".to_string();
5249            args_integrity = "recovered".to_string();
5250            args = set_doc_path_arg(args, inferred);
5251        } else if let Some(recovered) = infer_doc_path_from_text(latest_assistant_context) {
5252            args_source = "recovered_from_context".to_string();
5253            args_integrity = "recovered".to_string();
5254            args = set_doc_path_arg(args, recovered);
5255        } else {
5256            args_source = "missing".to_string();
5257            args_integrity = "empty".to_string();
5258            missing_terminal = true;
5259            missing_terminal_reason = Some("DOC_PATH_MISSING".to_string());
5260        }
5261    } else if is_shell_tool_name(&normalized_tool) {
5262        if let Some(command) = extract_shell_command(&args) {
5263            args = set_shell_command(args, command);
5264        } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
5265            args_source = "inferred_from_context".to_string();
5266            args_integrity = "recovered".to_string();
5267            args = set_shell_command(args, inferred);
5268        } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
5269            args_source = "inferred_from_user".to_string();
5270            args_integrity = "recovered".to_string();
5271            args = set_shell_command(args, inferred);
5272        } else {
5273            args_source = "missing".to_string();
5274            args_integrity = "empty".to_string();
5275            missing_terminal = true;
5276            missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
5277        }
5278    } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5279        if let Some(path) = extract_file_path_arg(&args) {
5280            args = set_file_path_arg(args, path);
5281        } else if normalized_tool == "write" || normalized_tool == "edit" {
5282            // Check if the model explicitly provided a non-trivial path argument that was
5283            // rejected by sanitization. In that case, do NOT silently recover with a
5284            // heuristic path — that creates garbage files. Return a terminal error so the
5285            // model can retry with a correct path.
5286            //
5287            // We exclude trivial/placeholder paths ("./", ".", "") because those indicate
5288            // the model didn't actually know the path and recovery is appropriate.
5289            let model_explicit_path_value = args
5290                .as_object()
5291                .and_then(|obj| obj.get("path"))
5292                .and_then(Value::as_str)
5293                .map(str::trim)
5294                .filter(|p| !p.is_empty());
5295            let path_is_trivial_placeholder = model_explicit_path_value
5296                .is_some_and(|p| matches!(p, "./" | "." | ".." | "/" | "~"));
5297            let model_explicitly_set_nontrivial_path = model_explicit_path_value
5298                .is_some_and(|p| p.len() > 2)
5299                && !path_is_trivial_placeholder;
5300            if model_explicitly_set_nontrivial_path {
5301                args_source = "rejected".to_string();
5302                args_integrity = "rejected_path".to_string();
5303                missing_terminal = true;
5304                missing_terminal_reason = Some("WRITE_PATH_REJECTED".to_string());
5305            } else if let Some(inferred) =
5306                infer_required_output_target_path_from_text(latest_user_text).or_else(|| {
5307                    infer_required_output_target_path_from_text(latest_assistant_context)
5308                })
5309            {
5310                args_source = "recovered_from_context".to_string();
5311                args_integrity = "recovered".to_string();
5312                args = set_file_path_arg(args, inferred);
5313            } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
5314                if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
5315                    args_source = "inferred_from_user".to_string();
5316                    args_integrity = "recovered".to_string();
5317                    args = set_file_path_arg(args, inferred);
5318                } else {
5319                    args_source = "missing".to_string();
5320                    args_integrity = "empty".to_string();
5321                    missing_terminal = true;
5322                    missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5323                }
5324            } else {
5325                args_source = "missing".to_string();
5326                args_integrity = "empty".to_string();
5327                missing_terminal = true;
5328                missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5329            }
5330        } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
5331            args_source = "inferred_from_user".to_string();
5332            args_integrity = "recovered".to_string();
5333            args = set_file_path_arg(args, inferred);
5334        } else {
5335            args_source = "missing".to_string();
5336            args_integrity = "empty".to_string();
5337            missing_terminal = true;
5338            missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5339        }
5340
5341        if !missing_terminal && normalized_tool == "write" {
5342            if let Some(content) = extract_write_content_arg(&args) {
5343                args = set_write_content_arg(args, content);
5344            } else if let Some(recovered) =
5345                infer_write_content_from_assistant_context(latest_assistant_context)
5346            {
5347                args_source = "recovered_from_context".to_string();
5348                args_integrity = "recovered".to_string();
5349                args = set_write_content_arg(args, recovered);
5350            } else {
5351                args_source = "missing".to_string();
5352                args_integrity = "empty".to_string();
5353                missing_terminal = true;
5354                missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
5355            }
5356        }
5357    } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
5358        if let Some(url) = extract_webfetch_url_arg(&args) {
5359            args = set_webfetch_url_arg(args, url);
5360        } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
5361            args_source = "inferred_from_context".to_string();
5362            args_integrity = "recovered".to_string();
5363            args = set_webfetch_url_arg(args, inferred);
5364        } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
5365            args_source = "inferred_from_user".to_string();
5366            args_integrity = "recovered".to_string();
5367            args = set_webfetch_url_arg(args, inferred);
5368        } else {
5369            args_source = "missing".to_string();
5370            args_integrity = "empty".to_string();
5371            missing_terminal = true;
5372            missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
5373        }
5374    } else if tool_name_requires_task_arg(&normalized_tool) {
5375        if let Some(task) = extract_task_arg(&args) {
5376            args = set_task_arg(args, task);
5377        } else if let Some(inferred) = infer_task_from_text(latest_user_text) {
5378            args_source = "inferred_from_user".to_string();
5379            args_integrity = "recovered".to_string();
5380            args = set_task_arg(args, inferred);
5381        } else if let Some(recovered) = infer_task_from_text(latest_assistant_context) {
5382            args_source = "recovered_from_context".to_string();
5383            args_integrity = "recovered".to_string();
5384            args = set_task_arg(args, recovered);
5385        } else {
5386            args_source = "missing".to_string();
5387            args_integrity = "empty".to_string();
5388            missing_terminal = true;
5389            missing_terminal_reason = Some("TASK_MISSING".to_string());
5390        }
5391    } else if normalized_tool == "pack_builder" {
5392        let mode = extract_pack_builder_mode_arg(&args);
5393        let plan_id = extract_pack_builder_plan_id_arg(&args);
5394        if mode.as_deref() == Some("apply") && plan_id.is_none() {
5395            if let Some(inferred_plan) =
5396                infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5397            {
5398                args_source = "recovered_from_context".to_string();
5399                args_integrity = "recovered".to_string();
5400                args = set_pack_builder_apply_args(args, inferred_plan);
5401            } else {
5402                args_source = "missing".to_string();
5403                args_integrity = "empty".to_string();
5404                missing_terminal = true;
5405                missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
5406            }
5407        } else if mode.as_deref() == Some("apply") {
5408            args = ensure_pack_builder_default_mode(args);
5409        } else if let Some(inferred_plan) =
5410            infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5411        {
5412            args_source = "recovered_from_context".to_string();
5413            args_integrity = "recovered".to_string();
5414            args = set_pack_builder_apply_args(args, inferred_plan);
5415        } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
5416            args = set_pack_builder_goal_arg(args, goal);
5417        } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
5418            args_source = "inferred_from_user".to_string();
5419            args_integrity = "recovered".to_string();
5420            args = set_pack_builder_goal_arg(args, inferred);
5421        } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
5422        {
5423            args_source = "recovered_from_context".to_string();
5424            args_integrity = "recovered".to_string();
5425            args = set_pack_builder_goal_arg(args, recovered);
5426        } else {
5427            args_source = "missing".to_string();
5428            args_integrity = "empty".to_string();
5429            missing_terminal = true;
5430            missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
5431        }
5432        args = ensure_pack_builder_default_mode(args);
5433    } else if is_email_delivery_tool_name(&normalized_tool) {
5434        let sanitized = sanitize_email_attachment_args(args);
5435        if sanitized != original_args {
5436            args_source = "sanitized_attachment".to_string();
5437            args_integrity = "recovered".to_string();
5438        }
5439        args = sanitized;
5440    }
5441
5442    NormalizedToolArgs {
5443        args,
5444        args_source,
5445        args_integrity,
5446        raw_args_state,
5447        query,
5448        missing_terminal,
5449        missing_terminal_reason,
5450    }
5451}
5452
5453fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
5454    match raw_args {
5455        Value::Null => RawToolArgsState::Empty,
5456        Value::Object(obj) => {
5457            if obj.is_empty() {
5458                RawToolArgsState::Empty
5459            } else {
5460                RawToolArgsState::Present
5461            }
5462        }
5463        Value::Array(items) => {
5464            if items.is_empty() {
5465                RawToolArgsState::Empty
5466            } else {
5467                RawToolArgsState::Present
5468            }
5469        }
5470        Value::String(raw) => {
5471            let trimmed = raw.trim();
5472            if trimmed.is_empty() {
5473                return RawToolArgsState::Empty;
5474            }
5475            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5476                return classify_raw_tool_args_state(&parsed);
5477            }
5478            if parse_function_style_args(trimmed).is_empty() {
5479                return RawToolArgsState::Unparseable;
5480            }
5481            RawToolArgsState::Present
5482        }
5483        _ => RawToolArgsState::Present,
5484    }
5485}
5486
5487fn args_missing_or_empty(args: &Value) -> bool {
5488    match args {
5489        Value::Null => true,
5490        Value::Object(obj) => obj.is_empty(),
5491        Value::Array(items) => items.is_empty(),
5492        Value::String(raw) => raw.trim().is_empty(),
5493        _ => false,
5494    }
5495}
5496
5497fn persisted_failed_tool_args(raw_args: &Value, normalized_args: &Value) -> Value {
5498    if args_missing_or_empty(raw_args) && !args_missing_or_empty(normalized_args) {
5499        normalized_args.clone()
5500    } else {
5501        raw_args.clone()
5502    }
5503}
5504
5505fn provider_specific_write_reason(
5506    tool: &str,
5507    missing_reason: &str,
5508    raw_args_state: RawToolArgsState,
5509) -> Option<String> {
5510    if tool != "write"
5511        || !matches!(
5512            missing_reason,
5513            "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
5514        )
5515    {
5516        return None;
5517    }
5518    match raw_args_state {
5519        RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
5520        RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
5521        RawToolArgsState::Present => None,
5522    }
5523}
5524
5525fn is_shell_tool_name(tool_name: &str) -> bool {
5526    matches!(
5527        tool_name.trim().to_ascii_lowercase().as_str(),
5528        "bash" | "shell" | "powershell" | "cmd"
5529    )
5530}
5531
5532fn email_tool_name_tokens(tool_name: &str) -> Vec<String> {
5533    tool_name
5534        .trim()
5535        .to_ascii_lowercase()
5536        .chars()
5537        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
5538        .collect::<String>()
5539        .split_whitespace()
5540        .map(str::to_string)
5541        .collect::<Vec<_>>()
5542}
5543
5544fn email_tool_name_compact(tool_name: &str) -> String {
5545    tool_name
5546        .trim()
5547        .to_ascii_lowercase()
5548        .chars()
5549        .filter(|ch| ch.is_ascii_alphanumeric())
5550        .collect::<String>()
5551}
5552
5553fn is_email_delivery_tool_name(tool_name: &str) -> bool {
5554    let tokens = email_tool_name_tokens(tool_name);
5555    let compact = email_tool_name_compact(tool_name);
5556    let looks_like_email_provider = tokens.iter().any(|token| {
5557        matches!(
5558            token.as_str(),
5559            "email"
5560                | "mail"
5561                | "gmail"
5562                | "outlook"
5563                | "smtp"
5564                | "imap"
5565                | "inbox"
5566                | "mailbox"
5567                | "mailer"
5568                | "exchange"
5569                | "sendgrid"
5570                | "mailgun"
5571                | "postmark"
5572                | "resend"
5573                | "ses"
5574        )
5575    });
5576    if !looks_like_email_provider {
5577        return false;
5578    }
5579    tokens.iter().any(|token| {
5580        matches!(
5581            token.as_str(),
5582            "send" | "deliver" | "reply" | "draft" | "compose" | "create"
5583        )
5584    }) || compact.contains("sendemail")
5585        || compact.contains("emailsend")
5586        || compact.contains("replyemail")
5587        || compact.contains("emailreply")
5588        || compact.contains("draftemail")
5589        || compact.contains("emaildraft")
5590        || compact.contains("composeemail")
5591        || compact.contains("emailcompose")
5592        || compact.contains("createemaildraft")
5593        || compact.contains("emailcreatedraft")
5594}
5595
5596fn sanitize_email_attachment_args(args: Value) -> Value {
5597    let mut obj = match args {
5598        Value::Object(map) => map,
5599        other => return other,
5600    };
5601    if let Some(Value::Object(attachment)) = obj.get("attachment") {
5602        let s3key = attachment
5603            .get("s3key")
5604            .and_then(Value::as_str)
5605            .map(str::trim)
5606            .unwrap_or("");
5607        if s3key.is_empty() {
5608            obj.remove("attachment");
5609        }
5610    } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
5611        obj.remove("attachment");
5612    }
5613    if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
5614        attachments.retain(|entry| {
5615            entry
5616                .get("s3key")
5617                .and_then(Value::as_str)
5618                .map(str::trim)
5619                .map(|value| !value.is_empty())
5620                .unwrap_or(false)
5621        });
5622        if attachments.is_empty() {
5623            obj.remove("attachments");
5624        }
5625    }
5626    Value::Object(obj)
5627}
5628
5629fn set_file_path_arg(args: Value, path: String) -> Value {
5630    let mut obj = args.as_object().cloned().unwrap_or_default();
5631    obj.insert("path".to_string(), Value::String(path));
5632    Value::Object(obj)
5633}
5634
5635fn normalize_workspace_alias_path(path: &str, workspace_root: &str) -> Option<String> {
5636    let trimmed = path.trim();
5637    if trimmed.is_empty() {
5638        return None;
5639    }
5640    let normalized = trimmed.replace('\\', "/");
5641    if normalized == "/workspace" {
5642        return Some(workspace_root.to_string());
5643    }
5644    if let Some(rest) = normalized.strip_prefix("/workspace/") {
5645        if rest.trim().is_empty() {
5646            return Some(workspace_root.to_string());
5647        }
5648        return Some(rest.trim().to_string());
5649    }
5650    None
5651}
5652
5653fn rewrite_workspace_alias_tool_args(tool: &str, args: Value, workspace_root: &str) -> Value {
5654    let normalized_tool = normalize_tool_name(tool);
5655    if !matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5656        return args;
5657    }
5658    let Some(path) = extract_file_path_arg(&args) else {
5659        return args;
5660    };
5661    let Some(rewritten) = normalize_workspace_alias_path(&path, workspace_root) else {
5662        return args;
5663    };
5664    set_file_path_arg(args, rewritten)
5665}
5666
5667fn set_write_content_arg(args: Value, content: String) -> Value {
5668    let mut obj = args.as_object().cloned().unwrap_or_default();
5669    obj.insert("content".to_string(), Value::String(content));
5670    Value::Object(obj)
5671}
5672
5673fn extract_file_path_arg(args: &Value) -> Option<String> {
5674    extract_file_path_arg_internal(args, 0)
5675}
5676
5677fn extract_write_content_arg(args: &Value) -> Option<String> {
5678    extract_write_content_arg_internal(args, 0)
5679}
5680
5681fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
5682    if depth > 5 {
5683        return None;
5684    }
5685
5686    match args {
5687        Value::String(raw) => {
5688            let trimmed = raw.trim();
5689            if trimmed.is_empty() {
5690                return None;
5691            }
5692            // If the provider sent plain string args, treat it as a path directly.
5693            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5694                return sanitize_path_candidate(trimmed);
5695            }
5696            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5697                return extract_file_path_arg_internal(&parsed, depth + 1);
5698            }
5699            sanitize_path_candidate(trimmed)
5700        }
5701        Value::Array(items) => items
5702            .iter()
5703            .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
5704        Value::Object(obj) => {
5705            for key in FILE_PATH_KEYS {
5706                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5707                    if let Some(path) = sanitize_path_candidate(raw) {
5708                        return Some(path);
5709                    }
5710                }
5711            }
5712            for container in NESTED_ARGS_KEYS {
5713                if let Some(nested) = obj.get(container) {
5714                    if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
5715                        return Some(path);
5716                    }
5717                }
5718            }
5719            None
5720        }
5721        _ => None,
5722    }
5723}
5724
5725fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
5726    if depth > 5 {
5727        return None;
5728    }
5729
5730    match args {
5731        Value::String(raw) => {
5732            let trimmed = raw.trim();
5733            if trimmed.is_empty() {
5734                return None;
5735            }
5736            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5737                return extract_write_content_arg_internal(&parsed, depth + 1);
5738            }
5739            // Some providers collapse args to a plain string. Recover as content only when
5740            // it does not look like a standalone file path token.
5741            if sanitize_path_candidate(trimmed).is_some()
5742                && !trimmed.contains('\n')
5743                && trimmed.split_whitespace().count() <= 3
5744            {
5745                return None;
5746            }
5747            Some(trimmed.to_string())
5748        }
5749        Value::Array(items) => items
5750            .iter()
5751            .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
5752        Value::Object(obj) => {
5753            for key in WRITE_CONTENT_KEYS {
5754                if let Some(value) = obj.get(key) {
5755                    if let Some(raw) = value.as_str() {
5756                        if !raw.is_empty() {
5757                            return Some(raw.to_string());
5758                        }
5759                    } else if let Some(recovered) =
5760                        extract_write_content_arg_internal(value, depth + 1)
5761                    {
5762                        return Some(recovered);
5763                    }
5764                }
5765            }
5766            for container in NESTED_ARGS_KEYS {
5767                if let Some(nested) = obj.get(container) {
5768                    if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
5769                        return Some(content);
5770                    }
5771                }
5772            }
5773            None
5774        }
5775        _ => None,
5776    }
5777}
5778
5779fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
5780    let text = latest_assistant_context.trim();
5781    if text.len() < 32 {
5782        return None;
5783    }
5784    Some(text.to_string())
5785}
5786
5787fn set_shell_command(args: Value, command: String) -> Value {
5788    let mut obj = args.as_object().cloned().unwrap_or_default();
5789    obj.insert("command".to_string(), Value::String(command));
5790    Value::Object(obj)
5791}
5792
5793fn extract_shell_command(args: &Value) -> Option<String> {
5794    extract_shell_command_internal(args, 0)
5795}
5796
5797fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
5798    if depth > 5 {
5799        return None;
5800    }
5801
5802    match args {
5803        Value::String(raw) => {
5804            let trimmed = raw.trim();
5805            if trimmed.is_empty() {
5806                return None;
5807            }
5808            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5809                return sanitize_shell_command_candidate(trimmed);
5810            }
5811            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5812                return extract_shell_command_internal(&parsed, depth + 1);
5813            }
5814            sanitize_shell_command_candidate(trimmed)
5815        }
5816        Value::Array(items) => items
5817            .iter()
5818            .find_map(|item| extract_shell_command_internal(item, depth + 1)),
5819        Value::Object(obj) => {
5820            for key in SHELL_COMMAND_KEYS {
5821                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5822                    if let Some(command) = sanitize_shell_command_candidate(raw) {
5823                        return Some(command);
5824                    }
5825                }
5826            }
5827            for container in NESTED_ARGS_KEYS {
5828                if let Some(nested) = obj.get(container) {
5829                    if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
5830                        return Some(command);
5831                    }
5832                }
5833            }
5834            None
5835        }
5836        _ => None,
5837    }
5838}
5839
5840fn infer_shell_command_from_text(text: &str) -> Option<String> {
5841    let trimmed = text.trim();
5842    if trimmed.is_empty() {
5843        return None;
5844    }
5845
5846    // Prefer explicit backtick commands first.
5847    let mut in_tick = false;
5848    let mut tick_buf = String::new();
5849    for ch in trimmed.chars() {
5850        if ch == '`' {
5851            if in_tick {
5852                if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
5853                    if looks_like_shell_command(&candidate) {
5854                        return Some(candidate);
5855                    }
5856                }
5857                tick_buf.clear();
5858            }
5859            in_tick = !in_tick;
5860            continue;
5861        }
5862        if in_tick {
5863            tick_buf.push(ch);
5864        }
5865    }
5866
5867    for line in trimmed.lines() {
5868        let line = line.trim();
5869        if line.is_empty() {
5870            continue;
5871        }
5872        let lower = line.to_ascii_lowercase();
5873        for prefix in [
5874            "run ",
5875            "execute ",
5876            "call ",
5877            "use bash ",
5878            "use shell ",
5879            "bash ",
5880            "shell ",
5881            "powershell ",
5882            "pwsh ",
5883        ] {
5884            if lower.starts_with(prefix) {
5885                let candidate = line[prefix.len()..].trim();
5886                if let Some(command) = sanitize_shell_command_candidate(candidate) {
5887                    if looks_like_shell_command(&command) {
5888                        return Some(command);
5889                    }
5890                }
5891            }
5892        }
5893    }
5894
5895    None
5896}
5897
5898fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
5899    let mut obj = args.as_object().cloned().unwrap_or_default();
5900    if let Some(q) = query {
5901        obj.insert("query".to_string(), Value::String(q));
5902    }
5903    obj.insert(
5904        "__query_source".to_string(),
5905        Value::String(query_source.to_string()),
5906    );
5907    Value::Object(obj)
5908}
5909
5910fn set_webfetch_url_arg(args: Value, url: String) -> Value {
5911    let mut obj = args.as_object().cloned().unwrap_or_default();
5912    obj.insert("url".to_string(), Value::String(url));
5913    Value::Object(obj)
5914}
5915
5916fn set_query_arg(args: Value, query: Option<String>, _source: &str) -> Value {
5917    let mut obj = args.as_object().cloned().unwrap_or_default();
5918    if let Some(query) = query {
5919        obj.insert("query".to_string(), Value::String(query));
5920    }
5921    Value::Object(obj)
5922}
5923
5924fn set_doc_path_arg(args: Value, path: String) -> Value {
5925    let mut obj = args.as_object().cloned().unwrap_or_default();
5926    obj.insert("path".to_string(), Value::String(path));
5927    Value::Object(obj)
5928}
5929
5930fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
5931    let mut obj = args.as_object().cloned().unwrap_or_default();
5932    obj.insert("goal".to_string(), Value::String(goal));
5933    Value::Object(obj)
5934}
5935
5936fn set_task_arg(args: Value, task: String) -> Value {
5937    let mut obj = args.as_object().cloned().unwrap_or_default();
5938    obj.insert("task".to_string(), Value::String(task));
5939    Value::Object(obj)
5940}
5941
5942fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
5943    let mut obj = args.as_object().cloned().unwrap_or_default();
5944    obj.insert("mode".to_string(), Value::String("apply".to_string()));
5945    obj.insert("plan_id".to_string(), Value::String(plan_id));
5946    obj.insert(
5947        "approve_connector_registration".to_string(),
5948        Value::Bool(true),
5949    );
5950    obj.insert("approve_pack_install".to_string(), Value::Bool(true));
5951    obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
5952    Value::Object(obj)
5953}
5954
5955fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
5956    for key in ["mode"] {
5957        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5958            let mode = value.trim().to_ascii_lowercase();
5959            if !mode.is_empty() {
5960                return Some(mode);
5961            }
5962        }
5963    }
5964    for container in ["arguments", "args", "input", "params"] {
5965        if let Some(obj) = args.get(container) {
5966            if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
5967                let mode = value.trim().to_ascii_lowercase();
5968                if !mode.is_empty() {
5969                    return Some(mode);
5970                }
5971            }
5972        }
5973    }
5974    None
5975}
5976
5977fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
5978    for key in ["plan_id", "planId"] {
5979        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5980            let plan_id = value.trim();
5981            if !plan_id.is_empty() {
5982                return Some(plan_id.to_string());
5983            }
5984        }
5985    }
5986    for container in ["arguments", "args", "input", "params"] {
5987        if let Some(obj) = args.get(container) {
5988            for key in ["plan_id", "planId"] {
5989                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5990                    let plan_id = value.trim();
5991                    if !plan_id.is_empty() {
5992                        return Some(plan_id.to_string());
5993                    }
5994                }
5995            }
5996        }
5997    }
5998    None
5999}
6000
6001fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
6002    if text.trim().is_empty() {
6003        return None;
6004    }
6005    let bytes = text.as_bytes();
6006    let mut idx = 0usize;
6007    while idx + 5 <= bytes.len() {
6008        if &bytes[idx..idx + 5] != b"plan-" {
6009            idx += 1;
6010            continue;
6011        }
6012        let mut end = idx + 5;
6013        while end < bytes.len() {
6014            let ch = bytes[end] as char;
6015            if ch.is_ascii_alphanumeric() || ch == '-' {
6016                end += 1;
6017            } else {
6018                break;
6019            }
6020        }
6021        if end > idx + 5 {
6022            let candidate = &text[idx..end];
6023            if candidate.len() >= 10 {
6024                return Some(candidate.to_string());
6025            }
6026        }
6027        idx = end.saturating_add(1);
6028    }
6029    None
6030}
6031
6032fn is_pack_builder_confirmation_text(text: &str) -> bool {
6033    let trimmed = text.trim();
6034    if trimmed.is_empty() {
6035        return false;
6036    }
6037    let lower = trimmed.to_ascii_lowercase();
6038    matches!(
6039        lower.as_str(),
6040        "confirm"
6041            | "confirmed"
6042            | "approve"
6043            | "approved"
6044            | "yes"
6045            | "y"
6046            | "ok"
6047            | "okay"
6048            | "go"
6049            | "go ahead"
6050            | "ship it"
6051            | "do it"
6052            | "apply"
6053            | "run it"
6054            | "✅"
6055            | "👍"
6056    )
6057}
6058
6059fn infer_pack_builder_apply_plan_id(
6060    latest_user_text: &str,
6061    latest_assistant_context: &str,
6062) -> Option<String> {
6063    if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
6064        return Some(plan_id);
6065    }
6066    if !is_pack_builder_confirmation_text(latest_user_text) {
6067        return None;
6068    }
6069    extract_pack_builder_plan_id_from_text(latest_assistant_context)
6070}
6071
6072fn ensure_pack_builder_default_mode(args: Value) -> Value {
6073    let mut obj = args.as_object().cloned().unwrap_or_default();
6074    let has_mode = obj
6075        .get("mode")
6076        .and_then(Value::as_str)
6077        .map(str::trim)
6078        .is_some_and(|v| !v.is_empty());
6079    if !has_mode {
6080        obj.insert("mode".to_string(), Value::String("preview".to_string()));
6081    }
6082    Value::Object(obj)
6083}
6084
6085fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
6086    const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
6087    for key in URL_KEYS {
6088        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6089            if let Some(url) = sanitize_url_candidate(value) {
6090                return Some(url);
6091            }
6092        }
6093    }
6094    for container in ["arguments", "args", "input", "params"] {
6095        if let Some(obj) = args.get(container) {
6096            for key in URL_KEYS {
6097                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6098                    if let Some(url) = sanitize_url_candidate(value) {
6099                        return Some(url);
6100                    }
6101                }
6102            }
6103        }
6104    }
6105    args.as_str().and_then(sanitize_url_candidate)
6106}
6107
6108fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
6109    const GOAL_KEYS: [&str; 1] = ["goal"];
6110    for key in GOAL_KEYS {
6111        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6112            let trimmed = value.trim();
6113            if !trimmed.is_empty() {
6114                return Some(trimmed.to_string());
6115            }
6116        }
6117    }
6118    for container in ["arguments", "args", "input", "params"] {
6119        if let Some(obj) = args.get(container) {
6120            for key in GOAL_KEYS {
6121                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6122                    let trimmed = value.trim();
6123                    if !trimmed.is_empty() {
6124                        return Some(trimmed.to_string());
6125                    }
6126                }
6127            }
6128        }
6129    }
6130    args.as_str()
6131        .map(str::trim)
6132        .filter(|v| !v.is_empty())
6133        .map(ToString::to_string)
6134}
6135
6136fn extract_task_arg(args: &Value) -> Option<String> {
6137    const TASK_KEYS: [&str; 4] = ["task", "query", "question", "prompt"];
6138    for key in TASK_KEYS {
6139        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6140            let trimmed = value.trim();
6141            if !trimmed.is_empty() {
6142                return Some(trimmed.to_string());
6143            }
6144        }
6145    }
6146    for container in ["arguments", "args", "input", "params"] {
6147        if let Some(obj) = args.get(container) {
6148            for key in TASK_KEYS {
6149                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6150                    let trimmed = value.trim();
6151                    if !trimmed.is_empty() {
6152                        return Some(trimmed.to_string());
6153                    }
6154                }
6155            }
6156        }
6157    }
6158    args.as_str()
6159        .map(str::trim)
6160        .filter(|value| !value.is_empty())
6161        .map(ToString::to_string)
6162}
6163
6164fn extract_websearch_query(args: &Value) -> Option<String> {
6165    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6166    for key in QUERY_KEYS {
6167        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6168            if let Some(query) = sanitize_websearch_query_candidate(value) {
6169                return Some(query);
6170            }
6171        }
6172    }
6173    for container in ["arguments", "args", "input", "params"] {
6174        if let Some(obj) = args.get(container) {
6175            for key in QUERY_KEYS {
6176                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6177                    if let Some(query) = sanitize_websearch_query_candidate(value) {
6178                        return Some(query);
6179                    }
6180                }
6181            }
6182        }
6183    }
6184    args.as_str().and_then(sanitize_websearch_query_candidate)
6185}
6186
6187fn extract_query_arg(args: &Value) -> Option<String> {
6188    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6189    for key in QUERY_KEYS {
6190        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6191            let trimmed = value.trim();
6192            if !trimmed.is_empty() {
6193                return Some(trimmed.to_string());
6194            }
6195        }
6196    }
6197    for container in ["arguments", "args", "input", "params"] {
6198        if let Some(obj) = args.get(container) {
6199            for key in QUERY_KEYS {
6200                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6201                    let trimmed = value.trim();
6202                    if !trimmed.is_empty() {
6203                        return Some(trimmed.to_string());
6204                    }
6205                }
6206            }
6207        }
6208    }
6209    args.as_str()
6210        .map(str::trim)
6211        .filter(|value| !value.is_empty())
6212        .map(ToString::to_string)
6213}
6214
6215fn extract_doc_path_arg(args: &Value) -> Option<String> {
6216    const PATH_KEYS: [&str; 4] = ["path", "url", "doc", "page"];
6217    for key in PATH_KEYS {
6218        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6219            if let Some(path) = sanitize_doc_path_candidate(value) {
6220                return Some(path);
6221            }
6222        }
6223    }
6224    for container in ["arguments", "args", "input", "params"] {
6225        if let Some(obj) = args.get(container) {
6226            for key in PATH_KEYS {
6227                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6228                    if let Some(path) = sanitize_doc_path_candidate(value) {
6229                        return Some(path);
6230                    }
6231                }
6232            }
6233        }
6234    }
6235    args.as_str().and_then(sanitize_doc_path_candidate)
6236}
6237
6238fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
6239    let trimmed = raw.trim();
6240    if trimmed.is_empty() {
6241        return None;
6242    }
6243
6244    let lower = trimmed.to_ascii_lowercase();
6245    if let Some(start) = lower.find("<arg_value>") {
6246        let value_start = start + "<arg_value>".len();
6247        let tail = &trimmed[value_start..];
6248        let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
6249            &tail[..end]
6250        } else {
6251            tail
6252        };
6253        let cleaned = value.trim();
6254        if !cleaned.is_empty() {
6255            return Some(cleaned.to_string());
6256        }
6257    }
6258
6259    let without_wrappers = trimmed
6260        .replace("<arg_key>", " ")
6261        .replace("</arg_key>", " ")
6262        .replace("<arg_value>", " ")
6263        .replace("</arg_value>", " ");
6264    let collapsed = without_wrappers
6265        .split_whitespace()
6266        .collect::<Vec<_>>()
6267        .join(" ");
6268    if collapsed.is_empty() {
6269        return None;
6270    }
6271
6272    let collapsed_lower = collapsed.to_ascii_lowercase();
6273    if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
6274        let offset = collapsed.len() - rest.len();
6275        let q = collapsed[offset..].trim();
6276        if !q.is_empty() {
6277            return Some(q.to_string());
6278        }
6279    }
6280    if let Some(rest) = collapsed_lower.strip_prefix("query ") {
6281        let offset = collapsed.len() - rest.len();
6282        let q = collapsed[offset..].trim();
6283        if !q.is_empty() {
6284            return Some(q.to_string());
6285        }
6286    }
6287
6288    Some(collapsed)
6289}
6290
6291fn infer_websearch_query_from_text(text: &str) -> Option<String> {
6292    let trimmed = text.trim();
6293    if trimmed.is_empty() {
6294        return None;
6295    }
6296
6297    let lower = trimmed.to_lowercase();
6298    const PREFIXES: [&str; 11] = [
6299        "web search",
6300        "websearch",
6301        "search web for",
6302        "search web",
6303        "search for",
6304        "search",
6305        "look up",
6306        "lookup",
6307        "find",
6308        "web lookup",
6309        "query",
6310    ];
6311
6312    let mut candidate = trimmed;
6313    for prefix in PREFIXES {
6314        if lower.starts_with(prefix) && lower.len() >= prefix.len() {
6315            let remainder = trimmed[prefix.len()..]
6316                .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
6317            candidate = remainder;
6318            break;
6319        }
6320    }
6321
6322    let normalized = candidate
6323        .trim()
6324        .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
6325        .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
6326        .trim()
6327        .to_string();
6328
6329    if normalized.split_whitespace().count() < 2 {
6330        return None;
6331    }
6332    Some(normalized)
6333}
6334
6335fn infer_file_path_from_text(text: &str) -> Option<String> {
6336    let trimmed = text.trim();
6337    if trimmed.is_empty() {
6338        return None;
6339    }
6340
6341    let mut candidates: Vec<String> = Vec::new();
6342
6343    // Prefer backtick-delimited paths when available.
6344    let mut in_tick = false;
6345    let mut tick_buf = String::new();
6346    for ch in trimmed.chars() {
6347        if ch == '`' {
6348            if in_tick {
6349                let cand = sanitize_path_candidate(&tick_buf);
6350                if let Some(path) = cand {
6351                    candidates.push(path);
6352                }
6353                tick_buf.clear();
6354            }
6355            in_tick = !in_tick;
6356            continue;
6357        }
6358        if in_tick {
6359            tick_buf.push(ch);
6360        }
6361    }
6362
6363    // Fallback: scan whitespace tokens.
6364    for raw in trimmed.split_whitespace() {
6365        if let Some(path) = sanitize_path_candidate(raw) {
6366            candidates.push(path);
6367        }
6368    }
6369
6370    let mut deduped = Vec::new();
6371    let mut seen = HashSet::new();
6372    for candidate in candidates {
6373        if seen.insert(candidate.clone()) {
6374            deduped.push(candidate);
6375        }
6376    }
6377
6378    deduped.into_iter().next()
6379}
6380
6381fn infer_workspace_root_from_text(text: &str) -> Option<String> {
6382    text.lines().find_map(|line| {
6383        let trimmed = line.trim();
6384        let value = trimmed.strip_prefix("Workspace:")?.trim();
6385        sanitize_path_candidate(value)
6386    })
6387}
6388
6389fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
6390    // Format 1: structured JSON marker used by regular sessions
6391    //   "Required output target: {"path": "some-file.md"}"
6392    let marker = "Required output target:";
6393    if let Some(idx) = text.find(marker) {
6394        let tail = text[idx + marker.len()..].trim_start();
6395        if let Some(start) = tail.find('{') {
6396            let json_candidate = tail[start..]
6397                .lines()
6398                .take_while(|line| {
6399                    let trimmed = line.trim();
6400                    !(trimmed.is_empty() && !trimmed.starts_with('{'))
6401                })
6402                .collect::<Vec<_>>()
6403                .join("\n");
6404            if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
6405                if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
6406                    if let Some(clean) = sanitize_explicit_output_target_path(path) {
6407                        return Some(clean);
6408                    }
6409                }
6410            }
6411        }
6412    }
6413    // Format 2: automation prompt "Required Workspace Output" section
6414    //   "Create or update `some-file.md` relative to the workspace root."
6415    let auto_marker = "Create or update `";
6416    if let Some(idx) = text.find(auto_marker) {
6417        let after = &text[idx + auto_marker.len()..];
6418        if let Some(end) = after.find('`') {
6419            let path = after[..end].trim();
6420            if let Some(clean) = sanitize_explicit_output_target_path(path) {
6421                return Some(clean);
6422            }
6423        }
6424    }
6425    None
6426}
6427
6428fn infer_write_file_path_from_text(text: &str) -> Option<String> {
6429    let inferred = infer_file_path_from_text(text)?;
6430    let workspace_root = infer_workspace_root_from_text(text);
6431    if workspace_root
6432        .as_deref()
6433        .is_some_and(|root| root == inferred)
6434    {
6435        return None;
6436    }
6437    Some(inferred)
6438}
6439
6440fn infer_url_from_text(text: &str) -> Option<String> {
6441    let trimmed = text.trim();
6442    if trimmed.is_empty() {
6443        return None;
6444    }
6445
6446    let mut candidates: Vec<String> = Vec::new();
6447
6448    // Prefer backtick-delimited URLs when available.
6449    let mut in_tick = false;
6450    let mut tick_buf = String::new();
6451    for ch in trimmed.chars() {
6452        if ch == '`' {
6453            if in_tick {
6454                if let Some(url) = sanitize_url_candidate(&tick_buf) {
6455                    candidates.push(url);
6456                }
6457                tick_buf.clear();
6458            }
6459            in_tick = !in_tick;
6460            continue;
6461        }
6462        if in_tick {
6463            tick_buf.push(ch);
6464        }
6465    }
6466
6467    // Fallback: scan whitespace tokens.
6468    for raw in trimmed.split_whitespace() {
6469        if let Some(url) = sanitize_url_candidate(raw) {
6470            candidates.push(url);
6471        }
6472    }
6473
6474    let mut seen = HashSet::new();
6475    candidates
6476        .into_iter()
6477        .find(|candidate| seen.insert(candidate.clone()))
6478}
6479
6480fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
6481    let trimmed = text.trim();
6482    if trimmed.is_empty() {
6483        None
6484    } else {
6485        Some(trimmed.to_string())
6486    }
6487}
6488
6489fn infer_task_from_text(text: &str) -> Option<String> {
6490    let trimmed = text.trim();
6491    if trimmed.is_empty() {
6492        None
6493    } else {
6494        Some(trimmed.to_string())
6495    }
6496}
6497
6498fn infer_query_from_text(text: &str) -> Option<String> {
6499    let trimmed = text.trim();
6500    if trimmed.is_empty() {
6501        None
6502    } else {
6503        Some(trimmed.to_string())
6504    }
6505}
6506
6507fn infer_doc_path_from_text(text: &str) -> Option<String> {
6508    if let Some(url) = infer_url_from_text(text) {
6509        return Some(url);
6510    }
6511
6512    let trimmed = text.trim();
6513    if trimmed.is_empty() {
6514        return None;
6515    }
6516
6517    let mut candidates: Vec<String> = Vec::new();
6518
6519    let mut in_tick = false;
6520    let mut tick_buf = String::new();
6521    for ch in trimmed.chars() {
6522        if ch == '`' {
6523            if in_tick {
6524                if let Some(path) = sanitize_doc_path_candidate(&tick_buf) {
6525                    candidates.push(path);
6526                }
6527                tick_buf.clear();
6528            }
6529            in_tick = !in_tick;
6530            continue;
6531        }
6532        if in_tick {
6533            tick_buf.push(ch);
6534        }
6535    }
6536
6537    for raw in trimmed.split_whitespace() {
6538        if let Some(path) = sanitize_doc_path_candidate(raw) {
6539            candidates.push(path);
6540        }
6541    }
6542
6543    let mut seen = HashSet::new();
6544    candidates
6545        .into_iter()
6546        .find(|candidate| seen.insert(candidate.clone()))
6547}
6548
6549fn tool_name_requires_task_arg(tool_name: &str) -> bool {
6550    let normalized = normalize_tool_name(tool_name);
6551    normalized == "answer_how_to" || normalized.ends_with(".answer_how_to")
6552}
6553
6554fn tool_name_requires_query_arg(tool_name: &str) -> bool {
6555    let normalized = normalize_tool_name(tool_name);
6556    normalized == "search_docs" || normalized.ends_with(".search_docs")
6557}
6558
6559fn tool_name_requires_doc_path_arg(tool_name: &str) -> bool {
6560    let normalized = normalize_tool_name(tool_name);
6561    normalized == "get_doc" || normalized.ends_with(".get_doc")
6562}
6563
6564fn sanitize_url_candidate(raw: &str) -> Option<String> {
6565    let token = raw
6566        .trim()
6567        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6568        .trim_start_matches(['(', '[', '{', '<'])
6569        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6570        .trim_end_matches('.')
6571        .trim();
6572
6573    if token.is_empty() {
6574        return None;
6575    }
6576    let lower = token.to_ascii_lowercase();
6577    if !(lower.starts_with("http://") || lower.starts_with("https://")) {
6578        return None;
6579    }
6580    Some(token.to_string())
6581}
6582
6583fn sanitize_doc_path_candidate(raw: &str) -> Option<String> {
6584    let token = raw
6585        .trim()
6586        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6587        .trim_start_matches(['(', '[', '{', '<'])
6588        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6589        .trim_end_matches('.')
6590        .trim();
6591
6592    if token.is_empty() {
6593        return None;
6594    }
6595
6596    if let Some(url) = sanitize_url_candidate(token) {
6597        return Some(url);
6598    }
6599
6600    let lower = token.to_ascii_lowercase();
6601    if token.starts_with('/')
6602        || token.starts_with("./")
6603        || token.starts_with("../")
6604        || lower.starts_with("start-here")
6605        || lower.starts_with("sdk/")
6606        || lower.starts_with("desktop/")
6607        || lower.starts_with("control-panel/")
6608        || lower.starts_with("reference/")
6609    {
6610        return Some(token.to_string());
6611    }
6612
6613    None
6614}
6615
6616fn clean_path_candidate_token(raw: &str) -> Option<String> {
6617    let token = raw.trim();
6618    let token = token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'));
6619    let token = token.trim_start_matches(['(', '[', '{', '<']);
6620    let token = token.trim_end_matches([',', ';', ':', ')', ']', '}', '>']);
6621    let token = token.trim_end_matches('.').trim();
6622
6623    if token.is_empty() {
6624        return None;
6625    }
6626    Some(token.to_string())
6627}
6628
6629fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
6630    let token = clean_path_candidate_token(raw)?;
6631    let lower = token.to_ascii_lowercase();
6632    if lower.starts_with("http://") || lower.starts_with("https://") {
6633        return None;
6634    }
6635    if is_malformed_tool_path_token(&token) {
6636        return None;
6637    }
6638    if is_root_only_path_token(&token) {
6639        return None;
6640    }
6641    if is_placeholder_path_token(&token) {
6642        return None;
6643    }
6644    if token.ends_with('/') || token.ends_with('\\') {
6645        return None;
6646    }
6647    Some(token.to_string())
6648}
6649
6650fn sanitize_path_candidate(raw: &str) -> Option<String> {
6651    let token = clean_path_candidate_token(raw)?;
6652    let lower = token.to_ascii_lowercase();
6653    if lower.starts_with("http://") || lower.starts_with("https://") {
6654        return None;
6655    }
6656    if is_malformed_tool_path_token(token.as_str()) {
6657        return None;
6658    }
6659    if is_root_only_path_token(token.as_str()) {
6660        return None;
6661    }
6662    if is_placeholder_path_token(token.as_str()) {
6663        return None;
6664    }
6665    if token.ends_with('/') || token.ends_with('\\') {
6666        return None;
6667    }
6668
6669    let looks_like_path = token.contains('/') || token.contains('\\');
6670    let has_file_ext = [
6671        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
6672        ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
6673        ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
6674    ]
6675    .iter()
6676    .any(|ext| lower.ends_with(ext));
6677
6678    if !looks_like_path && !has_file_ext {
6679        return None;
6680    }
6681
6682    Some(token)
6683}
6684
6685fn is_placeholder_path_token(token: &str) -> bool {
6686    let lowered = token.trim().to_ascii_lowercase();
6687    if lowered.is_empty() {
6688        return true;
6689    }
6690    matches!(
6691        lowered.as_str(),
6692        "files/directories"
6693            | "file/directory"
6694            | "relative/or/absolute/path"
6695            | "path/to/file"
6696            | "path/to/your/file"
6697            | "tool/policy"
6698            | "tools/policy"
6699            | "the expected artifact file"
6700            | "workspace/file"
6701    )
6702}
6703
6704fn is_malformed_tool_path_token(token: &str) -> bool {
6705    let lower = token.to_ascii_lowercase();
6706    // XML-ish tool-call wrappers emitted by some model responses.
6707    if lower.contains("<tool_call")
6708        || lower.contains("</tool_call")
6709        || lower.contains("<function=")
6710        || lower.contains("<parameter=")
6711        || lower.contains("</function>")
6712        || lower.contains("</parameter>")
6713    {
6714        return true;
6715    }
6716    // Multiline payloads are not valid single file paths.
6717    if token.contains('\n') || token.contains('\r') {
6718        return true;
6719    }
6720    // Glob patterns are not concrete file paths for read/write/edit.
6721    if token.contains('*') || token.contains('?') {
6722        return true;
6723    }
6724    // Context object IDs from runtime_context_partition bindings are not file paths.
6725    // These look like "ctx:wfplan-...:assess:assess.artifact" and the model sometimes
6726    // confuses them for filesystem paths.
6727    if lower.starts_with("ctx:") {
6728        return true;
6729    }
6730    // Colon-separated identifiers that look like context bindings (e.g. "routine:assess:artifact")
6731    // but aren't Windows drive paths (which are caught above).
6732    if token.matches(':').count() >= 2 {
6733        return true;
6734    }
6735    false
6736}
6737
6738fn is_root_only_path_token(token: &str) -> bool {
6739    let trimmed = token.trim();
6740    if trimmed.is_empty() {
6741        return true;
6742    }
6743    if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
6744        return true;
6745    }
6746    // Windows drive root placeholders, e.g. `C:` or `C:\`.
6747    let bytes = trimmed.as_bytes();
6748    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
6749        return true;
6750    }
6751    if bytes.len() == 3
6752        && bytes[1] == b':'
6753        && (bytes[0] as char).is_ascii_alphabetic()
6754        && (bytes[2] == b'\\' || bytes[2] == b'/')
6755    {
6756        return true;
6757    }
6758    false
6759}
6760
6761fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
6762    let token = raw
6763        .trim()
6764        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
6765        .trim();
6766    if token.is_empty() {
6767        return None;
6768    }
6769    Some(token.to_string())
6770}
6771
6772fn looks_like_shell_command(candidate: &str) -> bool {
6773    let lower = candidate.to_ascii_lowercase();
6774    if lower.is_empty() {
6775        return false;
6776    }
6777    let first = lower.split_whitespace().next().unwrap_or_default();
6778    let common = [
6779        "rg",
6780        "git",
6781        "cargo",
6782        "pnpm",
6783        "npm",
6784        "node",
6785        "python",
6786        "pytest",
6787        "pwsh",
6788        "powershell",
6789        "cmd",
6790        "dir",
6791        "ls",
6792        "cat",
6793        "type",
6794        "echo",
6795        "cd",
6796        "mkdir",
6797        "cp",
6798        "copy",
6799        "move",
6800        "del",
6801        "rm",
6802    ];
6803    common.contains(&first)
6804        || first.starts_with("get-")
6805        || first.starts_with("./")
6806        || first.starts_with(".\\")
6807        || lower.contains(" | ")
6808        || lower.contains(" && ")
6809        || lower.contains(" ; ")
6810}
6811
6812const FILE_PATH_KEYS: [&str; 10] = [
6813    "path",
6814    "file_path",
6815    "filePath",
6816    "filepath",
6817    "filename",
6818    "file",
6819    "target",
6820    "targetFile",
6821    "absolutePath",
6822    "uri",
6823];
6824
6825const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
6826
6827const WRITE_CONTENT_KEYS: [&str; 8] = [
6828    "content",
6829    "text",
6830    "body",
6831    "value",
6832    "markdown",
6833    "document",
6834    "output",
6835    "file_content",
6836];
6837
6838const NESTED_ARGS_KEYS: [&str; 10] = [
6839    "arguments",
6840    "args",
6841    "input",
6842    "params",
6843    "payload",
6844    "data",
6845    "tool_input",
6846    "toolInput",
6847    "tool_args",
6848    "toolArgs",
6849];
6850
6851fn tool_signature(tool_name: &str, args: &Value) -> String {
6852    let normalized = normalize_tool_name(tool_name);
6853    if normalized == "websearch" {
6854        let query = extract_websearch_query(args)
6855            .unwrap_or_default()
6856            .to_lowercase();
6857        let limit = args
6858            .get("limit")
6859            .or_else(|| args.get("numResults"))
6860            .or_else(|| args.get("num_results"))
6861            .and_then(|v| v.as_u64())
6862            .unwrap_or(8);
6863        let domains = args
6864            .get("domains")
6865            .or_else(|| args.get("domain"))
6866            .map(|v| v.to_string())
6867            .unwrap_or_default();
6868        let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
6869        return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
6870    }
6871    format!("{}:{}", normalized, args)
6872}
6873
6874fn stable_hash(input: &str) -> String {
6875    let mut hasher = DefaultHasher::new();
6876    input.hash(&mut hasher);
6877    format!("{:016x}", hasher.finish())
6878}
6879
6880fn summarize_tool_outputs(outputs: &[String]) -> String {
6881    outputs
6882        .iter()
6883        .take(6)
6884        .map(|output| truncate_text(output, 600))
6885        .collect::<Vec<_>>()
6886        .join("\n\n")
6887}
6888
6889fn summarize_user_visible_tool_outputs(outputs: &[String]) -> String {
6890    let filtered = outputs
6891        .iter()
6892        .filter(|output| !should_hide_tool_output_from_user_fallback(output))
6893        .take(3)
6894        .map(|output| truncate_text(output, 240))
6895        .collect::<Vec<_>>();
6896    filtered.join("\n")
6897}
6898
6899fn should_hide_tool_output_from_user_fallback(output: &str) -> bool {
6900    let trimmed = output.trim();
6901    if trimmed.is_empty() {
6902        return true;
6903    }
6904    let lower = trimmed.to_ascii_lowercase();
6905    if lower.contains("call skipped")
6906        || lower.contains("it is not available in this turn")
6907        || is_terminal_tool_error_reason(trimmed)
6908    {
6909        return true;
6910    }
6911    extract_tool_result_body(trimmed).is_some_and(is_non_productive_tool_result_body)
6912}
6913
6914fn summarize_terminal_tool_failure_for_user(outputs: &[String]) -> Option<String> {
6915    let reasons = outputs
6916        .iter()
6917        .filter_map(|output| terminal_tool_error_reason(output))
6918        .collect::<Vec<_>>();
6919    if reasons.is_empty() {
6920        return None;
6921    }
6922    if reasons.iter().any(|reason| *reason == "DOC_PATH_MISSING") {
6923        return Some(
6924            "I couldn't tell which Tandem docs page to open. Please include a docs URL like `https://docs.tandem.ac/start-here/` or a docs path like `/start-here/` and try again."
6925                .to_string(),
6926        );
6927    }
6928    if reasons
6929        .iter()
6930        .any(|reason| *reason == "QUERY_MISSING" || *reason == "WEBSEARCH_QUERY_MISSING")
6931    {
6932        return Some(
6933            "I need a concrete search query or target URL to continue. Please include the exact thing you want searched and try again."
6934                .to_string(),
6935        );
6936    }
6937    if reasons.iter().any(|reason| *reason == "TASK_MISSING") {
6938        return Some(
6939            "I need the actual docs/help question in the prompt before I can answer it. Please resend the request with the question you want answered."
6940                .to_string(),
6941        );
6942    }
6943    None
6944}
6945
6946fn terminal_tool_error_reason(output: &str) -> Option<&str> {
6947    let trimmed = output.trim();
6948    if trimmed.is_empty() {
6949        return None;
6950    }
6951    let first_line = trimmed.lines().next().unwrap_or_default().trim();
6952    if first_line.is_empty() {
6953        return None;
6954    }
6955    let normalized = first_line.to_ascii_uppercase();
6956    if is_terminal_tool_error_reason(&normalized) {
6957        Some(first_line)
6958    } else {
6959        None
6960    }
6961}
6962
6963fn is_os_mismatch_tool_output(output: &str) -> bool {
6964    let lower = output.to_ascii_lowercase();
6965    lower.contains("os error 3")
6966        || lower.contains("system cannot find the path specified")
6967        || lower.contains("command not found")
6968        || lower.contains("is not recognized as an internal or external command")
6969        || lower.contains("shell command blocked on windows")
6970}
6971
6972fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
6973    match requested {
6974        ContextMode::Full => "full",
6975        ContextMode::Compact => "compact",
6976        ContextMode::Auto => {
6977            if auto_compact {
6978                "auto_compact"
6979            } else {
6980                "auto_standard"
6981            }
6982        }
6983    }
6984}
6985
6986fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
6987    let mut sections = Vec::new();
6988    if os_aware_prompts_enabled() {
6989        sections.push(format!(
6990            "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
6991            host_os_label(host.os),
6992            shell_family_label(host.shell_family),
6993            path_style_label(host.path_style),
6994            host.arch
6995        ));
6996    }
6997    sections.push(
6998        "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
6999Use tool calls to inspect and modify the workspace when needed instead of asking the user
7000to manually run basic discovery steps. Permission prompts may occur for some tools; if
7001a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
7002            .to_string(),
7003    );
7004    sections.push(
7005        "For greetings or simple conversational messages (for example: hi, hello, thanks),
7006respond directly without calling tools."
7007            .to_string(),
7008    );
7009    if host.os == HostOs::Windows {
7010        sections.push(
7011            "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
7012Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
7013If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
7014                .to_string(),
7015        );
7016    } else {
7017        sections.push(
7018            "POSIX guidance: standard shell commands are available.
7019Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
7020                .to_string(),
7021        );
7022    }
7023    if !mcp_server_names.is_empty() {
7024        let cap = mcp_catalog_max_servers();
7025        let mut listed = mcp_server_names
7026            .iter()
7027            .take(cap)
7028            .cloned()
7029            .collect::<Vec<_>>();
7030        listed.sort();
7031        let mut catalog = listed
7032            .iter()
7033            .map(|name| format!("- {name}"))
7034            .collect::<Vec<_>>();
7035        if mcp_server_names.len() > cap {
7036            catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
7037        }
7038        sections.push(format!(
7039            "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
7040            catalog.join("\n")
7041        ));
7042    }
7043    sections.join("\n\n")
7044}
7045
7046fn os_aware_prompts_enabled() -> bool {
7047    std::env::var("TANDEM_OS_AWARE_PROMPTS")
7048        .ok()
7049        .map(|v| {
7050            let normalized = v.trim().to_ascii_lowercase();
7051            !(normalized == "0" || normalized == "false" || normalized == "off")
7052        })
7053        .unwrap_or(true)
7054}
7055
7056fn semantic_tool_retrieval_enabled() -> bool {
7057    std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
7058        .ok()
7059        .map(|raw| {
7060            !matches!(
7061                raw.trim().to_ascii_lowercase().as_str(),
7062                "0" | "false" | "off" | "no"
7063            )
7064        })
7065        .unwrap_or(true)
7066}
7067
7068fn semantic_tool_retrieval_k() -> usize {
7069    std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
7070        .ok()
7071        .and_then(|raw| raw.trim().parse::<usize>().ok())
7072        .filter(|value| *value > 0)
7073        .unwrap_or_else(max_tools_per_call_expanded)
7074}
7075
7076fn mcp_catalog_in_system_prompt_enabled() -> bool {
7077    std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
7078        .ok()
7079        .map(|raw| {
7080            !matches!(
7081                raw.trim().to_ascii_lowercase().as_str(),
7082                "0" | "false" | "off" | "no"
7083            )
7084        })
7085        .unwrap_or(true)
7086}
7087
7088fn mcp_catalog_max_servers() -> usize {
7089    std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
7090        .ok()
7091        .and_then(|raw| raw.trim().parse::<usize>().ok())
7092        .filter(|value| *value > 0)
7093        .unwrap_or(20)
7094}
7095
7096fn host_os_label(os: HostOs) -> &'static str {
7097    match os {
7098        HostOs::Windows => "windows",
7099        HostOs::Linux => "linux",
7100        HostOs::Macos => "macos",
7101    }
7102}
7103
7104fn shell_family_label(shell: ShellFamily) -> &'static str {
7105    match shell {
7106        ShellFamily::Powershell => "powershell",
7107        ShellFamily::Posix => "posix",
7108    }
7109}
7110
7111fn path_style_label(path_style: PathStyle) -> &'static str {
7112    match path_style {
7113        PathStyle::Windows => "windows",
7114        PathStyle::Posix => "posix",
7115    }
7116}
7117
7118fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
7119    let user = user_text.to_lowercase();
7120    let reply = completion.to_lowercase();
7121
7122    let asked_for_project_context = [
7123        "what is this project",
7124        "what's this project",
7125        "what project is this",
7126        "explain this project",
7127        "analyze this project",
7128        "inspect this project",
7129        "look at the project",
7130        "summarize this project",
7131        "show me this project",
7132        "what files are in",
7133        "show files",
7134        "list files",
7135        "read files",
7136        "browse files",
7137        "use glob",
7138        "run glob",
7139    ]
7140    .iter()
7141    .any(|needle| user.contains(needle));
7142
7143    if !asked_for_project_context {
7144        return false;
7145    }
7146
7147    let assistant_claimed_no_access = [
7148        "can't inspect",
7149        "cannot inspect",
7150        "unable to inspect",
7151        "unable to directly inspect",
7152        "can't access",
7153        "cannot access",
7154        "unable to access",
7155        "can't read files",
7156        "cannot read files",
7157        "unable to read files",
7158        "tool restriction",
7159        "tool restrictions",
7160        "don't have visibility",
7161        "no visibility",
7162        "haven't been able to inspect",
7163        "i don't know what this project is",
7164        "need your help to",
7165        "sandbox",
7166        "restriction",
7167        "system restriction",
7168        "permissions restrictions",
7169    ]
7170    .iter()
7171    .any(|needle| reply.contains(needle));
7172
7173    // If the user is explicitly asking for project inspection and the model replies with
7174    // a no-access narrative instead of making a tool call, force a minimal read-only probe.
7175    asked_for_project_context && assistant_claimed_no_access
7176}
7177
7178fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
7179    let raw = input.trim();
7180    if !raw.starts_with("/tool ") {
7181        return None;
7182    }
7183    let rest = raw.trim_start_matches("/tool ").trim();
7184    let mut split = rest.splitn(2, ' ');
7185    let tool = normalize_tool_name(split.next()?.trim());
7186    let args = split
7187        .next()
7188        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
7189        .unwrap_or_else(|| json!({}));
7190    Some((tool, args))
7191}
7192
7193fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
7194    let trimmed = input.trim();
7195    if trimmed.is_empty() {
7196        return Vec::new();
7197    }
7198
7199    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
7200        if let Some(found) = extract_tool_call_from_value(&parsed) {
7201            return vec![found];
7202        }
7203    }
7204
7205    if let Some(block) = extract_first_json_object(trimmed) {
7206        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
7207            if let Some(found) = extract_tool_call_from_value(&parsed) {
7208                return vec![found];
7209            }
7210        }
7211    }
7212
7213    parse_function_style_tool_calls(trimmed)
7214}
7215
7216#[cfg(test)]
7217fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
7218    parse_tool_invocations_from_response(input)
7219        .into_iter()
7220        .next()
7221}
7222
7223fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
7224    let mut calls = Vec::new();
7225    let lower = input.to_lowercase();
7226    let names = [
7227        "todo_write",
7228        "todowrite",
7229        "update_todo_list",
7230        "update_todos",
7231    ];
7232    let mut cursor = 0usize;
7233
7234    while cursor < lower.len() {
7235        let mut best: Option<(usize, &str)> = None;
7236        for name in names {
7237            let needle = format!("{name}(");
7238            if let Some(rel_idx) = lower[cursor..].find(&needle) {
7239                let idx = cursor + rel_idx;
7240                if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
7241                    best = Some((idx, name));
7242                }
7243            }
7244        }
7245
7246        let Some((tool_start, tool_name)) = best else {
7247            break;
7248        };
7249
7250        let open_paren = tool_start + tool_name.len();
7251        if let Some(close_paren) = find_matching_paren(input, open_paren) {
7252            if let Some(args_text) = input.get(open_paren + 1..close_paren) {
7253                let args = parse_function_style_args(args_text.trim());
7254                calls.push((normalize_tool_name(tool_name), Value::Object(args)));
7255            }
7256            cursor = close_paren.saturating_add(1);
7257        } else {
7258            cursor = tool_start.saturating_add(tool_name.len());
7259        }
7260    }
7261
7262    calls
7263}
7264
7265fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
7266    if input.as_bytes().get(open_paren).copied()? != b'(' {
7267        return None;
7268    }
7269
7270    let mut depth = 0usize;
7271    let mut in_single = false;
7272    let mut in_double = false;
7273    let mut escaped = false;
7274
7275    for (offset, ch) in input.get(open_paren..)?.char_indices() {
7276        if escaped {
7277            escaped = false;
7278            continue;
7279        }
7280        if ch == '\\' && (in_single || in_double) {
7281            escaped = true;
7282            continue;
7283        }
7284        if ch == '\'' && !in_double {
7285            in_single = !in_single;
7286            continue;
7287        }
7288        if ch == '"' && !in_single {
7289            in_double = !in_double;
7290            continue;
7291        }
7292        if in_single || in_double {
7293            continue;
7294        }
7295
7296        match ch {
7297            '(' => depth += 1,
7298            ')' => {
7299                depth = depth.saturating_sub(1);
7300                if depth == 0 {
7301                    return Some(open_paren + offset);
7302                }
7303            }
7304            _ => {}
7305        }
7306    }
7307
7308    None
7309}
7310
7311fn parse_function_style_args(input: &str) -> Map<String, Value> {
7312    let mut args = Map::new();
7313    if input.trim().is_empty() {
7314        return args;
7315    }
7316
7317    let mut parts = Vec::<String>::new();
7318    let mut current = String::new();
7319    let mut in_single = false;
7320    let mut in_double = false;
7321    let mut escaped = false;
7322    let mut depth_paren = 0usize;
7323    let mut depth_bracket = 0usize;
7324    let mut depth_brace = 0usize;
7325
7326    for ch in input.chars() {
7327        if escaped {
7328            current.push(ch);
7329            escaped = false;
7330            continue;
7331        }
7332        if ch == '\\' && (in_single || in_double) {
7333            current.push(ch);
7334            escaped = true;
7335            continue;
7336        }
7337        if ch == '\'' && !in_double {
7338            in_single = !in_single;
7339            current.push(ch);
7340            continue;
7341        }
7342        if ch == '"' && !in_single {
7343            in_double = !in_double;
7344            current.push(ch);
7345            continue;
7346        }
7347        if in_single || in_double {
7348            current.push(ch);
7349            continue;
7350        }
7351
7352        match ch {
7353            '(' => depth_paren += 1,
7354            ')' => depth_paren = depth_paren.saturating_sub(1),
7355            '[' => depth_bracket += 1,
7356            ']' => depth_bracket = depth_bracket.saturating_sub(1),
7357            '{' => depth_brace += 1,
7358            '}' => depth_brace = depth_brace.saturating_sub(1),
7359            ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
7360                let part = current.trim();
7361                if !part.is_empty() {
7362                    parts.push(part.to_string());
7363                }
7364                current.clear();
7365                continue;
7366            }
7367            _ => {}
7368        }
7369        current.push(ch);
7370    }
7371    let tail = current.trim();
7372    if !tail.is_empty() {
7373        parts.push(tail.to_string());
7374    }
7375
7376    for part in parts {
7377        let Some((raw_key, raw_value)) = part
7378            .split_once('=')
7379            .or_else(|| part.split_once(':'))
7380            .map(|(k, v)| (k.trim(), v.trim()))
7381        else {
7382            continue;
7383        };
7384        let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
7385        if key.is_empty() {
7386            continue;
7387        }
7388        if !is_valid_function_style_key(key) {
7389            continue;
7390        }
7391        let value = parse_scalar_like_value(raw_value);
7392        args.insert(key.to_string(), value);
7393    }
7394
7395    args
7396}
7397
7398fn is_valid_function_style_key(key: &str) -> bool {
7399    // Accept common tool-style identifiers such as `path`, `allow_empty`,
7400    // `search.query`, and `arg-name`, but reject malformed fragments that
7401    // commonly come from broken JSON streams (e.g. `{"allow_empty`).
7402    let mut chars = key.chars();
7403    let Some(first) = chars.next() else {
7404        return false;
7405    };
7406    if !(first.is_ascii_alphanumeric() || first == '_') {
7407        return false;
7408    }
7409    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
7410}
7411
7412fn parse_scalar_like_value(raw: &str) -> Value {
7413    let trimmed = raw.trim();
7414    if trimmed.is_empty() {
7415        return Value::Null;
7416    }
7417
7418    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
7419        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
7420    {
7421        if trimmed.len() < 2 {
7422            return Value::String(trimmed.to_string());
7423        }
7424        return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
7425    }
7426
7427    if trimmed.eq_ignore_ascii_case("true") {
7428        return Value::Bool(true);
7429    }
7430    if trimmed.eq_ignore_ascii_case("false") {
7431        return Value::Bool(false);
7432    }
7433    if trimmed.eq_ignore_ascii_case("null") {
7434        return Value::Null;
7435    }
7436
7437    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
7438        return v;
7439    }
7440    if let Ok(v) = trimmed.parse::<i64>() {
7441        return Value::Number(Number::from(v));
7442    }
7443    if let Ok(v) = trimmed.parse::<f64>() {
7444        if let Some(n) = Number::from_f64(v) {
7445            return Value::Number(n);
7446        }
7447    }
7448
7449    Value::String(trimmed.to_string())
7450}
7451
7452fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
7453    let content = extract_loose_json_string_field(raw, "content")?;
7454    let mut obj = Map::new();
7455    if let Some(path) = extract_loose_json_string_field(raw, "path") {
7456        obj.insert("path".to_string(), Value::String(path));
7457    }
7458    obj.insert("content".to_string(), Value::String(content));
7459    Some(Value::Object(obj))
7460}
7461
7462fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
7463    let pattern = format!("\"{key}\"");
7464    let start = input.find(&pattern)?;
7465    let remainder = input.get(start + pattern.len()..)?;
7466    let colon = remainder.find(':')?;
7467    let value = remainder.get(colon + 1..)?.trim_start();
7468    let value = value.strip_prefix('"')?;
7469    Some(parse_loose_json_string_value(value))
7470}
7471
7472fn parse_loose_json_string_value(input: &str) -> String {
7473    let mut out = String::new();
7474    let mut chars = input.chars().peekable();
7475    let mut closed = false;
7476
7477    while let Some(ch) = chars.next() {
7478        if ch == '"' {
7479            closed = true;
7480            break;
7481        }
7482        if ch != '\\' {
7483            out.push(ch);
7484            continue;
7485        }
7486
7487        let Some(escaped) = chars.next() else {
7488            out.push('\\');
7489            break;
7490        };
7491        match escaped {
7492            '"' => out.push('"'),
7493            '\\' => out.push('\\'),
7494            '/' => out.push('/'),
7495            'b' => out.push('\u{0008}'),
7496            'f' => out.push('\u{000C}'),
7497            'n' => out.push('\n'),
7498            'r' => out.push('\r'),
7499            't' => out.push('\t'),
7500            'u' => {
7501                let mut hex = String::new();
7502                for _ in 0..4 {
7503                    let Some(next) = chars.next() else {
7504                        break;
7505                    };
7506                    hex.push(next);
7507                }
7508                if hex.len() == 4 {
7509                    if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
7510                        if let Some(decoded) = char::from_u32(codepoint as u32) {
7511                            out.push(decoded);
7512                            continue;
7513                        }
7514                    }
7515                }
7516                out.push('\\');
7517                out.push('u');
7518                out.push_str(&hex);
7519            }
7520            other => {
7521                out.push('\\');
7522                out.push(other);
7523            }
7524        }
7525    }
7526
7527    if !closed {
7528        return out;
7529    }
7530    out
7531}
7532
7533fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
7534    if is_todo_status_update_args(&args) {
7535        return args;
7536    }
7537
7538    let mut obj = match args {
7539        Value::Object(map) => map,
7540        Value::Array(items) => {
7541            return json!({ "todos": normalize_todo_arg_items(items) });
7542        }
7543        Value::String(text) => {
7544            let derived = extract_todo_candidates_from_text(&text);
7545            if !derived.is_empty() {
7546                return json!({ "todos": derived });
7547            }
7548            return json!({});
7549        }
7550        _ => return json!({}),
7551    };
7552
7553    if obj
7554        .get("todos")
7555        .and_then(|v| v.as_array())
7556        .map(|arr| !arr.is_empty())
7557        .unwrap_or(false)
7558    {
7559        return Value::Object(obj);
7560    }
7561
7562    for alias in ["tasks", "items", "list", "checklist"] {
7563        if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
7564            let normalized = normalize_todo_arg_items(items.clone());
7565            if !normalized.is_empty() {
7566                obj.insert("todos".to_string(), Value::Array(normalized));
7567                return Value::Object(obj);
7568            }
7569        }
7570    }
7571
7572    let derived = extract_todo_candidates_from_text(completion);
7573    if !derived.is_empty() {
7574        obj.insert("todos".to_string(), Value::Array(derived));
7575    }
7576    Value::Object(obj)
7577}
7578
7579fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
7580    items
7581        .into_iter()
7582        .filter_map(|item| match item {
7583            Value::String(text) => {
7584                let content = text.trim();
7585                if content.is_empty() {
7586                    None
7587                } else {
7588                    Some(json!({"content": content}))
7589                }
7590            }
7591            Value::Object(mut obj) => {
7592                if !obj.contains_key("content") {
7593                    if let Some(text) = obj.get("text").cloned() {
7594                        obj.insert("content".to_string(), text);
7595                    } else if let Some(title) = obj.get("title").cloned() {
7596                        obj.insert("content".to_string(), title);
7597                    } else if let Some(name) = obj.get("name").cloned() {
7598                        obj.insert("content".to_string(), name);
7599                    }
7600                }
7601                let content = obj
7602                    .get("content")
7603                    .and_then(|v| v.as_str())
7604                    .map(str::trim)
7605                    .unwrap_or("");
7606                if content.is_empty() {
7607                    None
7608                } else {
7609                    Some(Value::Object(obj))
7610                }
7611            }
7612            _ => None,
7613        })
7614        .collect()
7615}
7616
7617fn is_todo_status_update_args(args: &Value) -> bool {
7618    let Some(obj) = args.as_object() else {
7619        return false;
7620    };
7621    let has_status = obj
7622        .get("status")
7623        .and_then(|v| v.as_str())
7624        .map(|s| !s.trim().is_empty())
7625        .unwrap_or(false);
7626    let has_target =
7627        obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
7628    has_status && has_target
7629}
7630
7631fn is_empty_todo_write_args(args: &Value) -> bool {
7632    if is_todo_status_update_args(args) {
7633        return false;
7634    }
7635    let Some(obj) = args.as_object() else {
7636        return true;
7637    };
7638    !obj.get("todos")
7639        .and_then(|v| v.as_array())
7640        .map(|arr| !arr.is_empty())
7641        .unwrap_or(false)
7642}
7643
7644fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
7645    let trimmed = raw_args.trim();
7646    if trimmed.is_empty() {
7647        return json!({});
7648    }
7649
7650    let normalized_tool = normalize_tool_name(tool_name);
7651    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
7652        return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
7653    }
7654
7655    if normalized_tool == "write" {
7656        if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
7657            return recovered;
7658        }
7659    }
7660
7661    // Some providers emit non-JSON argument text (for example: raw query strings
7662    // or key=value fragments). Recover the common forms instead of dropping to {}.
7663    let kv_args = parse_function_style_args(trimmed);
7664    if !kv_args.is_empty() {
7665        return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
7666    }
7667
7668    if normalized_tool == "websearch" {
7669        if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
7670            return json!({ "query": query });
7671        }
7672        return json!({});
7673    }
7674
7675    Value::String(trimmed.to_string())
7676}
7677
7678fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
7679    let normalized_tool = normalize_tool_name(tool_name);
7680    if normalized_tool != "websearch" {
7681        return parsed;
7682    }
7683
7684    match parsed {
7685        Value::Object(mut obj) => {
7686            if !has_websearch_query(&obj) && !raw.trim().is_empty() {
7687                if let Some(query) = sanitize_websearch_query_candidate(raw) {
7688                    obj.insert("query".to_string(), Value::String(query));
7689                }
7690            }
7691            Value::Object(obj)
7692        }
7693        Value::String(s) => match sanitize_websearch_query_candidate(&s) {
7694            Some(query) => json!({ "query": query }),
7695            None => json!({}),
7696        },
7697        other => other,
7698    }
7699}
7700
7701fn has_websearch_query(obj: &Map<String, Value>) -> bool {
7702    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
7703    QUERY_KEYS.iter().any(|key| {
7704        obj.get(*key)
7705            .and_then(|v| v.as_str())
7706            .map(|s| !s.trim().is_empty())
7707            .unwrap_or(false)
7708    })
7709}
7710
7711fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
7712    if let Some(obj) = value.as_object() {
7713        if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
7714            return Some((
7715                normalize_tool_name(tool),
7716                obj.get("args").cloned().unwrap_or_else(|| json!({})),
7717            ));
7718        }
7719
7720        if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
7721            let args = obj
7722                .get("args")
7723                .cloned()
7724                .or_else(|| obj.get("arguments").cloned())
7725                .unwrap_or_else(|| json!({}));
7726            let normalized_tool = normalize_tool_name(tool);
7727            let args = if let Some(raw) = args.as_str() {
7728                parse_streamed_tool_args(&normalized_tool, raw)
7729            } else {
7730                args
7731            };
7732            return Some((normalized_tool, args));
7733        }
7734
7735        for key in [
7736            "tool_call",
7737            "toolCall",
7738            "call",
7739            "function_call",
7740            "functionCall",
7741        ] {
7742            if let Some(nested) = obj.get(key) {
7743                if let Some(found) = extract_tool_call_from_value(nested) {
7744                    return Some(found);
7745                }
7746            }
7747        }
7748
7749        if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
7750            for call in calls {
7751                if let Some(found) = extract_tool_call_from_value(call) {
7752                    return Some(found);
7753                }
7754            }
7755        }
7756    }
7757
7758    if let Some(items) = value.as_array() {
7759        for item in items {
7760            if let Some(found) = extract_tool_call_from_value(item) {
7761                return Some(found);
7762            }
7763        }
7764    }
7765
7766    None
7767}
7768
7769fn extract_first_json_object(input: &str) -> Option<String> {
7770    let mut start = None;
7771    let mut depth = 0usize;
7772    for (idx, ch) in input.char_indices() {
7773        if ch == '{' {
7774            if start.is_none() {
7775                start = Some(idx);
7776            }
7777            depth += 1;
7778        } else if ch == '}' {
7779            if depth == 0 {
7780                continue;
7781            }
7782            depth -= 1;
7783            if depth == 0 {
7784                let begin = start?;
7785                let block = input.get(begin..=idx)?;
7786                return Some(block.to_string());
7787            }
7788        }
7789    }
7790    None
7791}
7792
7793fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
7794    let mut seen = HashSet::<String>::new();
7795    let mut todos = Vec::new();
7796
7797    for raw_line in input.lines() {
7798        let mut line = raw_line.trim();
7799        let mut structured_line = false;
7800        if line.is_empty() {
7801            continue;
7802        }
7803        if line.starts_with("```") {
7804            continue;
7805        }
7806        if line.ends_with(':') {
7807            continue;
7808        }
7809        if let Some(rest) = line
7810            .strip_prefix("- [ ]")
7811            .or_else(|| line.strip_prefix("* [ ]"))
7812            .or_else(|| line.strip_prefix("- [x]"))
7813            .or_else(|| line.strip_prefix("* [x]"))
7814        {
7815            line = rest.trim();
7816            structured_line = true;
7817        } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
7818            line = rest.trim();
7819            structured_line = true;
7820        } else {
7821            let bytes = line.as_bytes();
7822            let mut i = 0usize;
7823            while i < bytes.len() && bytes[i].is_ascii_digit() {
7824                i += 1;
7825            }
7826            if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
7827                line = line[i + 1..].trim();
7828                structured_line = true;
7829            }
7830        }
7831        if !structured_line {
7832            continue;
7833        }
7834
7835        let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
7836        if content.len() < 5 || content.len() > 180 {
7837            continue;
7838        }
7839        let key = content.to_lowercase();
7840        if seen.contains(&key) {
7841            continue;
7842        }
7843        seen.insert(key);
7844        todos.push(json!({ "content": content }));
7845        if todos.len() >= 25 {
7846            break;
7847        }
7848    }
7849
7850    todos
7851}
7852
7853async fn emit_plan_todo_fallback(
7854    storage: std::sync::Arc<Storage>,
7855    bus: &EventBus,
7856    session_id: &str,
7857    message_id: &str,
7858    completion: &str,
7859) {
7860    let todos = extract_todo_candidates_from_text(completion);
7861    if todos.is_empty() {
7862        return;
7863    }
7864
7865    let invoke_part = WireMessagePart::tool_invocation(
7866        session_id,
7867        message_id,
7868        "todo_write",
7869        json!({"todos": todos.clone()}),
7870    );
7871    let call_id = invoke_part.id.clone();
7872    bus.publish(EngineEvent::new(
7873        "message.part.updated",
7874        json!({"part": invoke_part}),
7875    ));
7876
7877    if storage.set_todos(session_id, todos.clone()).await.is_err() {
7878        let mut failed_part = WireMessagePart::tool_result(
7879            session_id,
7880            message_id,
7881            "todo_write",
7882            Some(json!({"todos": todos.clone()})),
7883            json!(null),
7884        );
7885        failed_part.id = call_id;
7886        failed_part.state = Some("failed".to_string());
7887        failed_part.error = Some("failed to persist plan todos".to_string());
7888        bus.publish(EngineEvent::new(
7889            "message.part.updated",
7890            json!({"part": failed_part}),
7891        ));
7892        return;
7893    }
7894
7895    let normalized = storage.get_todos(session_id).await;
7896    let mut result_part = WireMessagePart::tool_result(
7897        session_id,
7898        message_id,
7899        "todo_write",
7900        Some(json!({"todos": todos.clone()})),
7901        json!({ "todos": normalized }),
7902    );
7903    result_part.id = call_id;
7904    bus.publish(EngineEvent::new(
7905        "message.part.updated",
7906        json!({"part": result_part}),
7907    ));
7908    bus.publish(EngineEvent::new(
7909        "todo.updated",
7910        json!({
7911            "sessionID": session_id,
7912            "todos": normalized
7913        }),
7914    ));
7915}
7916
7917async fn emit_plan_question_fallback(
7918    storage: std::sync::Arc<Storage>,
7919    bus: &EventBus,
7920    session_id: &str,
7921    message_id: &str,
7922    completion: &str,
7923) {
7924    let trimmed = completion.trim();
7925    if trimmed.is_empty() {
7926        return;
7927    }
7928
7929    let hints = extract_todo_candidates_from_text(trimmed)
7930        .into_iter()
7931        .take(6)
7932        .filter_map(|v| {
7933            v.get("content")
7934                .and_then(|c| c.as_str())
7935                .map(ToString::to_string)
7936        })
7937        .collect::<Vec<_>>();
7938
7939    let mut options = hints
7940        .iter()
7941        .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
7942        .collect::<Vec<_>>();
7943    if options.is_empty() {
7944        options = vec![
7945            json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
7946            json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
7947            json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
7948        ];
7949    }
7950
7951    let question_payload = vec![json!({
7952        "header":"Planning Input",
7953        "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
7954        "options": options,
7955        "multiple": true,
7956        "custom": true
7957    })];
7958
7959    let request = storage
7960        .add_question_request(session_id, message_id, question_payload.clone())
7961        .await
7962        .ok();
7963    bus.publish(EngineEvent::new(
7964        "question.asked",
7965        json!({
7966            "id": request
7967                .as_ref()
7968                .map(|req| req.id.clone())
7969                .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7970            "sessionID": session_id,
7971            "messageID": message_id,
7972            "questions": question_payload,
7973            "tool": request.and_then(|req| {
7974                req.tool.map(|tool| {
7975                    json!({
7976                        "callID": tool.call_id,
7977                        "messageID": tool.message_id
7978                    })
7979                })
7980            })
7981        }),
7982    ));
7983}
7984
7985#[derive(Debug, Clone, Copy)]
7986enum ChatHistoryProfile {
7987    Full,
7988    Standard,
7989    Compact,
7990}
7991
7992async fn load_chat_history(
7993    storage: std::sync::Arc<Storage>,
7994    session_id: &str,
7995    profile: ChatHistoryProfile,
7996) -> Vec<ChatMessage> {
7997    let Some(session) = storage.get_session(session_id).await else {
7998        return Vec::new();
7999    };
8000    let messages = session
8001        .messages
8002        .into_iter()
8003        .map(|m| {
8004            let role = format!("{:?}", m.role).to_lowercase();
8005            let content = m
8006                .parts
8007                .into_iter()
8008                .map(|part| match part {
8009                    MessagePart::Text { text } => text,
8010                    MessagePart::Reasoning { text } => text,
8011                    MessagePart::ToolInvocation {
8012                        tool,
8013                        args,
8014                        result,
8015                        error,
8016                    } => summarize_tool_invocation_for_history(
8017                        &tool,
8018                        &args,
8019                        result.as_ref(),
8020                        error.as_deref(),
8021                    ),
8022                })
8023                .collect::<Vec<_>>()
8024                .join("\n");
8025            ChatMessage {
8026                role,
8027                content,
8028                attachments: Vec::new(),
8029            }
8030        })
8031        .collect::<Vec<_>>();
8032    compact_chat_history(messages, profile)
8033}
8034
8035fn summarize_tool_invocation_for_history(
8036    tool: &str,
8037    args: &Value,
8038    result: Option<&Value>,
8039    error: Option<&str>,
8040) -> String {
8041    let mut segments = vec![format!("Tool {tool}")];
8042    if !args.is_null()
8043        && !args.as_object().is_some_and(|value| value.is_empty())
8044        && !args
8045            .as_str()
8046            .map(|value| value.trim().is_empty())
8047            .unwrap_or(false)
8048    {
8049        segments.push(format!("args={args}"));
8050    }
8051    if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
8052        segments.push(format!("error={error}"));
8053    }
8054    if let Some(result) = result.filter(|value| !value.is_null()) {
8055        segments.push(format!("result={result}"));
8056    }
8057    if segments.len() == 1 {
8058        segments.push("result={}".to_string());
8059    }
8060    segments.join(" ")
8061}
8062
8063fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
8064    if attachments.is_empty() {
8065        return;
8066    }
8067    if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
8068        message.attachments = attachments.to_vec();
8069    }
8070}
8071
8072async fn build_runtime_attachments(
8073    provider_id: &str,
8074    parts: &[MessagePartInput],
8075) -> Vec<ChatAttachment> {
8076    if !supports_image_attachments(provider_id) {
8077        return Vec::new();
8078    }
8079
8080    let mut attachments = Vec::new();
8081    for part in parts {
8082        let MessagePartInput::File { mime, url, .. } = part else {
8083            continue;
8084        };
8085        if !mime.to_ascii_lowercase().starts_with("image/") {
8086            continue;
8087        }
8088        if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
8089            attachments.push(ChatAttachment::ImageUrl { url: source_url });
8090        }
8091    }
8092
8093    attachments
8094}
8095
8096fn supports_image_attachments(provider_id: &str) -> bool {
8097    matches!(
8098        provider_id,
8099        "openai"
8100            | "openai-codex"
8101            | "openrouter"
8102            | "ollama"
8103            | "groq"
8104            | "mistral"
8105            | "together"
8106            | "azure"
8107            | "bedrock"
8108            | "vertex"
8109            | "copilot"
8110    )
8111}
8112
8113async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
8114    let trimmed = url.trim();
8115    if trimmed.is_empty() {
8116        return None;
8117    }
8118    if trimmed.starts_with("http://")
8119        || trimmed.starts_with("https://")
8120        || trimmed.starts_with("data:")
8121    {
8122        return Some(trimmed.to_string());
8123    }
8124
8125    let file_path = trimmed
8126        .strip_prefix("file://")
8127        .map(PathBuf::from)
8128        .unwrap_or_else(|| PathBuf::from(trimmed));
8129    if !file_path.exists() {
8130        return None;
8131    }
8132
8133    let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
8134        .ok()
8135        .and_then(|v| v.parse::<usize>().ok())
8136        .unwrap_or(20 * 1024 * 1024);
8137
8138    let bytes = match tokio::fs::read(&file_path).await {
8139        Ok(bytes) => bytes,
8140        Err(err) => {
8141            tracing::warn!(
8142                "failed reading local attachment '{}': {}",
8143                file_path.to_string_lossy(),
8144                err
8145            );
8146            return None;
8147        }
8148    };
8149    if bytes.len() > max_bytes {
8150        tracing::warn!(
8151            "local attachment '{}' exceeds max bytes ({} > {})",
8152            file_path.to_string_lossy(),
8153            bytes.len(),
8154            max_bytes
8155        );
8156        return None;
8157    }
8158
8159    use base64::Engine as _;
8160    let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
8161    Some(format!("data:{mime};base64,{b64}"))
8162}
8163
8164struct ToolSideEventContext<'a> {
8165    session_id: &'a str,
8166    message_id: &'a str,
8167    tool: &'a str,
8168    args: &'a serde_json::Value,
8169    metadata: &'a serde_json::Value,
8170    workspace_root: Option<&'a str>,
8171    effective_cwd: Option<&'a str>,
8172}
8173
8174async fn emit_tool_side_events(
8175    storage: std::sync::Arc<Storage>,
8176    bus: &EventBus,
8177    ctx: ToolSideEventContext<'_>,
8178) {
8179    let ToolSideEventContext {
8180        session_id,
8181        message_id,
8182        tool,
8183        args,
8184        metadata,
8185        workspace_root,
8186        effective_cwd,
8187    } = ctx;
8188    if tool == "todo_write" {
8189        let todos_from_metadata = metadata
8190            .get("todos")
8191            .and_then(|v| v.as_array())
8192            .cloned()
8193            .unwrap_or_default();
8194
8195        if !todos_from_metadata.is_empty() {
8196            let _ = storage.set_todos(session_id, todos_from_metadata).await;
8197        } else {
8198            let current = storage.get_todos(session_id).await;
8199            if let Some(updated) = apply_todo_updates_from_args(current, args) {
8200                let _ = storage.set_todos(session_id, updated).await;
8201            }
8202        }
8203
8204        let normalized = storage.get_todos(session_id).await;
8205        bus.publish(EngineEvent::new(
8206            "todo.updated",
8207            json!({
8208                "sessionID": session_id,
8209                "todos": normalized,
8210                "workspaceRoot": workspace_root,
8211                "effectiveCwd": effective_cwd
8212            }),
8213        ));
8214    }
8215    if tool == "question" {
8216        let questions = metadata
8217            .get("questions")
8218            .and_then(|v| v.as_array())
8219            .cloned()
8220            .unwrap_or_default();
8221        if questions.is_empty() {
8222            tracing::warn!(
8223                "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
8224                session_id,
8225                message_id
8226            );
8227        } else {
8228            let request = storage
8229                .add_question_request(session_id, message_id, questions.clone())
8230                .await
8231                .ok();
8232            bus.publish(EngineEvent::new(
8233                "question.asked",
8234                json!({
8235                    "id": request
8236                        .as_ref()
8237                        .map(|req| req.id.clone())
8238                        .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
8239                    "sessionID": session_id,
8240                    "messageID": message_id,
8241                    "questions": questions,
8242                    "tool": request.and_then(|req| {
8243                        req.tool.map(|tool| {
8244                            json!({
8245                                "callID": tool.call_id,
8246                                "messageID": tool.message_id
8247                            })
8248                        })
8249                    }),
8250                    "workspaceRoot": workspace_root,
8251                    "effectiveCwd": effective_cwd
8252                }),
8253            ));
8254        }
8255    }
8256    if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
8257        for event in events {
8258            let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
8259                continue;
8260            };
8261            if !event_type.starts_with("agent_team.") {
8262                continue;
8263            }
8264            let mut properties = event
8265                .get("properties")
8266                .and_then(|v| v.as_object())
8267                .cloned()
8268                .unwrap_or_default();
8269            properties
8270                .entry("sessionID".to_string())
8271                .or_insert(json!(session_id));
8272            properties
8273                .entry("messageID".to_string())
8274                .or_insert(json!(message_id));
8275            properties
8276                .entry("workspaceRoot".to_string())
8277                .or_insert(json!(workspace_root));
8278            properties
8279                .entry("effectiveCwd".to_string())
8280                .or_insert(json!(effective_cwd));
8281            bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
8282        }
8283    }
8284}
8285
8286fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
8287    let obj = args.as_object()?;
8288    let mut todos = current;
8289    let mut changed = false;
8290
8291    if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
8292        for item in items {
8293            let Some(item_obj) = item.as_object() else {
8294                continue;
8295            };
8296            let status = item_obj
8297                .get("status")
8298                .and_then(|v| v.as_str())
8299                .map(normalize_todo_status);
8300            let target = item_obj
8301                .get("task_id")
8302                .or_else(|| item_obj.get("todo_id"))
8303                .or_else(|| item_obj.get("id"));
8304
8305            if let (Some(status), Some(target)) = (status, target) {
8306                changed |= apply_single_todo_status_update(&mut todos, target, &status);
8307            }
8308        }
8309    }
8310
8311    let status = obj
8312        .get("status")
8313        .and_then(|v| v.as_str())
8314        .map(normalize_todo_status);
8315    let target = obj
8316        .get("task_id")
8317        .or_else(|| obj.get("todo_id"))
8318        .or_else(|| obj.get("id"));
8319    if let (Some(status), Some(target)) = (status, target) {
8320        changed |= apply_single_todo_status_update(&mut todos, target, &status);
8321    }
8322
8323    if changed {
8324        Some(todos)
8325    } else {
8326        None
8327    }
8328}
8329
8330fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
8331    let idx_from_value = match target {
8332        Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
8333        Value::String(s) => {
8334            let trimmed = s.trim();
8335            trimmed
8336                .parse::<usize>()
8337                .ok()
8338                .map(|v| v.saturating_sub(1))
8339                .or_else(|| {
8340                    let digits = trimmed
8341                        .chars()
8342                        .rev()
8343                        .take_while(|c| c.is_ascii_digit())
8344                        .collect::<String>()
8345                        .chars()
8346                        .rev()
8347                        .collect::<String>();
8348                    digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
8349                })
8350        }
8351        _ => None,
8352    };
8353
8354    if let Some(idx) = idx_from_value {
8355        if idx < todos.len() {
8356            if let Some(obj) = todos[idx].as_object_mut() {
8357                obj.insert("status".to_string(), Value::String(status.to_string()));
8358                return true;
8359            }
8360        }
8361    }
8362
8363    let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
8364    if let Some(id_target) = id_target {
8365        for todo in todos.iter_mut() {
8366            if let Some(obj) = todo.as_object_mut() {
8367                if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
8368                    obj.insert("status".to_string(), Value::String(status.to_string()));
8369                    return true;
8370                }
8371            }
8372        }
8373    }
8374
8375    false
8376}
8377
8378fn normalize_todo_status(raw: &str) -> String {
8379    match raw.trim().to_lowercase().as_str() {
8380        "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
8381        "done" | "complete" | "completed" => "completed".to_string(),
8382        "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
8383        "open" | "todo" | "pending" => "pending".to_string(),
8384        other => other.to_string(),
8385    }
8386}
8387
8388fn compact_chat_history(
8389    messages: Vec<ChatMessage>,
8390    profile: ChatHistoryProfile,
8391) -> Vec<ChatMessage> {
8392    let (max_context_chars, keep_recent_messages) = match profile {
8393        ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
8394        ChatHistoryProfile::Standard => (80_000usize, 40usize),
8395        ChatHistoryProfile::Compact => (12_000usize, 12usize),
8396    };
8397
8398    if messages.len() <= keep_recent_messages {
8399        let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
8400        if total_chars <= max_context_chars {
8401            return messages;
8402        }
8403    }
8404
8405    let mut kept = messages;
8406    let mut dropped_count = 0usize;
8407    let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
8408
8409    while kept.len() > keep_recent_messages || total_chars > max_context_chars {
8410        if kept.is_empty() {
8411            break;
8412        }
8413        let removed = kept.remove(0);
8414        total_chars = total_chars.saturating_sub(removed.content.len());
8415        dropped_count += 1;
8416    }
8417
8418    if dropped_count > 0 {
8419        kept.insert(
8420            0,
8421            ChatMessage {
8422                role: "system".to_string(),
8423                content: format!(
8424                    "[history compacted: omitted {} older messages to fit context window]",
8425                    dropped_count
8426                ),
8427                attachments: Vec::new(),
8428            },
8429        );
8430    }
8431    kept
8432}
8433
8434#[cfg(test)]
8435mod tests {
8436    use super::*;
8437    use crate::{EventBus, Storage};
8438    use std::sync::{Mutex, OnceLock};
8439    use tandem_types::Session;
8440    use uuid::Uuid;
8441
8442    fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
8443        static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
8444        ENV_TEST_LOCK
8445            .get_or_init(|| Mutex::new(()))
8446            .lock()
8447            .expect("env test lock")
8448    }
8449
8450    #[tokio::test]
8451    async fn todo_updated_event_is_normalized() {
8452        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8453        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8454        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8455        let session_id = session.id.clone();
8456        storage.save_session(session).await.expect("save session");
8457
8458        let bus = EventBus::new();
8459        let mut rx = bus.subscribe();
8460        emit_tool_side_events(
8461            storage.clone(),
8462            &bus,
8463            ToolSideEventContext {
8464                session_id: &session_id,
8465                message_id: "m1",
8466                tool: "todo_write",
8467                args: &json!({"todos":[{"content":"ship parity"}]}),
8468                metadata: &json!({"todos":[{"content":"ship parity"}]}),
8469                workspace_root: Some("."),
8470                effective_cwd: Some("."),
8471            },
8472        )
8473        .await;
8474
8475        let event = rx.recv().await.expect("event");
8476        assert_eq!(event.event_type, "todo.updated");
8477        let todos = event
8478            .properties
8479            .get("todos")
8480            .and_then(|v| v.as_array())
8481            .cloned()
8482            .unwrap_or_default();
8483        assert_eq!(todos.len(), 1);
8484        assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
8485        assert_eq!(
8486            todos[0].get("content").and_then(|v| v.as_str()),
8487            Some("ship parity")
8488        );
8489        assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
8490    }
8491
8492    #[tokio::test]
8493    async fn question_asked_event_contains_tool_reference() {
8494        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8495        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8496        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8497        let session_id = session.id.clone();
8498        storage.save_session(session).await.expect("save session");
8499
8500        let bus = EventBus::new();
8501        let mut rx = bus.subscribe();
8502        emit_tool_side_events(
8503            storage,
8504            &bus,
8505            ToolSideEventContext {
8506                session_id: &session_id,
8507                message_id: "msg-1",
8508                tool: "question",
8509                args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8510                metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8511                workspace_root: Some("."),
8512                effective_cwd: Some("."),
8513            },
8514        )
8515        .await;
8516
8517        let event = rx.recv().await.expect("event");
8518        assert_eq!(event.event_type, "question.asked");
8519        assert_eq!(
8520            event
8521                .properties
8522                .get("sessionID")
8523                .and_then(|v| v.as_str())
8524                .unwrap_or(""),
8525            session_id
8526        );
8527        let tool = event
8528            .properties
8529            .get("tool")
8530            .cloned()
8531            .unwrap_or_else(|| json!({}));
8532        assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
8533        assert_eq!(
8534            tool.get("messageID").and_then(|v| v.as_str()),
8535            Some("msg-1")
8536        );
8537    }
8538
8539    #[test]
8540    fn compact_chat_history_keeps_recent_and_inserts_summary() {
8541        let mut messages = Vec::new();
8542        for i in 0..60 {
8543            messages.push(ChatMessage {
8544                role: "user".to_string(),
8545                content: format!("message-{i}"),
8546                attachments: Vec::new(),
8547            });
8548        }
8549        let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
8550        assert!(compacted.len() <= 41);
8551        assert_eq!(compacted[0].role, "system");
8552        assert!(compacted[0].content.contains("history compacted"));
8553        assert!(compacted.iter().any(|m| m.content.contains("message-59")));
8554    }
8555
8556    #[tokio::test]
8557    async fn load_chat_history_preserves_tool_args_and_error_context() {
8558        let base = std::env::temp_dir().join(format!(
8559            "tandem-core-load-chat-history-error-{}",
8560            uuid::Uuid::new_v4()
8561        ));
8562        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8563        let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8564        let session_id = session.id.clone();
8565        storage.save_session(session).await.expect("save session");
8566
8567        let message = Message::new(
8568            MessageRole::User,
8569            vec![
8570                MessagePart::Text {
8571                    text: "build the page".to_string(),
8572                },
8573                MessagePart::ToolInvocation {
8574                    tool: "write".to_string(),
8575                    args: json!({"path":"game.html","content":"<html>draft</html>"}),
8576                    result: None,
8577                    error: Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
8578                },
8579            ],
8580        );
8581        storage
8582            .append_message(&session_id, message)
8583            .await
8584            .expect("append message");
8585
8586        let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8587        let content = history
8588            .iter()
8589            .find(|message| message.role == "user")
8590            .map(|message| message.content.clone())
8591            .unwrap_or_default();
8592        assert!(content.contains("build the page"));
8593        assert!(content.contains("Tool write"));
8594        assert!(content.contains(r#"args={"content":"<html>draft</html>","path":"game.html"}"#));
8595        assert!(content.contains("error=WRITE_ARGS_EMPTY_FROM_PROVIDER"));
8596    }
8597
8598    #[tokio::test]
8599    async fn load_chat_history_preserves_tool_args_and_result_context() {
8600        let base = std::env::temp_dir().join(format!(
8601            "tandem-core-load-chat-history-result-{}",
8602            uuid::Uuid::new_v4()
8603        ));
8604        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8605        let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8606        let session_id = session.id.clone();
8607        storage.save_session(session).await.expect("save session");
8608
8609        let message = Message::new(
8610            MessageRole::Assistant,
8611            vec![MessagePart::ToolInvocation {
8612                tool: "glob".to_string(),
8613                args: json!({"pattern":"src/**/*.rs"}),
8614                result: Some(json!({"output":"src/lib.rs\nsrc/main.rs"})),
8615                error: None,
8616            }],
8617        );
8618        storage
8619            .append_message(&session_id, message)
8620            .await
8621            .expect("append message");
8622
8623        let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8624        let content = history
8625            .iter()
8626            .find(|message| message.role == "assistant")
8627            .map(|message| message.content.clone())
8628            .unwrap_or_default();
8629        assert!(content.contains("Tool glob"));
8630        assert!(content.contains(r#"args={"pattern":"src/**/*.rs"}"#));
8631        assert!(content.contains(r#"result={"output":"src/lib.rs\nsrc/main.rs"}"#));
8632    }
8633
8634    #[test]
8635    fn extracts_todos_from_checklist_and_numbered_lines() {
8636        let input = r#"
8637Plan:
8638- [ ] Audit current implementation
8639- [ ] Add planner fallback
86401. Add regression test coverage
8641"#;
8642        let todos = extract_todo_candidates_from_text(input);
8643        assert_eq!(todos.len(), 3);
8644        assert_eq!(
8645            todos[0].get("content").and_then(|v| v.as_str()),
8646            Some("Audit current implementation")
8647        );
8648    }
8649
8650    #[test]
8651    fn does_not_extract_todos_from_plain_prose_lines() {
8652        let input = r#"
8653I need more information to proceed.
8654Can you tell me the event size and budget?
8655Once I have that, I can provide a detailed plan.
8656"#;
8657        let todos = extract_todo_candidates_from_text(input);
8658        assert!(todos.is_empty());
8659    }
8660
8661    #[test]
8662    fn parses_wrapped_tool_call_from_markdown_response() {
8663        let input = r#"
8664Here is the tool call:
8665```json
8666{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
8667```
8668"#;
8669        let parsed = parse_tool_invocation_from_response(input).expect("tool call");
8670        assert_eq!(parsed.0, "todo_write");
8671        assert!(parsed.1.get("todos").is_some());
8672    }
8673
8674    #[test]
8675    fn parses_top_level_name_args_tool_call() {
8676        let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
8677        let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
8678        assert_eq!(parsed.0, "bash");
8679        assert_eq!(
8680            parsed.1.get("command").and_then(|v| v.as_str()),
8681            Some("echo hi")
8682        );
8683    }
8684
8685    #[test]
8686    fn parses_function_style_todowrite_call() {
8687        let input = r#"Status: Completed
8688Call: todowrite(task_id=2, status="completed")"#;
8689        let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
8690        assert_eq!(parsed.0, "todo_write");
8691        assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8692        assert_eq!(
8693            parsed.1.get("status").and_then(|v| v.as_str()),
8694            Some("completed")
8695        );
8696    }
8697
8698    #[test]
8699    fn parses_multiple_function_style_todowrite_calls() {
8700        let input = r#"
8701Call: todowrite(task_id=2, status="completed")
8702Call: todowrite(task_id=3, status="in_progress")
8703"#;
8704        let parsed = parse_tool_invocations_from_response(input);
8705        assert_eq!(parsed.len(), 2);
8706        assert_eq!(parsed[0].0, "todo_write");
8707        assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8708        assert_eq!(
8709            parsed[0].1.get("status").and_then(|v| v.as_str()),
8710            Some("completed")
8711        );
8712        assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
8713        assert_eq!(
8714            parsed[1].1.get("status").and_then(|v| v.as_str()),
8715            Some("in_progress")
8716        );
8717    }
8718
8719    #[test]
8720    fn applies_todo_status_update_from_task_id_args() {
8721        let current = vec![
8722            json!({"id":"todo-1","content":"a","status":"pending"}),
8723            json!({"id":"todo-2","content":"b","status":"pending"}),
8724            json!({"id":"todo-3","content":"c","status":"pending"}),
8725        ];
8726        let updated =
8727            apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
8728                .expect("status update");
8729        assert_eq!(
8730            updated[1].get("status").and_then(|v| v.as_str()),
8731            Some("completed")
8732        );
8733    }
8734
8735    #[test]
8736    fn normalizes_todo_write_tasks_alias() {
8737        let normalized = normalize_todo_write_args(
8738            json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
8739            "",
8740        );
8741        let todos = normalized
8742            .get("todos")
8743            .and_then(|v| v.as_array())
8744            .cloned()
8745            .unwrap_or_default();
8746        assert_eq!(todos.len(), 2);
8747        assert_eq!(
8748            todos[0].get("content").and_then(|v| v.as_str()),
8749            Some("Book venue")
8750        );
8751        assert_eq!(
8752            todos[1].get("content").and_then(|v| v.as_str()),
8753            Some("Send invites")
8754        );
8755    }
8756
8757    #[test]
8758    fn normalizes_todo_write_from_completion_when_args_empty() {
8759        let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
8760        let normalized = normalize_todo_write_args(json!({}), completion);
8761        let todos = normalized
8762            .get("todos")
8763            .and_then(|v| v.as_array())
8764            .cloned()
8765            .unwrap_or_default();
8766        assert_eq!(todos.len(), 3);
8767        assert!(!is_empty_todo_write_args(&normalized));
8768    }
8769
8770    #[test]
8771    fn empty_todo_write_args_allows_status_updates() {
8772        let args = json!({"task_id": 2, "status":"completed"});
8773        assert!(!is_empty_todo_write_args(&args));
8774    }
8775
8776    #[test]
8777    fn streamed_websearch_args_fallback_to_query_string() {
8778        let parsed = parse_streamed_tool_args("websearch", "meaning of life");
8779        assert_eq!(
8780            parsed.get("query").and_then(|v| v.as_str()),
8781            Some("meaning of life")
8782        );
8783    }
8784
8785    #[test]
8786    fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
8787        assert_eq!(
8788            parse_scalar_like_value("\""),
8789            Value::String("\"".to_string())
8790        );
8791        assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
8792    }
8793
8794    #[test]
8795    fn streamed_websearch_stringified_json_args_are_unwrapped() {
8796        let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
8797        assert_eq!(
8798            parsed.get("query").and_then(|v| v.as_str()),
8799            Some("donkey gestation period")
8800        );
8801    }
8802
8803    #[test]
8804    fn streamed_websearch_args_strip_arg_key_value_wrappers() {
8805        let parsed = parse_streamed_tool_args(
8806            "websearch",
8807            "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
8808        );
8809        assert_eq!(
8810            parsed.get("query").and_then(|v| v.as_str()),
8811            Some("taj card what is it benefits how to apply")
8812        );
8813    }
8814
8815    #[test]
8816    fn normalize_tool_args_websearch_infers_from_user_text() {
8817        let normalized =
8818            normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
8819        assert_eq!(
8820            normalized.args.get("query").and_then(|v| v.as_str()),
8821            Some("meaning of life")
8822        );
8823        assert_eq!(normalized.args_source, "inferred_from_user");
8824        assert_eq!(normalized.args_integrity, "recovered");
8825    }
8826
8827    #[test]
8828    fn normalize_tool_args_websearch_keeps_existing_query() {
8829        let normalized = normalize_tool_args(
8830            "websearch",
8831            json!({"query":"already set"}),
8832            "web search should not override",
8833            "",
8834        );
8835        assert_eq!(
8836            normalized.args.get("query").and_then(|v| v.as_str()),
8837            Some("already set")
8838        );
8839        assert_eq!(normalized.args_source, "provider_json");
8840        assert_eq!(normalized.args_integrity, "ok");
8841    }
8842
8843    #[test]
8844    fn normalize_tool_args_websearch_fails_when_unrecoverable() {
8845        let normalized = normalize_tool_args("websearch", json!({}), "search", "");
8846        assert!(normalized.query.is_none());
8847        assert!(normalized.missing_terminal);
8848        assert_eq!(normalized.args_source, "missing");
8849        assert_eq!(normalized.args_integrity, "empty");
8850    }
8851
8852    #[test]
8853    fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
8854        let normalized = normalize_tool_args(
8855            "webfetch",
8856            json!({}),
8857            "Please fetch `https://docs.tandem.ac/` in markdown mode",
8858            "",
8859        );
8860        assert!(!normalized.missing_terminal);
8861        assert_eq!(
8862            normalized.args.get("url").and_then(|v| v.as_str()),
8863            Some("https://docs.tandem.ac/")
8864        );
8865        assert_eq!(normalized.args_source, "inferred_from_user");
8866        assert_eq!(normalized.args_integrity, "recovered");
8867    }
8868
8869    #[test]
8870    fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
8871        let normalized = normalize_tool_args(
8872            "webfetch",
8873            json!({"args":{"uri":"https://example.com/page"}}),
8874            "",
8875            "",
8876        );
8877        assert!(!normalized.missing_terminal);
8878        assert_eq!(
8879            normalized.args.get("url").and_then(|v| v.as_str()),
8880            Some("https://example.com/page")
8881        );
8882        assert_eq!(normalized.args_source, "provider_json");
8883    }
8884
8885    #[test]
8886    fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
8887        let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
8888        assert!(normalized.missing_terminal);
8889        assert_eq!(
8890            normalized.missing_terminal_reason.as_deref(),
8891            Some("WEBFETCH_URL_MISSING")
8892        );
8893    }
8894
8895    #[test]
8896    fn normalize_tool_args_answer_how_to_infers_task_from_user_prompt() {
8897        let user_text = "what is tandem and how do i use it?";
8898        let normalized =
8899            normalize_tool_args("mcp.tandem_mcp.answer_how_to", json!({}), user_text, "");
8900        assert!(!normalized.missing_terminal);
8901        assert_eq!(
8902            normalized.args.get("task").and_then(|v| v.as_str()),
8903            Some(user_text)
8904        );
8905        assert_eq!(normalized.args_source, "inferred_from_user");
8906        assert_eq!(normalized.args_integrity, "recovered");
8907    }
8908
8909    #[test]
8910    fn normalize_tool_args_answer_how_to_keeps_existing_task() {
8911        let normalized = normalize_tool_args(
8912            "mcp.tandem_mcp.answer_how_to",
8913            json!({"task":"install tandem locally"}),
8914            "different user prompt",
8915            "",
8916        );
8917        assert!(!normalized.missing_terminal);
8918        assert_eq!(
8919            normalized.args.get("task").and_then(|v| v.as_str()),
8920            Some("install tandem locally")
8921        );
8922        assert_eq!(normalized.args_source, "provider_json");
8923        assert_eq!(normalized.args_integrity, "ok");
8924    }
8925
8926    #[test]
8927    fn normalize_tool_args_search_docs_infers_query_from_user_prompt() {
8928        let user_text = "https://docs.tandem.ac/start-here/";
8929        let normalized =
8930            normalize_tool_args("mcp.tandem_mcp.search_docs", json!({}), user_text, "");
8931        assert!(!normalized.missing_terminal);
8932        assert_eq!(
8933            normalized.args.get("query").and_then(|v| v.as_str()),
8934            Some(user_text)
8935        );
8936        assert_eq!(normalized.args_source, "inferred_from_user");
8937        assert_eq!(normalized.args_integrity, "recovered");
8938    }
8939
8940    #[test]
8941    fn normalize_tool_args_search_docs_keeps_existing_query() {
8942        let normalized = normalize_tool_args(
8943            "mcp.tandem_mcp.search_docs",
8944            json!({"query":"oauth setup"}),
8945            "different user prompt",
8946            "",
8947        );
8948        assert!(!normalized.missing_terminal);
8949        assert_eq!(
8950            normalized.args.get("query").and_then(|v| v.as_str()),
8951            Some("oauth setup")
8952        );
8953        assert_eq!(normalized.args_source, "provider_json");
8954        assert_eq!(normalized.args_integrity, "ok");
8955    }
8956
8957    #[test]
8958    fn normalize_tool_args_get_doc_infers_path_from_user_url() {
8959        let user_text = "https://docs.tandem.ac/start-here/";
8960        let normalized = normalize_tool_args("mcp.tandem_mcp.get_doc", json!({}), user_text, "");
8961        assert!(!normalized.missing_terminal);
8962        assert_eq!(
8963            normalized.args.get("path").and_then(|v| v.as_str()),
8964            Some(user_text)
8965        );
8966        assert_eq!(normalized.args_source, "inferred_from_user");
8967        assert_eq!(normalized.args_integrity, "recovered");
8968    }
8969
8970    #[test]
8971    fn normalize_tool_args_get_doc_keeps_existing_path() {
8972        let normalized = normalize_tool_args(
8973            "mcp.tandem_mcp.get_doc",
8974            json!({"path":"/start-here/"}),
8975            "different user prompt",
8976            "",
8977        );
8978        assert!(!normalized.missing_terminal);
8979        assert_eq!(
8980            normalized.args.get("path").and_then(|v| v.as_str()),
8981            Some("/start-here/")
8982        );
8983        assert_eq!(normalized.args_source, "provider_json");
8984        assert_eq!(normalized.args_integrity, "ok");
8985    }
8986
8987    #[test]
8988    fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
8989        let user_text =
8990            "Create a pack that checks latest headline news every day at 8 AM and emails me.";
8991        let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
8992        assert!(!normalized.missing_terminal);
8993        assert_eq!(
8994            normalized.args.get("goal").and_then(|v| v.as_str()),
8995            Some(user_text)
8996        );
8997        assert_eq!(
8998            normalized.args.get("mode").and_then(|v| v.as_str()),
8999            Some("preview")
9000        );
9001        assert_eq!(normalized.args_source, "inferred_from_user");
9002        assert_eq!(normalized.args_integrity, "recovered");
9003    }
9004
9005    #[test]
9006    fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
9007        let normalized = normalize_tool_args(
9008            "pack_builder",
9009            json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
9010            "new goal should not override",
9011            "",
9012        );
9013        assert!(!normalized.missing_terminal);
9014        assert_eq!(
9015            normalized.args.get("goal").and_then(|v| v.as_str()),
9016            Some("existing goal")
9017        );
9018        assert_eq!(
9019            normalized.args.get("mode").and_then(|v| v.as_str()),
9020            Some("apply")
9021        );
9022        assert_eq!(normalized.args_source, "provider_json");
9023        assert_eq!(normalized.args_integrity, "ok");
9024    }
9025
9026    #[test]
9027    fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
9028        let assistant_context =
9029            "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
9030        let normalized =
9031            normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
9032        assert!(!normalized.missing_terminal);
9033        assert_eq!(
9034            normalized.args.get("mode").and_then(|v| v.as_str()),
9035            Some("apply")
9036        );
9037        assert_eq!(
9038            normalized.args.get("plan_id").and_then(|v| v.as_str()),
9039            Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
9040        );
9041        assert_eq!(
9042            normalized
9043                .args
9044                .get("approve_pack_install")
9045                .and_then(|v| v.as_bool()),
9046            Some(true)
9047        );
9048        assert_eq!(normalized.args_source, "recovered_from_context");
9049    }
9050
9051    #[test]
9052    fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
9053        let assistant_context =
9054            "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
9055        let normalized = normalize_tool_args(
9056            "pack_builder",
9057            json!({"mode":"apply"}),
9058            "yes",
9059            assistant_context,
9060        );
9061        assert!(!normalized.missing_terminal);
9062        assert_eq!(
9063            normalized.args.get("mode").and_then(|v| v.as_str()),
9064            Some("apply")
9065        );
9066        assert_eq!(
9067            normalized.args.get("plan_id").and_then(|v| v.as_str()),
9068            Some("plan-11111111-2222-3333-4444-555555555555")
9069        );
9070    }
9071
9072    #[test]
9073    fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
9074        let assistant_context =
9075            "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
9076        let normalized = normalize_tool_args(
9077            "pack_builder",
9078            json!({}),
9079            "create jira sync",
9080            assistant_context,
9081        );
9082        assert!(!normalized.missing_terminal);
9083        assert_eq!(
9084            normalized.args.get("mode").and_then(|v| v.as_str()),
9085            Some("preview")
9086        );
9087        assert_eq!(
9088            normalized.args.get("goal").and_then(|v| v.as_str()),
9089            Some("create jira sync")
9090        );
9091    }
9092
9093    #[test]
9094    fn normalize_tool_args_write_requires_path() {
9095        let normalized = normalize_tool_args("write", json!({}), "", "");
9096        assert!(normalized.missing_terminal);
9097        assert_eq!(
9098            normalized.missing_terminal_reason.as_deref(),
9099            Some("FILE_PATH_MISSING")
9100        );
9101    }
9102
9103    #[test]
9104    fn persisted_failed_tool_args_prefers_normalized_when_raw_is_empty() {
9105        let args = persisted_failed_tool_args(
9106            &json!({}),
9107            &json!({"path":"game.html","content":"<html></html>"}),
9108        );
9109        assert_eq!(args["path"], "game.html");
9110        assert_eq!(args["content"], "<html></html>");
9111    }
9112
9113    #[test]
9114    fn persisted_failed_tool_args_keeps_non_empty_raw_payload() {
9115        let args = persisted_failed_tool_args(
9116            &json!("path=game.html content"),
9117            &json!({"path":"game.html"}),
9118        );
9119        assert_eq!(args, json!("path=game.html content"));
9120    }
9121
9122    #[test]
9123    fn normalize_tool_args_write_recovers_alias_path_key() {
9124        let normalized = normalize_tool_args(
9125            "write",
9126            json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
9127            "",
9128            "",
9129        );
9130        assert!(!normalized.missing_terminal);
9131        assert_eq!(
9132            normalized.args.get("path").and_then(|v| v.as_str()),
9133            Some("docs/CONCEPT.md")
9134        );
9135        assert_eq!(
9136            normalized.args.get("content").and_then(|v| v.as_str()),
9137            Some("hello")
9138        );
9139    }
9140
9141    #[test]
9142    fn normalize_tool_args_write_recovers_html_output_target_path() {
9143        let normalized = normalize_tool_args_with_mode(
9144            "write",
9145            json!({"content":"<html></html>"}),
9146            "Execute task.\n\nRequired output target:\n{\n  \"path\": \"game.html\",\n  \"kind\": \"source\",\n  \"operation\": \"create_or_update\"\n}\n",
9147            "",
9148            WritePathRecoveryMode::OutputTargetOnly,
9149        );
9150        assert!(!normalized.missing_terminal);
9151        assert_eq!(
9152            normalized.args.get("path").and_then(|v| v.as_str()),
9153            Some("game.html")
9154        );
9155    }
9156
9157    #[test]
9158    fn normalize_tool_args_read_infers_path_from_user_prompt() {
9159        let normalized = normalize_tool_args(
9160            "read",
9161            json!({}),
9162            "Please inspect `FEATURE_LIST.md` and summarize key sections.",
9163            "",
9164        );
9165        assert!(!normalized.missing_terminal);
9166        assert_eq!(
9167            normalized.args.get("path").and_then(|v| v.as_str()),
9168            Some("FEATURE_LIST.md")
9169        );
9170        assert_eq!(normalized.args_source, "inferred_from_user");
9171        assert_eq!(normalized.args_integrity, "recovered");
9172    }
9173
9174    #[test]
9175    fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
9176        let normalized = normalize_tool_args(
9177            "read",
9178            json!({}),
9179            "generic instruction",
9180            "I will read src-tauri/src/orchestrator/engine.rs first.",
9181        );
9182        assert!(normalized.missing_terminal);
9183        assert_eq!(
9184            normalized.missing_terminal_reason.as_deref(),
9185            Some("FILE_PATH_MISSING")
9186        );
9187    }
9188
9189    #[test]
9190    fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
9191        let normalized = normalize_tool_args(
9192            "write",
9193            json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
9194            "",
9195            "",
9196        );
9197        assert!(!normalized.missing_terminal);
9198        assert_eq!(
9199            normalized.args.get("path").and_then(|v| v.as_str()),
9200            Some("docs/CONCEPT.md")
9201        );
9202    }
9203
9204    #[test]
9205    fn normalize_tool_args_write_recovers_content_alias() {
9206        let normalized = normalize_tool_args(
9207            "write",
9208            json!({"path":"docs/FEATURES.md","body":"feature notes"}),
9209            "",
9210            "",
9211        );
9212        assert!(!normalized.missing_terminal);
9213        assert_eq!(
9214            normalized.args.get("content").and_then(|v| v.as_str()),
9215            Some("feature notes")
9216        );
9217    }
9218
9219    #[test]
9220    fn normalize_tool_args_write_fails_when_content_missing() {
9221        let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
9222        assert!(normalized.missing_terminal);
9223        assert_eq!(
9224            normalized.missing_terminal_reason.as_deref(),
9225            Some("WRITE_CONTENT_MISSING")
9226        );
9227    }
9228
9229    #[test]
9230    fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
9231        let normalized = normalize_tool_args_with_mode(
9232            "write",
9233            json!({}),
9234            "Please implement the screen/state structure in the workspace.",
9235            "",
9236            WritePathRecoveryMode::OutputTargetOnly,
9237        );
9238        assert!(normalized.missing_terminal);
9239        assert_eq!(
9240            normalized.missing_terminal_reason.as_deref(),
9241            Some("FILE_PATH_MISSING")
9242        );
9243    }
9244
9245    #[test]
9246    fn normalize_tool_args_write_output_target_only_recovers_from_dot_slash_path() {
9247        let normalized = normalize_tool_args_with_mode(
9248            "write",
9249            json!({"path":"./","content":"{}"}),
9250            "Required Workspace Output:\n- Create or update `.tandem/runs/automation-v2-run-123/artifacts/research-sources.json` relative to the workspace root.",
9251            "",
9252            WritePathRecoveryMode::OutputTargetOnly,
9253        );
9254        assert!(!normalized.missing_terminal);
9255        assert_eq!(
9256            normalized.args.get("path").and_then(|v| v.as_str()),
9257            Some(".tandem/runs/automation-v2-run-123/artifacts/research-sources.json")
9258        );
9259    }
9260
9261    #[test]
9262    fn normalize_tool_args_write_recovers_content_from_assistant_context() {
9263        let normalized = normalize_tool_args(
9264            "write",
9265            json!({"path":"docs/FEATURES.md"}),
9266            "",
9267            "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
9268        );
9269        assert!(!normalized.missing_terminal);
9270        assert_eq!(
9271            normalized.args.get("path").and_then(|v| v.as_str()),
9272            Some("docs/FEATURES.md")
9273        );
9274        assert_eq!(
9275            normalized.args.get("content").and_then(|v| v.as_str()),
9276            Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
9277        );
9278        assert_eq!(normalized.args_source, "recovered_from_context");
9279        assert_eq!(normalized.args_integrity, "recovered");
9280    }
9281
9282    #[test]
9283    fn normalize_tool_args_write_recovers_raw_nested_string_content() {
9284        let normalized = normalize_tool_args(
9285            "write",
9286            json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
9287            "",
9288            "",
9289        );
9290        assert!(!normalized.missing_terminal);
9291        assert_eq!(
9292            normalized.args.get("path").and_then(|v| v.as_str()),
9293            Some("docs/FEATURES.md")
9294        );
9295        assert_eq!(
9296            normalized.args.get("content").and_then(|v| v.as_str()),
9297            Some("Line 1\nLine 2")
9298        );
9299    }
9300
9301    #[test]
9302    fn normalize_tool_args_write_does_not_treat_path_as_content() {
9303        let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
9304        assert!(normalized.missing_terminal);
9305        assert_eq!(
9306            normalized.missing_terminal_reason.as_deref(),
9307            Some("WRITE_CONTENT_MISSING")
9308        );
9309    }
9310
9311    #[test]
9312    fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
9313        let normalized = normalize_tool_args(
9314            "mcp.composio_1.gmail_send_email",
9315            json!({
9316                "to": "user123@example.com",
9317                "subject": "Test",
9318                "body": "Hello",
9319                "attachment": {
9320                    "s3key": ""
9321                }
9322            }),
9323            "",
9324            "",
9325        );
9326        assert!(normalized.args.get("attachment").is_none());
9327        assert_eq!(normalized.args_source, "sanitized_attachment");
9328    }
9329
9330    #[test]
9331    fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
9332        let normalized = normalize_tool_args(
9333            "mcp.composio_1.gmail_send_email",
9334            json!({
9335                "to": "user123@example.com",
9336                "subject": "Test",
9337                "body": "Hello",
9338                "attachment": {
9339                    "s3key": "file_123"
9340                }
9341            }),
9342            "",
9343            "",
9344        );
9345        assert_eq!(
9346            normalized
9347                .args
9348                .get("attachment")
9349                .and_then(|value| value.get("s3key"))
9350                .and_then(|value| value.as_str()),
9351            Some("file_123")
9352        );
9353    }
9354
9355    #[test]
9356    fn classify_required_tool_failure_detects_empty_provider_write_args() {
9357        let reason = classify_required_tool_failure(
9358            &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
9359            true,
9360            1,
9361            false,
9362            false,
9363        );
9364        assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
9365    }
9366
9367    #[test]
9368    fn normalize_tool_args_read_infers_path_from_bold_markdown() {
9369        let normalized = normalize_tool_args(
9370            "read",
9371            json!({}),
9372            "Please read **FEATURE_LIST.md** and summarize.",
9373            "",
9374        );
9375        assert!(!normalized.missing_terminal);
9376        assert_eq!(
9377            normalized.args.get("path").and_then(|v| v.as_str()),
9378            Some("FEATURE_LIST.md")
9379        );
9380    }
9381
9382    #[test]
9383    fn normalize_tool_args_shell_infers_command_from_user_prompt() {
9384        let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
9385        assert!(!normalized.missing_terminal);
9386        assert_eq!(
9387            normalized.args.get("command").and_then(|v| v.as_str()),
9388            Some("rg -n \"TODO\" .")
9389        );
9390        assert_eq!(normalized.args_source, "inferred_from_user");
9391        assert_eq!(normalized.args_integrity, "recovered");
9392    }
9393
9394    #[test]
9395    fn normalize_tool_args_read_rejects_root_only_path() {
9396        let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
9397        assert!(normalized.missing_terminal);
9398        assert_eq!(
9399            normalized.missing_terminal_reason.as_deref(),
9400            Some("FILE_PATH_MISSING")
9401        );
9402    }
9403
9404    #[test]
9405    fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
9406        let normalized =
9407            normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
9408        assert!(!normalized.missing_terminal);
9409        assert_eq!(
9410            normalized.args.get("path").and_then(|v| v.as_str()),
9411            Some("CONCEPT.md")
9412        );
9413        assert_eq!(normalized.args_source, "inferred_from_user");
9414        assert_eq!(normalized.args_integrity, "recovered");
9415    }
9416
9417    #[test]
9418    fn normalize_tool_args_read_rejects_tool_call_markup_path() {
9419        let normalized = normalize_tool_args(
9420            "read",
9421            json!({
9422                "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
9423            }),
9424            "",
9425            "",
9426        );
9427        assert!(normalized.missing_terminal);
9428        assert_eq!(
9429            normalized.missing_terminal_reason.as_deref(),
9430            Some("FILE_PATH_MISSING")
9431        );
9432    }
9433
9434    #[test]
9435    fn normalize_tool_args_read_rejects_glob_pattern_path() {
9436        let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
9437        assert!(normalized.missing_terminal);
9438        assert_eq!(
9439            normalized.missing_terminal_reason.as_deref(),
9440            Some("FILE_PATH_MISSING")
9441        );
9442    }
9443
9444    #[test]
9445    fn normalize_tool_args_read_rejects_placeholder_path() {
9446        let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
9447        assert!(normalized.missing_terminal);
9448        assert_eq!(
9449            normalized.missing_terminal_reason.as_deref(),
9450            Some("FILE_PATH_MISSING")
9451        );
9452    }
9453
9454    #[test]
9455    fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
9456        let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
9457        assert!(normalized.missing_terminal);
9458        assert_eq!(
9459            normalized.missing_terminal_reason.as_deref(),
9460            Some("FILE_PATH_MISSING")
9461        );
9462    }
9463
9464    #[test]
9465    fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
9466        let normalized = normalize_tool_args(
9467            "read",
9468            json!({"path":"tool/policy"}),
9469            "Read `T1011U kitöltési útmutató.pdf` and summarize.",
9470            "",
9471        );
9472        assert!(!normalized.missing_terminal);
9473        assert_eq!(
9474            normalized.args.get("path").and_then(|v| v.as_str()),
9475            Some("T1011U kitöltési útmutató.pdf")
9476        );
9477        assert_eq!(normalized.args_source, "inferred_from_user");
9478        assert_eq!(normalized.args_integrity, "recovered");
9479    }
9480
9481    #[test]
9482    fn normalize_tool_name_strips_default_api_namespace() {
9483        assert_eq!(normalize_tool_name("default_api:read"), "read");
9484        assert_eq!(normalize_tool_name("functions.shell"), "bash");
9485    }
9486
9487    #[test]
9488    fn mcp_server_from_tool_name_parses_server_segment() {
9489        assert_eq!(
9490            mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
9491            Some("arcade")
9492        );
9493        assert_eq!(mcp_server_from_tool_name("read"), None);
9494        assert_eq!(mcp_server_from_tool_name("mcp"), None);
9495    }
9496
9497    #[test]
9498    fn mcp_tools_are_exempt_from_workspace_sandbox_path_checks() {
9499        assert!(is_mcp_tool_name("mcp_list"));
9500        assert!(is_mcp_tool_name("mcp.tandem_mcp.get_doc"));
9501        assert!(is_mcp_tool_name("MCP.TANDEM_MCP.GET_DOC"));
9502        assert!(!is_mcp_tool_name("read"));
9503        assert!(!is_mcp_tool_name("glob"));
9504    }
9505
9506    #[test]
9507    fn batch_helpers_use_name_when_tool_is_wrapper() {
9508        let args = json!({
9509            "tool_calls":[
9510                {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
9511                {"tool":"default_api:glob","args":{"pattern":"*.md"}}
9512            ]
9513        });
9514        let calls = extract_batch_calls(&args);
9515        assert_eq!(calls.len(), 2);
9516        assert_eq!(calls[0].0, "read");
9517        assert_eq!(calls[1].0, "glob");
9518        assert!(is_read_only_batch_call(&args));
9519        let sig = batch_tool_signature(&args).unwrap_or_default();
9520        assert!(sig.contains("read:"));
9521        assert!(sig.contains("glob:"));
9522    }
9523
9524    #[test]
9525    fn batch_helpers_resolve_nested_function_name() {
9526        let args = json!({
9527            "tool_calls":[
9528                {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
9529            ]
9530        });
9531        let calls = extract_batch_calls(&args);
9532        assert_eq!(calls.len(), 1);
9533        assert_eq!(calls[0].0, "read");
9534        assert!(is_read_only_batch_call(&args));
9535    }
9536
9537    #[test]
9538    fn batch_output_classifier_detects_non_productive_unknown_results() {
9539        let output = r#"
9540[
9541  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
9542  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
9543]
9544"#;
9545        assert!(is_non_productive_batch_output(output));
9546    }
9547
9548    #[test]
9549    fn runtime_prompt_includes_execution_environment_block() {
9550        let prompt = tandem_runtime_system_prompt(
9551            &HostRuntimeContext {
9552                os: HostOs::Windows,
9553                arch: "x86_64".to_string(),
9554                shell_family: ShellFamily::Powershell,
9555                path_style: PathStyle::Windows,
9556            },
9557            &[],
9558        );
9559        assert!(prompt.contains("[Execution Environment]"));
9560        assert!(prompt.contains("Host OS: windows"));
9561        assert!(prompt.contains("Shell: powershell"));
9562        assert!(prompt.contains("Path style: windows"));
9563    }
9564
9565    #[test]
9566    fn runtime_prompt_includes_connected_integrations_block() {
9567        let prompt = tandem_runtime_system_prompt(
9568            &HostRuntimeContext {
9569                os: HostOs::Linux,
9570                arch: "x86_64".to_string(),
9571                shell_family: ShellFamily::Posix,
9572                path_style: PathStyle::Posix,
9573            },
9574            &["notion".to_string(), "github".to_string()],
9575        );
9576        assert!(prompt.contains("[Connected Integrations]"));
9577        assert!(prompt.contains("- notion"));
9578        assert!(prompt.contains("- github"));
9579    }
9580
9581    #[test]
9582    fn detects_web_research_prompt_keywords() {
9583        assert!(requires_web_research_prompt(
9584            "research todays top news stories and include links"
9585        ));
9586        assert!(!requires_web_research_prompt(
9587            "say hello and summarize this text"
9588        ));
9589    }
9590
9591    #[test]
9592    fn detects_email_delivery_prompt_keywords() {
9593        assert!(requires_email_delivery_prompt(
9594            "send a full report with links to user123@example.com"
9595        ));
9596        assert!(!requires_email_delivery_prompt("draft a summary for later"));
9597    }
9598
9599    #[test]
9600    fn completion_claim_detector_flags_sent_language() {
9601        assert!(completion_claims_email_sent(
9602            "Email Status: Sent to user123@example.com."
9603        ));
9604        assert!(!completion_claims_email_sent(
9605            "I could not send email in this run."
9606        ));
9607    }
9608
9609    #[test]
9610    fn email_tool_detector_finds_mcp_gmail_tools() {
9611        let schemas = vec![
9612            ToolSchema::new("read", "", json!({})),
9613            ToolSchema::new("mcp.composio.gmail_send_email", "", json!({})),
9614        ];
9615        assert!(has_email_action_tools(&schemas));
9616    }
9617
9618    #[test]
9619    fn extract_mcp_auth_required_metadata_parses_expected_shape() {
9620        let metadata = json!({
9621            "server": "arcade",
9622            "mcpAuth": {
9623                "required": true,
9624                "challengeId": "abc123",
9625                "authorizationUrl": "https://example.com/oauth",
9626                "message": "Authorize first",
9627                "pending": true,
9628                "blocked": true,
9629                "retryAfterMs": 8000
9630            }
9631        });
9632        let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
9633        assert_eq!(parsed.challenge_id, "abc123");
9634        assert_eq!(parsed.authorization_url, "https://example.com/oauth");
9635        assert_eq!(parsed.message, "Authorize first");
9636        assert_eq!(parsed.server.as_deref(), Some("arcade"));
9637        assert!(parsed.pending);
9638        assert!(parsed.blocked);
9639        assert_eq!(parsed.retry_after_ms, Some(8000));
9640    }
9641
9642    #[test]
9643    fn auth_required_output_detector_matches_auth_text() {
9644        assert!(is_auth_required_tool_output(
9645            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
9646        ));
9647        assert!(is_auth_required_tool_output(
9648            "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
9649        ));
9650        assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
9651    }
9652
9653    #[test]
9654    fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
9655        assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
9656        assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
9657        assert!(!is_productive_tool_output(
9658            "write",
9659            "Tool `write` result:\nWRITE_CONTENT_MISSING"
9660        ));
9661        assert!(!is_productive_tool_output(
9662            "edit",
9663            "Tool `edit` result:\nFILE_PATH_MISSING"
9664        ));
9665        assert!(!is_productive_tool_output(
9666            "write",
9667            "Tool `write` result:\ninvalid_function_parameters"
9668        ));
9669    }
9670
9671    #[test]
9672    fn productive_tool_output_detector_accepts_real_tool_results() {
9673        assert!(is_productive_tool_output(
9674            "write",
9675            "Tool `write` result:\nWrote /tmp/probe.html"
9676        ));
9677        assert!(!is_productive_tool_output(
9678            "write",
9679            "Authorization required for `write`.\nAuthorize here: https://example.com"
9680        ));
9681    }
9682
9683    #[test]
9684    fn glob_empty_result_is_productive() {
9685        assert!(is_productive_tool_output("glob", "Tool `glob` result:\n"));
9686        assert!(is_productive_tool_output("glob", ""));
9687    }
9688
9689    #[test]
9690    fn write_required_node_retries_after_empty_glob() {
9691        assert!(should_retry_nonproductive_required_tool_cycle(
9692            true, false, true, 0
9693        ));
9694        assert!(should_retry_nonproductive_required_tool_cycle(
9695            true, false, true, 1
9696        ));
9697        assert!(!should_retry_nonproductive_required_tool_cycle(
9698            true, false, true, 2
9699        ));
9700    }
9701
9702    #[test]
9703    fn write_required_node_does_not_take_preparatory_retry_after_write_attempt() {
9704        assert!(!should_retry_nonproductive_required_tool_cycle(
9705            true, true, true, 0
9706        ));
9707        assert!(should_retry_nonproductive_required_tool_cycle(
9708            false, true, false, 0
9709        ));
9710    }
9711
9712    #[test]
9713    fn guard_budget_output_detector_matches_expected_text() {
9714        assert!(is_guard_budget_tool_output(
9715            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9716        ));
9717        assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
9718    }
9719
9720    #[test]
9721    fn summarize_guard_budget_outputs_returns_run_scoped_message() {
9722        let outputs = vec![
9723            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9724                .to_string(),
9725            "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
9726                .to_string(),
9727        ];
9728        let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
9729        assert!(summary.contains("per-run tool guard budget"));
9730        assert!(summary.contains("fresh run"));
9731    }
9732
9733    #[test]
9734    fn duplicate_signature_output_detector_matches_expected_text() {
9735        assert!(is_duplicate_signature_limit_output(
9736            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9737        ));
9738        assert!(!is_duplicate_signature_limit_output(
9739            "Tool `read` result: ok"
9740        ));
9741    }
9742
9743    #[test]
9744    fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
9745        let outputs = vec![
9746            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9747                .to_string(),
9748            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9749                .to_string(),
9750        ];
9751        let summary =
9752            summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
9753        assert!(summary.contains("same tool call kept repeating"));
9754        assert!(summary.contains("clearer command target"));
9755    }
9756
9757    #[test]
9758    fn required_tool_mode_unsatisfied_completion_includes_marker() {
9759        let message =
9760            required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
9761        assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
9762        assert!(message.contains("NO_TOOL_CALL_EMITTED"));
9763        assert!(message.contains("tool_mode=required"));
9764    }
9765
9766    #[test]
9767    fn post_tool_final_narrative_generation_is_allowed_after_required_tools_succeed() {
9768        assert!(should_generate_post_tool_final_narrative(
9769            ToolMode::Required,
9770            1
9771        ));
9772        assert!(!should_generate_post_tool_final_narrative(
9773            ToolMode::Required,
9774            0
9775        ));
9776        assert!(should_generate_post_tool_final_narrative(ToolMode::Auto, 0));
9777    }
9778
9779    #[test]
9780    fn post_tool_final_narrative_prompt_preserves_structured_response_requirements() {
9781        let prompt = build_post_tool_final_narrative_prompt(&[String::from(
9782            "Tool `glob` result:\n/home/user123/marketing-tandem/tandem-reference/SOURCES.md",
9783        )]);
9784        assert!(prompt.contains("Preserve any requested output contract"));
9785        assert!(prompt.contains("required JSON structure"));
9786        assert!(prompt.contains("required handoff fields"));
9787        assert!(prompt.contains("required final status object"));
9788        assert!(prompt.contains("Do not stop at a tool summary"));
9789    }
9790
9791    #[test]
9792    fn summarize_terminal_tool_failure_for_user_maps_doc_path_missing() {
9793        let summary = summarize_terminal_tool_failure_for_user(&[String::from("DOC_PATH_MISSING")]);
9794        assert!(summary.as_deref().unwrap_or_default().contains("docs page"));
9795        assert!(summary
9796            .as_deref()
9797            .unwrap_or_default()
9798            .contains("https://docs.tandem.ac/start-here/"));
9799    }
9800
9801    #[test]
9802    fn summarize_user_visible_tool_outputs_hides_internal_skipped_and_error_lines() {
9803        let summary = summarize_user_visible_tool_outputs(&[
9804            String::from(
9805                "Tool `read` result:\n# Start Here\nTandem is an engine-owned workflow runtime.",
9806            ),
9807            String::from(
9808                "Tool `tool` call skipped: it is not available in this turn. Available tools: mcp.tandem_mcp.get_doc.",
9809            ),
9810            String::from("DOC_PATH_MISSING"),
9811        ]);
9812        assert!(summary.contains("Tool `read` result:"));
9813        assert!(!summary.contains("call skipped"));
9814        assert!(!summary.contains("DOC_PATH_MISSING"));
9815    }
9816
9817    #[test]
9818    fn required_tool_retry_context_mentions_offered_tools() {
9819        let prompt = build_required_tool_retry_context(
9820            "read, write, apply_patch",
9821            RequiredToolFailureKind::ToolCallInvalidArgs,
9822        );
9823        assert!(prompt.contains("Tool access is mandatory"));
9824        assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
9825        assert!(prompt.contains("full `content`"));
9826        assert!(prompt.contains("write, edit, or apply_patch"));
9827    }
9828
9829    #[test]
9830    fn required_tool_retry_context_requires_write_after_read_only_pass() {
9831        let prompt = build_required_tool_retry_context(
9832            "glob, read, write, edit, apply_patch",
9833            RequiredToolFailureKind::WriteRequiredNotSatisfied,
9834        );
9835        assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
9836        assert!(prompt.contains("Inspection is complete"));
9837        assert!(prompt.contains("write, edit, or apply_patch"));
9838    }
9839
9840    #[test]
9841    fn classify_required_tool_failure_detects_invalid_args() {
9842        let reason = classify_required_tool_failure(
9843            &[String::from("WRITE_CONTENT_MISSING")],
9844            true,
9845            1,
9846            false,
9847            false,
9848        );
9849        assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
9850    }
9851
9852    #[test]
9853    fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
9854        assert!(looks_like_unparsed_tool_payload(
9855            r#"{"content":[{"type":"tool_call","name":"write"}]}"#
9856        ));
9857        assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
9858    }
9859
9860    #[test]
9861    fn workspace_write_tool_detection_is_limited_to_mutations() {
9862        assert!(is_workspace_write_tool("write"));
9863        assert!(is_workspace_write_tool("edit"));
9864        assert!(is_workspace_write_tool("apply_patch"));
9865        assert!(!is_workspace_write_tool("read"));
9866        assert!(!is_workspace_write_tool("glob"));
9867    }
9868
9869    #[test]
9870    fn proactive_write_gate_applies_only_before_prewrite_is_satisfied() {
9871        let decision = evaluate_prewrite_gate(
9872            true,
9873            &PrewriteRequirements {
9874                workspace_inspection_required: true,
9875                web_research_required: false,
9876                concrete_read_required: true,
9877                successful_web_research_required: false,
9878                repair_on_unmet_requirements: true,
9879                coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9880            },
9881            PrewriteProgress {
9882                productive_write_tool_calls_total: 0,
9883                productive_workspace_inspection_total: 0,
9884                productive_concrete_read_total: 0,
9885                productive_web_research_total: 0,
9886                successful_web_research_total: 0,
9887                required_write_retry_count: 0,
9888                unmet_prewrite_repair_retry_count: 0,
9889                prewrite_gate_waived: false,
9890            },
9891        );
9892        assert!(decision.gate_write);
9893    }
9894
9895    #[test]
9896    fn prewrite_repair_can_start_before_any_write_attempt() {
9897        assert!(should_start_prewrite_repair_before_first_write(
9898            true, 0, false, false
9899        ));
9900        assert!(!should_start_prewrite_repair_before_first_write(
9901            true, 0, true, false
9902        ));
9903        assert!(!should_start_prewrite_repair_before_first_write(
9904            false, 0, false, false
9905        ));
9906        assert!(should_start_prewrite_repair_before_first_write(
9907            false, 0, false, true
9908        ));
9909    }
9910
9911    #[test]
9912    fn prewrite_repair_does_not_fire_after_first_write() {
9913        assert!(!should_start_prewrite_repair_before_first_write(
9914            true, 1, false, false
9915        ));
9916        assert!(!should_start_prewrite_repair_before_first_write(
9917            true, 2, false, true
9918        ));
9919    }
9920
9921    #[test]
9922    fn infer_code_workflow_from_text_detects_code_agent_contract() {
9923        let prompt = "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test";
9924        assert!(infer_code_workflow_from_text(prompt));
9925    }
9926
9927    #[test]
9928    fn infer_code_workflow_from_text_detects_source_target_path() {
9929        let prompt = "Required Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.";
9930        assert!(infer_code_workflow_from_text(prompt));
9931    }
9932
9933    #[test]
9934    fn required_tool_retry_context_for_task_adds_code_loop_guidance() {
9935        let prompt = build_required_tool_retry_context_for_task(
9936            "read, edit, apply_patch, bash",
9937            RequiredToolFailureKind::WriteRequiredNotSatisfied,
9938            "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test\nRequired Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.",
9939        );
9940        assert!(prompt.contains("inspect -> patch -> apply -> test -> repair"));
9941        assert!(prompt.contains("apply_patch"));
9942        assert!(prompt.contains("cargo test"));
9943        assert!(prompt.contains("src/lib.rs"));
9944    }
9945
9946    #[test]
9947    fn write_tool_removed_after_first_productive_write() {
9948        let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9949        let repair_on_unmet_requirements = true;
9950        let productive_write_tool_calls_total = 1usize;
9951        if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9952            offered.retain(|tool| !is_workspace_write_tool(tool));
9953        }
9954        assert_eq!(offered, vec!["glob", "read", "websearch", "write", "edit"]);
9955    }
9956
9957    #[test]
9958    fn write_tool_removed_after_third_productive_write() {
9959        let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9960        let repair_on_unmet_requirements = true;
9961        let productive_write_tool_calls_total = 3usize;
9962        if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9963            offered.retain(|tool| !is_workspace_write_tool(tool));
9964        }
9965        assert_eq!(offered, vec!["glob", "read", "websearch"]);
9966    }
9967
9968    #[test]
9969    fn force_write_only_retry_disabled_for_prewrite_repair_nodes() {
9970        let requested_write_required = true;
9971        let required_write_retry_count = 1usize;
9972        let productive_write_tool_calls_total = 0usize;
9973        let prewrite_satisfied = true;
9974        let prewrite_gate_write = false;
9975        let repair_on_unmet_requirements = true;
9976
9977        let force_write_only_retry = requested_write_required
9978            && required_write_retry_count > 0
9979            && (productive_write_tool_calls_total == 0 || prewrite_satisfied)
9980            && !prewrite_gate_write
9981            && !repair_on_unmet_requirements;
9982
9983        assert!(!force_write_only_retry);
9984    }
9985
9986    #[test]
9987    fn infer_required_output_target_path_reads_prompt_json_block() {
9988        let prompt = r#"Execute task.
9989
9990Required output target:
9991{
9992  "path": "src/game.html",
9993  "kind": "source",
9994  "operation": "create"
9995}
9996"#;
9997        assert_eq!(
9998            infer_required_output_target_path_from_text(prompt).as_deref(),
9999            Some("src/game.html")
10000        );
10001    }
10002
10003    #[test]
10004    fn infer_required_output_target_path_accepts_extensionless_target() {
10005        let prompt = r#"Execute task.
10006
10007Required output target:
10008{
10009  "path": "Dockerfile",
10010  "kind": "source",
10011  "operation": "create"
10012}
10013"#;
10014        assert_eq!(
10015            infer_required_output_target_path_from_text(prompt).as_deref(),
10016            Some("Dockerfile")
10017        );
10018    }
10019
10020    #[test]
10021    fn infer_write_file_path_from_text_rejects_workspace_root() {
10022        let prompt = "Workspace: /home/user123/game\nCreate the scaffold in the workspace now.";
10023        assert_eq!(infer_write_file_path_from_text(prompt), None);
10024    }
10025
10026    #[test]
10027    fn duplicate_signature_limit_defaults_to_200_for_general_tools_and_1_for_email_delivery() {
10028        let _guard = env_test_lock();
10029        unsafe {
10030            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
10031            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10032        }
10033        assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
10034        assert_eq!(duplicate_signature_limit_for("bash"), 200);
10035        assert_eq!(duplicate_signature_limit_for("write"), 200);
10036        assert_eq!(
10037            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10038            1
10039        );
10040        assert_eq!(
10041            duplicate_signature_limit_for("mcp.composio_1.gmail_create_email_draft"),
10042            1
10043        );
10044    }
10045
10046    #[test]
10047    fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
10048        let parsed = parse_streamed_tool_args("write", "path=game.html content");
10049        assert_ne!(parsed, json!({}));
10050    }
10051
10052    #[test]
10053    fn parse_streamed_tool_args_rejects_malformed_json_fragment_as_function_style() {
10054        let parsed = parse_streamed_tool_args("write", r#"{"allow_empty": null"#);
10055        assert_eq!(parsed, json!(r#"{"allow_empty": null"#));
10056    }
10057
10058    #[test]
10059    fn parse_streamed_tool_args_preserves_large_write_payload() {
10060        let content = "x".repeat(4096);
10061        let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
10062        let parsed = parse_streamed_tool_args("write", &raw_args);
10063        assert_eq!(
10064            parsed.get("path").and_then(|value| value.as_str()),
10065            Some("game.html")
10066        );
10067        assert_eq!(
10068            parsed.get("content").and_then(|value| value.as_str()),
10069            Some(content.as_str())
10070        );
10071    }
10072
10073    #[test]
10074    fn parse_streamed_tool_args_recovers_truncated_write_json() {
10075        let raw_args = concat!(
10076            r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
10077            r#"<html lang=\"en\"><body>Neon Drift"#
10078        );
10079        let parsed = parse_streamed_tool_args("write", raw_args);
10080        assert_eq!(
10081            parsed,
10082            json!({
10083                "path": "game.html",
10084                "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
10085            })
10086        );
10087    }
10088
10089    #[test]
10090    fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
10091        let raw_args = concat!(
10092            r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
10093            r#"<html lang=\"en\"><body>Neon Drift"#
10094        );
10095        let parsed = parse_streamed_tool_args("write", raw_args);
10096        assert_eq!(parsed.get("path"), None);
10097        assert_eq!(
10098            parsed.get("content").and_then(|value| value.as_str()),
10099            Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
10100        );
10101    }
10102
10103    #[test]
10104    fn duplicate_signature_limit_env_override_respects_minimum_floor() {
10105        let _guard = env_test_lock();
10106        unsafe {
10107            std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
10108            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10109        }
10110        assert_eq!(duplicate_signature_limit_for("write"), 200);
10111        assert_eq!(duplicate_signature_limit_for("bash"), 200);
10112        unsafe {
10113            std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
10114        }
10115        assert_eq!(duplicate_signature_limit_for("bash"), 250);
10116        unsafe {
10117            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
10118        }
10119    }
10120
10121    #[test]
10122    fn email_delivery_duplicate_signature_limit_env_override_respects_floor_of_one() {
10123        let _guard = env_test_lock();
10124        unsafe {
10125            std::env::set_var(
10126                "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
10127                "1",
10128            );
10129        }
10130        assert_eq!(
10131            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10132            1
10133        );
10134        unsafe {
10135            std::env::set_var(
10136                "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
10137                "3",
10138            );
10139        }
10140        assert_eq!(
10141            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
10142            3
10143        );
10144        unsafe {
10145            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
10146        }
10147    }
10148
10149    #[test]
10150    fn email_delivery_detection_is_provider_agnostic() {
10151        assert!(is_email_delivery_tool_name(
10152            "mcp.composio_1.gmail_send_email"
10153        ));
10154        assert!(is_email_delivery_tool_name("mcp.sendgrid.send_email"));
10155        assert!(is_email_delivery_tool_name("mcp.resend.create_email_draft"));
10156        assert!(is_email_delivery_tool_name("mcp.outlook.reply_email"));
10157        assert!(!is_email_delivery_tool_name("mcp.reddit.send_message"));
10158        assert!(!is_email_delivery_tool_name("mcp.github.create_issue"));
10159    }
10160
10161    #[test]
10162    fn websearch_duplicate_signature_limit_is_unset_by_default() {
10163        let _guard = env_test_lock();
10164        unsafe {
10165            std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
10166        }
10167        assert_eq!(websearch_duplicate_signature_limit(), None);
10168    }
10169
10170    #[test]
10171    fn websearch_duplicate_signature_limit_reads_env() {
10172        let _guard = env_test_lock();
10173        unsafe {
10174            std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
10175        }
10176        assert_eq!(websearch_duplicate_signature_limit(), Some(200));
10177        unsafe {
10178            std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
10179        }
10180        assert_eq!(websearch_duplicate_signature_limit(), Some(300));
10181        unsafe {
10182            std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
10183        }
10184    }
10185
10186    #[test]
10187    fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
10188        let outputs = vec![
10189            "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
10190            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
10191        ];
10192        let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
10193        assert!(summary.contains("Authorization is required before I can continue"));
10194        assert!(summary.contains("gmail_sendemail"));
10195        assert!(summary.contains("gmail_whoami"));
10196    }
10197
10198    #[test]
10199    fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
10200        let outputs = vec![
10201            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
10202            "Tool `read` result:\nok".to_string(),
10203        ];
10204        assert!(summarize_auth_pending_outputs(&outputs).is_none());
10205    }
10206
10207    #[test]
10208    fn invalid_tool_args_retry_context_handles_missing_bash_command() {
10209        let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
10210        let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
10211            .expect("retry expected");
10212        assert!(message.contains("required `command` field"));
10213        assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
10214    }
10215
10216    #[test]
10217    fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
10218        let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
10219        let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
10220            .expect("retry expected");
10221        assert!(message.contains("Do not repeat an empty bash call"));
10222    }
10223
10224    #[test]
10225    fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
10226        let outputs = vec!["Tool `read` result:\nok".to_string()];
10227        assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
10228    }
10229
10230    #[test]
10231    fn prewrite_repair_retry_context_prioritizes_research_tools_before_write() {
10232        let requirements = PrewriteRequirements {
10233            workspace_inspection_required: true,
10234            web_research_required: true,
10235            concrete_read_required: true,
10236            successful_web_research_required: true,
10237            repair_on_unmet_requirements: true,
10238            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10239        };
10240        let prompt = build_prewrite_repair_retry_context(
10241            "glob, read, websearch, write",
10242            RequiredToolFailureKind::WriteRequiredNotSatisfied,
10243            r#"Required output target:
10244{
10245  "path": "marketing-brief.md",
10246  "kind": "artifact"
10247}"#,
10248            &requirements,
10249            true,
10250            false,
10251            false,
10252            false,
10253        );
10254        assert!(prompt.contains("requires concrete `read` calls"));
10255        assert!(prompt.contains("call `websearch` with a concrete query now"));
10256        assert!(prompt.contains("Use `read` and `websearch` now to gather evidence"));
10257        assert!(prompt.contains("Do not declare the output blocked"));
10258        assert!(!prompt.contains("blocked-but-substantive artifact"));
10259        assert!(!prompt.contains("Your next response must be a `write` tool call"));
10260        assert!(!prompt.contains("Do not call `glob`, `read`, or `websearch` again"));
10261    }
10262
10263    #[test]
10264    fn empty_completion_retry_context_requires_write_when_prewrite_is_satisfied() {
10265        let requirements = PrewriteRequirements {
10266            workspace_inspection_required: true,
10267            web_research_required: false,
10268            concrete_read_required: true,
10269            successful_web_research_required: false,
10270            repair_on_unmet_requirements: true,
10271            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10272        };
10273        let prompt = build_empty_completion_retry_context(
10274            "glob, read, write",
10275            "Create or update `marketing-brief.md` relative to the workspace root.",
10276            &requirements,
10277            true,
10278            true,
10279            false,
10280            false,
10281        );
10282        assert!(prompt.contains("returned no final output"));
10283        assert!(prompt.contains("marketing-brief.md"));
10284        assert!(prompt.contains("must be a `write` tool call"));
10285    }
10286
10287    #[test]
10288    fn empty_completion_retry_context_mentions_missing_prewrite_work() {
10289        let requirements = PrewriteRequirements {
10290            workspace_inspection_required: true,
10291            web_research_required: true,
10292            concrete_read_required: true,
10293            successful_web_research_required: true,
10294            repair_on_unmet_requirements: true,
10295            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10296        };
10297        let prompt = build_empty_completion_retry_context(
10298            "glob, read, websearch, write",
10299            "Create or update `marketing-brief.md` relative to the workspace root.",
10300            &requirements,
10301            true,
10302            false,
10303            false,
10304            false,
10305        );
10306        assert!(prompt.contains("still need to use `read`"));
10307        assert!(prompt.contains("use `websearch`"));
10308        assert!(prompt.contains("After completing the missing requirement"));
10309    }
10310
10311    #[test]
10312    fn synthesize_artifact_write_completion_from_tool_state_marks_completed() {
10313        let completion = synthesize_artifact_write_completion_from_tool_state(
10314            "Create or update `marketing-brief.md` relative to the workspace root.",
10315            true,
10316            false,
10317        );
10318        assert!(completion.contains("wrote `marketing-brief.md`"));
10319        assert!(completion.contains("\"status\":\"completed\""));
10320        assert!(completion.contains("Runtime validation will verify"));
10321    }
10322
10323    #[test]
10324    fn synthesize_artifact_write_completion_from_tool_state_mentions_waived_evidence() {
10325        let completion = synthesize_artifact_write_completion_from_tool_state(
10326            "Create or update `marketing-brief.md` relative to the workspace root.",
10327            false,
10328            true,
10329        );
10330        assert!(completion.contains("waived in-run"));
10331        assert!(completion.contains("\"status\":\"completed\""));
10332    }
10333
10334    #[test]
10335    fn prewrite_repair_retry_budget_allows_five_repair_attempts() {
10336        assert_eq!(prewrite_repair_retry_max_attempts(), 5);
10337    }
10338
10339    #[test]
10340    fn prewrite_repair_tool_filter_removes_write_until_evidence_is_satisfied() {
10341        let offered = ["glob", "read", "websearch", "write", "edit"];
10342        let filtered = offered
10343            .iter()
10344            .copied()
10345            .filter(|tool| {
10346                tool_matches_unmet_prewrite_repair_requirement(
10347                    tool,
10348                    &[
10349                        "workspace_inspection_required",
10350                        "concrete_read_required",
10351                        "successful_web_research_required",
10352                    ],
10353                )
10354            })
10355            .collect::<Vec<_>>();
10356        assert_eq!(filtered, vec!["glob", "read", "websearch"]);
10357    }
10358
10359    #[test]
10360    fn prewrite_repair_tool_filter_restricts_to_glob_and_read_for_concrete_reads() {
10361        let offered = ["glob", "read", "search", "write"];
10362        let filtered = offered
10363            .iter()
10364            .copied()
10365            .filter(|tool| {
10366                tool_matches_unmet_prewrite_repair_requirement(tool, &["concrete_read_required"])
10367            })
10368            .collect::<Vec<_>>();
10369        assert_eq!(filtered, vec!["glob", "read"]);
10370    }
10371
10372    #[test]
10373    fn prewrite_repair_tool_filter_allows_glob_only_for_workspace_inspection() {
10374        let offered = ["glob", "read", "websearch", "write"];
10375        let with_inspection_unmet = offered
10376            .iter()
10377            .copied()
10378            .filter(|tool| {
10379                tool_matches_unmet_prewrite_repair_requirement(
10380                    tool,
10381                    &["workspace_inspection_required", "concrete_read_required"],
10382                )
10383            })
10384            .collect::<Vec<_>>();
10385        assert_eq!(with_inspection_unmet, vec!["glob", "read"]);
10386
10387        let without_inspection_unmet = offered
10388            .iter()
10389            .copied()
10390            .filter(|tool| {
10391                tool_matches_unmet_prewrite_repair_requirement(
10392                    tool,
10393                    &["concrete_read_required", "web_research_required"],
10394                )
10395            })
10396            .collect::<Vec<_>>();
10397        assert_eq!(without_inspection_unmet, vec!["glob", "read", "websearch"]);
10398    }
10399
10400    #[test]
10401    fn prewrite_repair_after_glob_restricts_to_glob_read_and_websearch() {
10402        let offered = ["glob", "read", "websearch", "write", "edit"];
10403        let filtered = offered
10404            .iter()
10405            .copied()
10406            .filter(|tool| {
10407                tool_matches_unmet_prewrite_repair_requirement(
10408                    tool,
10409                    &[
10410                        "concrete_read_required",
10411                        "successful_web_research_required",
10412                        "coverage_mode",
10413                    ],
10414                )
10415            })
10416            .collect::<Vec<_>>();
10417        assert_eq!(filtered, vec!["glob", "read", "websearch"]);
10418    }
10419
10420    #[test]
10421    fn prewrite_requirements_exhausted_completion_reports_structured_repair_state() {
10422        let message = prewrite_requirements_exhausted_completion(
10423            &["concrete_read_required", "successful_web_research_required"],
10424            2,
10425            0,
10426        );
10427        assert!(message.contains("PREWRITE_REQUIREMENTS_EXHAUSTED"));
10428        assert!(message.contains("\"status\":\"blocked\""));
10429        assert!(message.contains("\"repairAttempt\":2"));
10430        assert!(message.contains("\"repairAttemptsRemaining\":0"));
10431        assert!(message.contains("\"repairExhausted\":true"));
10432        assert!(message.contains("\"unmetRequirements\":[\"concrete_read_required\", \"successful_web_research_required\"]"));
10433    }
10434
10435    #[test]
10436    fn prewrite_waived_write_context_includes_unmet_codes() {
10437        let user_text = "Some task text without output target marker.";
10438        let unmet = vec!["concrete_read_required", "coverage_mode"];
10439        let ctx = build_prewrite_waived_write_context(user_text, &unmet);
10440        assert!(ctx.contains("could not be fully satisfied"));
10441        assert!(ctx.contains("concrete_read_required"));
10442        assert!(ctx.contains("coverage_mode"));
10443        assert!(ctx.contains("write"));
10444        assert!(ctx.contains("Do not write a blocked or placeholder file"));
10445    }
10446
10447    #[test]
10448    fn prewrite_waived_write_context_includes_output_path_when_present() {
10449        let user_text = "Required output target: {\"path\": \"marketing-brief.md\"}";
10450        let unmet = vec!["concrete_read_required"];
10451        let ctx = build_prewrite_waived_write_context(user_text, &unmet);
10452        assert!(ctx.contains("marketing-brief.md"));
10453        assert!(ctx.contains("`write`"));
10454    }
10455
10456    #[test]
10457    fn prewrite_gate_waived_disables_prewrite_gate_write() {
10458        let requirements = PrewriteRequirements {
10459            workspace_inspection_required: true,
10460            web_research_required: false,
10461            concrete_read_required: true,
10462            successful_web_research_required: false,
10463            repair_on_unmet_requirements: true,
10464            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10465        };
10466        let before = evaluate_prewrite_gate(
10467            true,
10468            &requirements,
10469            PrewriteProgress {
10470                productive_write_tool_calls_total: 0,
10471                productive_workspace_inspection_total: 0,
10472                productive_concrete_read_total: 0,
10473                productive_web_research_total: 0,
10474                successful_web_research_total: 0,
10475                required_write_retry_count: 0,
10476                unmet_prewrite_repair_retry_count: 0,
10477                prewrite_gate_waived: false,
10478            },
10479        );
10480        assert!(before.gate_write, "gate should be active before waiver");
10481        let after = evaluate_prewrite_gate(
10482            true,
10483            &requirements,
10484            PrewriteProgress {
10485                productive_write_tool_calls_total: 0,
10486                productive_workspace_inspection_total: 0,
10487                productive_concrete_read_total: 0,
10488                productive_web_research_total: 0,
10489                successful_web_research_total: 0,
10490                required_write_retry_count: 0,
10491                unmet_prewrite_repair_retry_count: 0,
10492                prewrite_gate_waived: true,
10493            },
10494        );
10495        assert!(!after.gate_write, "gate should be off after waiver");
10496    }
10497
10498    #[test]
10499    fn prewrite_gate_waived_disables_allow_repair_tools() {
10500        let requirements = PrewriteRequirements {
10501            workspace_inspection_required: true,
10502            web_research_required: true,
10503            concrete_read_required: true,
10504            successful_web_research_required: true,
10505            repair_on_unmet_requirements: true,
10506            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10507        };
10508        let before = evaluate_prewrite_gate(
10509            true,
10510            &requirements,
10511            PrewriteProgress {
10512                productive_write_tool_calls_total: 0,
10513                productive_workspace_inspection_total: 0,
10514                productive_concrete_read_total: 0,
10515                productive_web_research_total: 0,
10516                successful_web_research_total: 0,
10517                required_write_retry_count: 0,
10518                unmet_prewrite_repair_retry_count: 1,
10519                prewrite_gate_waived: false,
10520            },
10521        );
10522        assert!(
10523            before.allow_repair_tools,
10524            "repair tools should be active before waiver"
10525        );
10526        let after = evaluate_prewrite_gate(
10527            true,
10528            &requirements,
10529            PrewriteProgress {
10530                productive_write_tool_calls_total: 0,
10531                productive_workspace_inspection_total: 0,
10532                productive_concrete_read_total: 0,
10533                productive_web_research_total: 0,
10534                successful_web_research_total: 0,
10535                required_write_retry_count: 0,
10536                unmet_prewrite_repair_retry_count: 1,
10537                prewrite_gate_waived: true,
10538            },
10539        );
10540        assert!(
10541            !after.allow_repair_tools,
10542            "repair tools should be disabled after waiver"
10543        );
10544    }
10545
10546    #[test]
10547    fn force_write_only_enabled_after_prewrite_waiver() {
10548        let requirements = PrewriteRequirements {
10549            workspace_inspection_required: true,
10550            web_research_required: true,
10551            concrete_read_required: true,
10552            successful_web_research_required: true,
10553            repair_on_unmet_requirements: true,
10554            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10555        };
10556        let decision = evaluate_prewrite_gate(
10557            true,
10558            &requirements,
10559            PrewriteProgress {
10560                productive_write_tool_calls_total: 0,
10561                productive_workspace_inspection_total: 0,
10562                productive_concrete_read_total: 0,
10563                productive_web_research_total: 0,
10564                successful_web_research_total: 0,
10565                required_write_retry_count: 1,
10566                unmet_prewrite_repair_retry_count: 1,
10567                prewrite_gate_waived: true,
10568            },
10569        );
10570        assert!(
10571            decision.force_write_only_retry,
10572            "force_write_only should be active after prewrite waiver + write retry"
10573        );
10574    }
10575
10576    #[test]
10577    fn force_write_only_disabled_before_prewrite_waiver() {
10578        let requirements = PrewriteRequirements {
10579            workspace_inspection_required: true,
10580            web_research_required: true,
10581            concrete_read_required: true,
10582            successful_web_research_required: true,
10583            repair_on_unmet_requirements: true,
10584            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10585        };
10586        let decision = evaluate_prewrite_gate(
10587            true,
10588            &requirements,
10589            PrewriteProgress {
10590                productive_write_tool_calls_total: 0,
10591                productive_workspace_inspection_total: 0,
10592                productive_concrete_read_total: 0,
10593                productive_web_research_total: 0,
10594                successful_web_research_total: 0,
10595                required_write_retry_count: 1,
10596                unmet_prewrite_repair_retry_count: 1,
10597                prewrite_gate_waived: false,
10598            },
10599        );
10600        assert!(
10601            !decision.force_write_only_retry,
10602            "force_write_only should be disabled before waiver for prewrite nodes"
10603        );
10604    }
10605
10606    #[test]
10607    fn parse_budget_override_zero_disables_budget() {
10608        unsafe {
10609            std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
10610        }
10611        assert_eq!(
10612            parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
10613            Some(usize::MAX)
10614        );
10615        unsafe {
10616            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10617        }
10618    }
10619
10620    #[test]
10621    fn disable_tool_guard_budgets_env_overrides_all_budgets() {
10622        unsafe {
10623            std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10624            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10625        }
10626        assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), 1);
10627        // M-2: disabling guards now returns HARD_TOOL_CALL_CEILING, not usize::MAX,
10628        // because the hard ceiling cannot be bypassed by any env setting.
10629        assert_eq!(tool_budget_for("websearch"), HARD_TOOL_CALL_CEILING);
10630        unsafe {
10631            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10632        }
10633    }
10634
10635    #[test]
10636    fn email_delivery_budget_can_still_be_explicitly_overridden_when_global_budgets_are_disabled() {
10637        let _guard = env_test_lock();
10638        unsafe {
10639            std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10640            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "0");
10641        }
10642        assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
10643        unsafe {
10644            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10645            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10646        }
10647    }
10648
10649    #[test]
10650    fn tool_budget_defaults_to_200_calls_and_1_for_email_delivery() {
10651        let _guard = env_test_lock();
10652        unsafe {
10653            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10654            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10655            std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10656            std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
10657            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10658        }
10659        assert_eq!(tool_budget_for("bash"), 200);
10660        assert_eq!(tool_budget_for("websearch"), 200);
10661        assert_eq!(tool_budget_for("read"), 200);
10662        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10663        assert_eq!(
10664            tool_budget_for("mcp.composio_1.gmail_create_email_draft"),
10665            1
10666        );
10667    }
10668
10669    #[test]
10670    fn tool_budget_env_override_respects_minimum_floor() {
10671        let _guard = env_test_lock();
10672        unsafe {
10673            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10674            std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
10675            std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
10676            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10677        }
10678        assert_eq!(tool_budget_for("bash"), 200);
10679        assert_eq!(tool_budget_for("websearch"), 250);
10680        unsafe {
10681            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10682            std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10683        }
10684    }
10685
10686    #[test]
10687    fn email_delivery_tool_budget_env_override_respects_floor_of_one() {
10688        let _guard = env_test_lock();
10689        unsafe {
10690            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10691            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "1");
10692        }
10693        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10694        unsafe {
10695            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "5");
10696        }
10697        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 5);
10698        unsafe {
10699            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10700        }
10701    }
10702
10703    #[test]
10704    fn provider_agnostic_email_tools_share_single_send_budget() {
10705        let _guard = env_test_lock();
10706        unsafe {
10707            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10708            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10709        }
10710        assert_eq!(tool_budget_for("mcp.sendgrid.send_email"), 1);
10711        assert_eq!(tool_budget_for("mcp.resend.create_email_draft"), 1);
10712        assert_eq!(duplicate_signature_limit_for("mcp.outlook.reply_email"), 1);
10713    }
10714}