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                let preview = last_tool_outputs
2227                    .iter()
2228                    .take(3)
2229                    .map(|o| truncate_text(o, 240))
2230                    .collect::<Vec<_>>()
2231                    .join("\n");
2232                completion = format!(
2233                    "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
2234                    preview
2235                );
2236            }
2237            if completion.trim().is_empty() {
2238                completion =
2239                    "I couldn't produce a final response for that run. Please retry your request."
2240                        .to_string();
2241            }
2242            // M-3: Gate fires unconditionally when email was requested but no email
2243            // action tool was executed. The completion text is NOT consulted — this
2244            // prevents the model from bypassing the gate by rephrasing, and prevents
2245            // false positives on legitimate text containing email keywords.
2246            if email_delivery_requested && !email_action_executed {
2247                let mut fallback = "I could not verify that an email was sent in this run. I did not complete the delivery action."
2248                    .to_string();
2249                if let Some(note) = latest_email_action_note.as_ref() {
2250                    fallback.push_str("\n\nLast email tool status: ");
2251                    fallback.push_str(note);
2252                }
2253                fallback.push_str(
2254                    "\n\nPlease retry with an explicit available email tool (for example a draft, reply, or send MCP tool in your current connector set).",
2255                );
2256                completion = fallback;
2257            }
2258            completion = strip_model_control_markers(&completion);
2259            truncate_text(&completion, 16_000)
2260        };
2261        emit_event(
2262            Level::INFO,
2263            ProcessKind::Engine,
2264            ObservabilityEvent {
2265                event: "provider.call.finish",
2266                component: "engine.loop",
2267                correlation_id: correlation_ref,
2268                session_id: Some(&session_id),
2269                run_id: None,
2270                message_id: Some(&user_message_id),
2271                provider_id: Some(provider_id.as_str()),
2272                model_id,
2273                status: Some("ok"),
2274                error_code: None,
2275                detail: Some("provider stream complete"),
2276            },
2277        );
2278        if active_agent.name.eq_ignore_ascii_case("plan") {
2279            emit_plan_todo_fallback(
2280                self.storage.clone(),
2281                &self.event_bus,
2282                &session_id,
2283                &user_message_id,
2284                &completion,
2285            )
2286            .await;
2287            let todos_after_fallback = self.storage.get_todos(&session_id).await;
2288            if todos_after_fallback.is_empty() && !question_tool_used {
2289                emit_plan_question_fallback(
2290                    self.storage.clone(),
2291                    &self.event_bus,
2292                    &session_id,
2293                    &user_message_id,
2294                    &completion,
2295                )
2296                .await;
2297            }
2298        }
2299        if cancel.is_cancelled() {
2300            self.event_bus.publish(EngineEvent::new(
2301                "session.status",
2302                json!({"sessionID": session_id, "status":"cancelled"}),
2303            ));
2304            self.cancellations.remove(&session_id).await;
2305            return Ok(());
2306        }
2307        let assistant = Message::new(
2308            MessageRole::Assistant,
2309            vec![MessagePart::Text {
2310                text: completion.clone(),
2311            }],
2312        );
2313        let assistant_message_id = assistant.id.clone();
2314        self.storage.append_message(&session_id, assistant).await?;
2315        let final_part = WireMessagePart::text(
2316            &session_id,
2317            &assistant_message_id,
2318            truncate_text(&completion, 16_000),
2319        );
2320        self.event_bus.publish(EngineEvent::new(
2321            "message.part.updated",
2322            json!({"part": final_part}),
2323        ));
2324        self.event_bus.publish(EngineEvent::new(
2325            "session.updated",
2326            json!({"sessionID": session_id, "status":"idle"}),
2327        ));
2328        self.event_bus.publish(EngineEvent::new(
2329            "session.status",
2330            json!({"sessionID": session_id, "status":"idle"}),
2331        ));
2332        self.cancellations.remove(&session_id).await;
2333        Ok(())
2334    }
2335
2336    pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
2337        self.providers.default_complete(&prompt).await
2338    }
2339
2340    pub async fn run_oneshot_for_provider(
2341        &self,
2342        prompt: String,
2343        provider_id: Option<&str>,
2344    ) -> anyhow::Result<String> {
2345        self.providers
2346            .complete_for_provider(provider_id, &prompt, None)
2347            .await
2348    }
2349
2350    #[allow(clippy::too_many_arguments)]
2351    async fn execute_tool_with_permission(
2352        &self,
2353        session_id: &str,
2354        message_id: &str,
2355        tool: String,
2356        args: Value,
2357        initial_tool_call_id: Option<String>,
2358        equipped_skills: Option<&[String]>,
2359        latest_user_text: &str,
2360        write_required: bool,
2361        latest_assistant_context: Option<&str>,
2362        cancel: CancellationToken,
2363    ) -> anyhow::Result<Option<String>> {
2364        let tool = normalize_tool_name(&tool);
2365        let raw_args = args.clone();
2366        let publish_tool_effect = |tool_call_id: Option<&str>,
2367                                   phase: ToolEffectLedgerPhase,
2368                                   status: ToolEffectLedgerStatus,
2369                                   args: &Value,
2370                                   metadata: Option<&Value>,
2371                                   output: Option<&str>,
2372                                   error: Option<&str>| {
2373            self.event_bus
2374                .publish(tool_effect_ledger_event(build_tool_effect_ledger_record(
2375                    session_id,
2376                    message_id,
2377                    tool_call_id,
2378                    &tool,
2379                    phase,
2380                    status,
2381                    args,
2382                    metadata,
2383                    output,
2384                    error,
2385                )));
2386        };
2387        let normalized = normalize_tool_args_with_mode(
2388            &tool,
2389            args,
2390            latest_user_text,
2391            latest_assistant_context.unwrap_or_default(),
2392            if write_required {
2393                WritePathRecoveryMode::OutputTargetOnly
2394            } else {
2395                WritePathRecoveryMode::Heuristic
2396            },
2397        );
2398        let raw_args_preview = truncate_text(&raw_args.to_string(), 2_000);
2399        let normalized_args_preview = truncate_text(&normalized.args.to_string(), 2_000);
2400        self.event_bus.publish(EngineEvent::new(
2401            "tool.args.normalized",
2402            json!({
2403                "sessionID": session_id,
2404                "messageID": message_id,
2405                "tool": tool,
2406                "argsSource": normalized.args_source,
2407                "argsIntegrity": normalized.args_integrity,
2408                "rawArgsState": normalized.raw_args_state.as_str(),
2409                "rawArgsPreview": raw_args_preview,
2410                "normalizedArgsPreview": normalized_args_preview,
2411                "query": normalized.query,
2412                "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2413                "requestID": Value::Null
2414            }),
2415        ));
2416        if normalized.args_integrity == "recovered" {
2417            self.event_bus.publish(EngineEvent::new(
2418                "tool.args.recovered",
2419                json!({
2420                    "sessionID": session_id,
2421                    "messageID": message_id,
2422                    "tool": tool,
2423                    "argsSource": normalized.args_source,
2424                    "rawArgsPreview": raw_args_preview,
2425                    "normalizedArgsPreview": normalized_args_preview,
2426                    "query": normalized.query,
2427                    "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
2428                    "requestID": Value::Null
2429                }),
2430            ));
2431        }
2432        if normalized.missing_terminal {
2433            let missing_reason = normalized
2434                .missing_terminal_reason
2435                .clone()
2436                .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
2437            let latest_user_preview = truncate_text(latest_user_text, 500);
2438            let latest_assistant_preview =
2439                truncate_text(latest_assistant_context.unwrap_or_default(), 500);
2440            self.event_bus.publish(EngineEvent::new(
2441                "tool.args.missing_terminal",
2442                json!({
2443                    "sessionID": session_id,
2444                    "messageID": message_id,
2445                    "tool": tool,
2446                    "argsSource": normalized.args_source,
2447                    "argsIntegrity": normalized.args_integrity,
2448                    "rawArgsState": normalized.raw_args_state.as_str(),
2449                    "requestID": Value::Null,
2450                    "error": missing_reason,
2451                    "rawArgsPreview": raw_args_preview,
2452                    "normalizedArgsPreview": normalized_args_preview,
2453                    "latestUserPreview": latest_user_preview,
2454                    "latestAssistantPreview": latest_assistant_preview,
2455                }),
2456            ));
2457            if tool == "write" {
2458                tracing::warn!(
2459                    session_id = %session_id,
2460                    message_id = %message_id,
2461                    tool = %tool,
2462                    reason = %missing_reason,
2463                    args_source = %normalized.args_source,
2464                    args_integrity = %normalized.args_integrity,
2465                    raw_args_state = %normalized.raw_args_state.as_str(),
2466                    raw_args = %raw_args_preview,
2467                    normalized_args = %normalized_args_preview,
2468                    latest_user = %latest_user_preview,
2469                    latest_assistant = %latest_assistant_preview,
2470                    "write tool arguments missing terminal field"
2471                );
2472            }
2473            let best_effort_args = persisted_failed_tool_args(&raw_args, &normalized.args);
2474            let mut failed_part = WireMessagePart::tool_result(
2475                session_id,
2476                message_id,
2477                tool.clone(),
2478                Some(best_effort_args),
2479                json!(null),
2480            );
2481            failed_part.state = Some("failed".to_string());
2482            let surfaced_reason =
2483                provider_specific_write_reason(&tool, &missing_reason, normalized.raw_args_state)
2484                    .unwrap_or_else(|| missing_reason.clone());
2485            failed_part.error = Some(surfaced_reason.clone());
2486            self.event_bus.publish(EngineEvent::new(
2487                "message.part.updated",
2488                json!({"part": failed_part}),
2489            ));
2490            publish_tool_effect(
2491                None,
2492                ToolEffectLedgerPhase::Outcome,
2493                ToolEffectLedgerStatus::Blocked,
2494                &normalized.args,
2495                None,
2496                None,
2497                Some(&surfaced_reason),
2498            );
2499            return Ok(Some(surfaced_reason));
2500        }
2501
2502        let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
2503            Ok(args) => args,
2504            Err(message) => {
2505                publish_tool_effect(
2506                    None,
2507                    ToolEffectLedgerPhase::Outcome,
2508                    ToolEffectLedgerStatus::Blocked,
2509                    &raw_args,
2510                    None,
2511                    None,
2512                    Some(&message),
2513                );
2514                return Ok(Some(message));
2515            }
2516        };
2517        if let Some(allowed_tools) = self
2518            .session_allowed_tools
2519            .read()
2520            .await
2521            .get(session_id)
2522            .cloned()
2523        {
2524            if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &tool) {
2525                let reason = format!("Tool `{tool}` is not allowed for this run.");
2526                publish_tool_effect(
2527                    None,
2528                    ToolEffectLedgerPhase::Outcome,
2529                    ToolEffectLedgerStatus::Blocked,
2530                    &args,
2531                    None,
2532                    None,
2533                    Some(&reason),
2534                );
2535                return Ok(Some(reason));
2536            }
2537        }
2538        if let Some(hook) = self.tool_policy_hook.read().await.clone() {
2539            let decision = hook
2540                .evaluate_tool(ToolPolicyContext {
2541                    session_id: session_id.to_string(),
2542                    message_id: message_id.to_string(),
2543                    tool: tool.clone(),
2544                    args: args.clone(),
2545                })
2546                .await?;
2547            if !decision.allowed {
2548                let reason = decision
2549                    .reason
2550                    .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
2551                let mut blocked_part = WireMessagePart::tool_result(
2552                    session_id,
2553                    message_id,
2554                    tool.clone(),
2555                    Some(args.clone()),
2556                    json!(null),
2557                );
2558                blocked_part.state = Some("failed".to_string());
2559                blocked_part.error = Some(reason.clone());
2560                self.event_bus.publish(EngineEvent::new(
2561                    "message.part.updated",
2562                    json!({"part": blocked_part}),
2563                ));
2564                publish_tool_effect(
2565                    None,
2566                    ToolEffectLedgerPhase::Outcome,
2567                    ToolEffectLedgerStatus::Blocked,
2568                    &args,
2569                    None,
2570                    None,
2571                    Some(&reason),
2572                );
2573                return Ok(Some(reason));
2574            }
2575        }
2576        let mut tool_call_id: Option<String> = initial_tool_call_id;
2577        if let Some(violation) = self
2578            .workspace_sandbox_violation(session_id, &tool, &args)
2579            .await
2580        {
2581            let mut blocked_part = WireMessagePart::tool_result(
2582                session_id,
2583                message_id,
2584                tool.clone(),
2585                Some(args.clone()),
2586                json!(null),
2587            );
2588            blocked_part.state = Some("failed".to_string());
2589            blocked_part.error = Some(violation.clone());
2590            self.event_bus.publish(EngineEvent::new(
2591                "message.part.updated",
2592                json!({"part": blocked_part}),
2593            ));
2594            publish_tool_effect(
2595                tool_call_id.as_deref(),
2596                ToolEffectLedgerPhase::Outcome,
2597                ToolEffectLedgerStatus::Blocked,
2598                &args,
2599                None,
2600                None,
2601                Some(&violation),
2602            );
2603            return Ok(Some(violation));
2604        }
2605        let rule = self
2606            .plugins
2607            .permission_override(&tool)
2608            .await
2609            .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
2610        if matches!(rule, PermissionAction::Deny) {
2611            let reason = format!("Permission denied for tool `{tool}` by policy.");
2612            publish_tool_effect(
2613                tool_call_id.as_deref(),
2614                ToolEffectLedgerPhase::Outcome,
2615                ToolEffectLedgerStatus::Blocked,
2616                &args,
2617                None,
2618                None,
2619                Some(&reason),
2620            );
2621            return Ok(Some(reason));
2622        }
2623
2624        let mut effective_args = args.clone();
2625        if matches!(rule, PermissionAction::Ask) {
2626            let auto_approve_permissions = self
2627                .session_auto_approve_permissions
2628                .read()
2629                .await
2630                .get(session_id)
2631                .copied()
2632                .unwrap_or(false);
2633            if auto_approve_permissions {
2634                // Governance audit: if args were recovered via heuristics and the tool is
2635                // mutating, log a WARN so recovered writes are never silent in automation
2636                // mode. Does not block — operators must opt out via TANDEM_AUTO_APPROVE_RECOVERED_ARGS=false
2637                // if they want a hard block (reserved for strict automation policy).
2638                if normalized.args_integrity == "recovered" && is_workspace_write_tool(&tool) {
2639                    tracing::warn!(
2640                        session_id = %session_id,
2641                        message_id = %message_id,
2642                        tool = %tool,
2643                        args_source = %normalized.args_source,
2644                        "auto-approve granted for mutating tool with recovered args; verify intent"
2645                    );
2646                    self.event_bus.publish(EngineEvent::new(
2647                        "tool.args.recovered_write_auto_approved",
2648                        json!({
2649                            "sessionID": session_id,
2650                            "messageID": message_id,
2651                            "tool": tool,
2652                            "argsSource": normalized.args_source,
2653                            "argsIntegrity": normalized.args_integrity,
2654                        }),
2655                    ));
2656                }
2657                self.event_bus.publish(EngineEvent::new(
2658                    "permission.auto_approved",
2659                    json!({
2660                        "sessionID": session_id,
2661                        "messageID": message_id,
2662                        "tool": tool,
2663                    }),
2664                ));
2665                effective_args = args;
2666            } else {
2667                let pending = self
2668                    .permissions
2669                    .ask_for_session_with_context(
2670                        Some(session_id),
2671                        &tool,
2672                        args.clone(),
2673                        Some(crate::PermissionArgsContext {
2674                            args_source: normalized.args_source.clone(),
2675                            args_integrity: normalized.args_integrity.clone(),
2676                            query: normalized.query.clone(),
2677                        }),
2678                    )
2679                    .await;
2680                let mut pending_part = WireMessagePart::tool_invocation(
2681                    session_id,
2682                    message_id,
2683                    tool.clone(),
2684                    args.clone(),
2685                );
2686                pending_part.id = Some(pending.id.clone());
2687                tool_call_id = Some(pending.id.clone());
2688                pending_part.state = Some("pending".to_string());
2689                self.event_bus.publish(EngineEvent::new(
2690                    "message.part.updated",
2691                    json!({"part": pending_part}),
2692                ));
2693                let reply = self
2694                    .permissions
2695                    .wait_for_reply_with_timeout(
2696                        &pending.id,
2697                        cancel.clone(),
2698                        Some(Duration::from_millis(permission_wait_timeout_ms() as u64)),
2699                    )
2700                    .await;
2701                let (reply, timed_out) = reply;
2702                if cancel.is_cancelled() {
2703                    return Ok(None);
2704                }
2705                if timed_out {
2706                    let timeout_ms = permission_wait_timeout_ms();
2707                    self.event_bus.publish(EngineEvent::new(
2708                        "permission.wait.timeout",
2709                        json!({
2710                            "sessionID": session_id,
2711                            "messageID": message_id,
2712                            "tool": tool,
2713                            "requestID": pending.id,
2714                            "timeoutMs": timeout_ms,
2715                        }),
2716                    ));
2717                    let mut timeout_part = WireMessagePart::tool_result(
2718                        session_id,
2719                        message_id,
2720                        tool.clone(),
2721                        Some(args.clone()),
2722                        json!(null),
2723                    );
2724                    timeout_part.id = Some(pending.id);
2725                    timeout_part.state = Some("failed".to_string());
2726                    timeout_part.error = Some(format!(
2727                        "Permission request timed out after {} ms",
2728                        timeout_ms
2729                    ));
2730                    self.event_bus.publish(EngineEvent::new(
2731                        "message.part.updated",
2732                        json!({"part": timeout_part}),
2733                    ));
2734                    let timeout_reason = format!(
2735                        "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2736                    );
2737                    publish_tool_effect(
2738                        tool_call_id.as_deref(),
2739                        ToolEffectLedgerPhase::Outcome,
2740                        ToolEffectLedgerStatus::Blocked,
2741                        &args,
2742                        None,
2743                        None,
2744                        Some(&timeout_reason),
2745                    );
2746                    return Ok(Some(format!(
2747                        "Permission request for tool `{tool}` timed out after {timeout_ms} ms."
2748                    )));
2749                }
2750                let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
2751                if !approved {
2752                    let mut denied_part = WireMessagePart::tool_result(
2753                        session_id,
2754                        message_id,
2755                        tool.clone(),
2756                        Some(args.clone()),
2757                        json!(null),
2758                    );
2759                    denied_part.id = Some(pending.id);
2760                    denied_part.state = Some("denied".to_string());
2761                    denied_part.error = Some("Permission denied by user".to_string());
2762                    self.event_bus.publish(EngineEvent::new(
2763                        "message.part.updated",
2764                        json!({"part": denied_part}),
2765                    ));
2766                    let denied_reason = format!("Permission denied for tool `{tool}` by user.");
2767                    publish_tool_effect(
2768                        tool_call_id.as_deref(),
2769                        ToolEffectLedgerPhase::Outcome,
2770                        ToolEffectLedgerStatus::Blocked,
2771                        &args,
2772                        None,
2773                        None,
2774                        Some(&denied_reason),
2775                    );
2776                    return Ok(Some(format!(
2777                        "Permission denied for tool `{tool}` by user."
2778                    )));
2779                }
2780                effective_args = args;
2781            }
2782        }
2783
2784        let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
2785        let session = self.storage.get_session(session_id).await;
2786        if let (Some(obj), Some(session)) = (args.as_object_mut(), session.as_ref()) {
2787            obj.insert(
2788                "__session_id".to_string(),
2789                Value::String(session_id.to_string()),
2790            );
2791            if let Some(project_id) = session.project_id.clone() {
2792                obj.insert(
2793                    "__project_id".to_string(),
2794                    Value::String(project_id.clone()),
2795                );
2796                if project_id.starts_with("channel-public::") {
2797                    obj.insert(
2798                        "__memory_max_visible_scope".to_string(),
2799                        Value::String("project".to_string()),
2800                    );
2801                }
2802            }
2803        }
2804        let tool_context = self.resolve_tool_execution_context(session_id).await;
2805        if let Some((workspace_root, effective_cwd, project_id)) = tool_context.as_ref() {
2806            args = rewrite_workspace_alias_tool_args(&tool, args, workspace_root);
2807            if let Some(obj) = args.as_object_mut() {
2808                obj.insert(
2809                    "__workspace_root".to_string(),
2810                    Value::String(workspace_root.clone()),
2811                );
2812                obj.insert(
2813                    "__effective_cwd".to_string(),
2814                    Value::String(effective_cwd.clone()),
2815                );
2816                obj.insert(
2817                    "__session_id".to_string(),
2818                    Value::String(session_id.to_string()),
2819                );
2820                if let Some(project_id) = project_id.clone() {
2821                    obj.insert("__project_id".to_string(), Value::String(project_id));
2822                }
2823            }
2824            tracing::info!(
2825                "tool execution context session_id={} tool={} workspace_root={} effective_cwd={} project_id={}",
2826                session_id,
2827                tool,
2828                workspace_root,
2829                effective_cwd,
2830                project_id.clone().unwrap_or_default()
2831            );
2832        }
2833        let mut invoke_part =
2834            WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
2835        if let Some(call_id) = tool_call_id.clone() {
2836            invoke_part.id = Some(call_id);
2837        }
2838        let invoke_part_id = invoke_part.id.clone();
2839        self.event_bus.publish(EngineEvent::new(
2840            "message.part.updated",
2841            json!({"part": invoke_part}),
2842        ));
2843        let args_for_side_events = args.clone();
2844        let mutation_checkpoint = prepare_mutation_checkpoint(&tool, &args_for_side_events);
2845        let progress_sink: SharedToolProgressSink = std::sync::Arc::new(EngineToolProgressSink {
2846            event_bus: self.event_bus.clone(),
2847            session_id: session_id.to_string(),
2848            message_id: message_id.to_string(),
2849            tool_call_id: invoke_part_id.clone(),
2850            source_tool: tool.clone(),
2851        });
2852        publish_tool_effect(
2853            invoke_part_id.as_deref(),
2854            ToolEffectLedgerPhase::Invocation,
2855            ToolEffectLedgerStatus::Started,
2856            &args_for_side_events,
2857            None,
2858            None,
2859            None,
2860        );
2861        let publish_mutation_checkpoint =
2862            |tool_call_id: Option<&str>, outcome: MutationCheckpointOutcome| {
2863                if let Some(baseline) = mutation_checkpoint.as_ref() {
2864                    self.event_bus.publish(mutation_checkpoint_event(
2865                        finalize_mutation_checkpoint_record(
2866                            session_id,
2867                            message_id,
2868                            tool_call_id,
2869                            baseline,
2870                            outcome,
2871                        ),
2872                    ));
2873                }
2874            };
2875        if tool == "spawn_agent" {
2876            let hook = self.spawn_agent_hook.read().await.clone();
2877            if let Some(hook) = hook {
2878                let spawned = hook
2879                    .spawn_agent(SpawnAgentToolContext {
2880                        session_id: session_id.to_string(),
2881                        message_id: message_id.to_string(),
2882                        tool_call_id: invoke_part_id.clone(),
2883                        args: args_for_side_events.clone(),
2884                    })
2885                    .await?;
2886                let output = self.plugins.transform_tool_output(spawned.output).await;
2887                let output = truncate_text(&output, 16_000);
2888                emit_tool_side_events(
2889                    self.storage.clone(),
2890                    &self.event_bus,
2891                    ToolSideEventContext {
2892                        session_id,
2893                        message_id,
2894                        tool: &tool,
2895                        args: &args_for_side_events,
2896                        metadata: &spawned.metadata,
2897                        workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
2898                        effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
2899                    },
2900                )
2901                .await;
2902                let mut result_part = WireMessagePart::tool_result(
2903                    session_id,
2904                    message_id,
2905                    tool.clone(),
2906                    Some(args_for_side_events.clone()),
2907                    json!(output.clone()),
2908                );
2909                result_part.id = invoke_part_id.clone();
2910                self.event_bus.publish(EngineEvent::new(
2911                    "message.part.updated",
2912                    json!({"part": result_part}),
2913                ));
2914                publish_tool_effect(
2915                    invoke_part_id.as_deref(),
2916                    ToolEffectLedgerPhase::Outcome,
2917                    ToolEffectLedgerStatus::Succeeded,
2918                    &args_for_side_events,
2919                    Some(&spawned.metadata),
2920                    Some(&output),
2921                    None,
2922                );
2923                publish_mutation_checkpoint(
2924                    invoke_part_id.as_deref(),
2925                    MutationCheckpointOutcome::Succeeded,
2926                );
2927                return Ok(Some(truncate_text(
2928                    &format!("Tool `{tool}` result:\n{output}"),
2929                    16_000,
2930                )));
2931            }
2932            let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
2933            let mut failed_part = WireMessagePart::tool_result(
2934                session_id,
2935                message_id,
2936                tool.clone(),
2937                Some(args_for_side_events.clone()),
2938                json!(null),
2939            );
2940            failed_part.id = invoke_part_id.clone();
2941            failed_part.state = Some("failed".to_string());
2942            failed_part.error = Some(output.to_string());
2943            self.event_bus.publish(EngineEvent::new(
2944                "message.part.updated",
2945                json!({"part": failed_part}),
2946            ));
2947            publish_tool_effect(
2948                invoke_part_id.as_deref(),
2949                ToolEffectLedgerPhase::Outcome,
2950                ToolEffectLedgerStatus::Failed,
2951                &args_for_side_events,
2952                None,
2953                None,
2954                Some(output),
2955            );
2956            publish_mutation_checkpoint(
2957                invoke_part_id.as_deref(),
2958                MutationCheckpointOutcome::Failed,
2959            );
2960            return Ok(Some(output.to_string()));
2961        }
2962        // Batch governance: validate sub-calls against engine policy and inject execution context
2963        // before delegating to BatchTool. This ensures sub-calls cannot bypass permissions,
2964        // sandbox checks, or allowed-tool lists, and that they receive the correct workspace
2965        // context (__workspace_root, __effective_cwd, __session_id, __project_id).
2966        //
2967        // By this point `args` already has those keys injected (see context injection above).
2968        if tool == "batch" {
2969            let allowed_tools = self
2970                .session_allowed_tools
2971                .read()
2972                .await
2973                .get(session_id)
2974                .cloned()
2975                .unwrap_or_default();
2976
2977            // Extract parent execution context from already-injected batch args.
2978            let ctx_workspace_root = args
2979                .get("__workspace_root")
2980                .and_then(|v| v.as_str())
2981                .map(ToString::to_string);
2982            let ctx_effective_cwd = args
2983                .get("__effective_cwd")
2984                .and_then(|v| v.as_str())
2985                .map(ToString::to_string);
2986            let ctx_session_id = args
2987                .get("__session_id")
2988                .and_then(|v| v.as_str())
2989                .map(ToString::to_string);
2990            let ctx_project_id = args
2991                .get("__project_id")
2992                .and_then(|v| v.as_str())
2993                .map(ToString::to_string);
2994
2995            // Process each sub-call: check governance, inject context.
2996            let raw_calls = args
2997                .get("tool_calls")
2998                .and_then(|v| v.as_array())
2999                .cloned()
3000                .unwrap_or_default();
3001
3002            let mut governed_calls: Vec<Value> = Vec::new();
3003            for mut call in raw_calls {
3004                let (sub_tool, mut sub_args) = {
3005                    let obj = match call.as_object() {
3006                        Some(o) => o,
3007                        None => {
3008                            governed_calls.push(call);
3009                            continue;
3010                        }
3011                    };
3012                    let tool_raw = non_empty_string_at(obj, "tool")
3013                        .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3014                        .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3015                        .or_else(|| non_empty_string_at(obj, "name"));
3016                    let sub_tool = match tool_raw {
3017                        Some(t) => normalize_tool_name(t),
3018                        None => {
3019                            governed_calls.push(call);
3020                            continue;
3021                        }
3022                    };
3023                    let sub_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3024                    (sub_tool, sub_args)
3025                };
3026
3027                // 1. Allowed-tools check.
3028                if !allowed_tools.is_empty() && !any_policy_matches(&allowed_tools, &sub_tool) {
3029                    // Strip this sub-call: replace it with an explanatory result.
3030                    if let Some(obj) = call.as_object_mut() {
3031                        obj.insert(
3032                            "_blocked".to_string(),
3033                            Value::String(format!(
3034                                "batch sub-call skipped: tool `{sub_tool}` is not in the allowed list for this run"
3035                            )),
3036                        );
3037                    }
3038                    governed_calls.push(call);
3039                    continue;
3040                }
3041
3042                // 2. Workspace sandbox check.
3043                if let Some(violation) = self
3044                    .workspace_sandbox_violation(session_id, &sub_tool, &sub_args)
3045                    .await
3046                {
3047                    if let Some(obj) = call.as_object_mut() {
3048                        obj.insert(
3049                            "_blocked".to_string(),
3050                            Value::String(format!("batch sub-call skipped: {violation}")),
3051                        );
3052                    }
3053                    governed_calls.push(call);
3054                    continue;
3055                }
3056
3057                // 3. Inject parent execution context into sub-call args.
3058                if let Some(sub_obj) = sub_args.as_object_mut() {
3059                    if let Some(ref v) = ctx_workspace_root {
3060                        sub_obj
3061                            .entry("__workspace_root")
3062                            .or_insert_with(|| Value::String(v.clone()));
3063                    }
3064                    if let Some(ref v) = ctx_effective_cwd {
3065                        sub_obj
3066                            .entry("__effective_cwd")
3067                            .or_insert_with(|| Value::String(v.clone()));
3068                    }
3069                    if let Some(ref v) = ctx_session_id {
3070                        sub_obj
3071                            .entry("__session_id")
3072                            .or_insert_with(|| Value::String(v.clone()));
3073                    }
3074                    if let Some(ref v) = ctx_project_id {
3075                        sub_obj
3076                            .entry("__project_id")
3077                            .or_insert_with(|| Value::String(v.clone()));
3078                    }
3079                }
3080
3081                // Write enriched args back into the call object.
3082                if let Some(obj) = call.as_object_mut() {
3083                    obj.insert("args".to_string(), sub_args);
3084                }
3085                governed_calls.push(call);
3086            }
3087
3088            // Rebuild batch args with the governed sub-calls.
3089            if let Some(obj) = args.as_object_mut() {
3090                obj.insert("tool_calls".to_string(), Value::Array(governed_calls));
3091            }
3092        }
3093        let result = match self
3094            .execute_tool_with_timeout(&tool, args, cancel.clone(), Some(progress_sink))
3095            .await
3096        {
3097            Ok(result) => result,
3098            Err(err) => {
3099                let err_text = err.to_string();
3100                if err_text.contains("TOOL_EXEC_TIMEOUT_MS_EXCEEDED(") {
3101                    let timeout_ms = tool_exec_timeout_ms();
3102                    let timeout_output = format!(
3103                        "Tool `{tool}` timed out after {timeout_ms} ms. It was stopped to keep this run responsive."
3104                    );
3105                    let mut failed_part = WireMessagePart::tool_result(
3106                        session_id,
3107                        message_id,
3108                        tool.clone(),
3109                        Some(args_for_side_events.clone()),
3110                        json!(null),
3111                    );
3112                    failed_part.id = invoke_part_id.clone();
3113                    failed_part.state = Some("failed".to_string());
3114                    failed_part.error = Some(timeout_output.clone());
3115                    self.event_bus.publish(EngineEvent::new(
3116                        "message.part.updated",
3117                        json!({"part": failed_part}),
3118                    ));
3119                    publish_tool_effect(
3120                        invoke_part_id.as_deref(),
3121                        ToolEffectLedgerPhase::Outcome,
3122                        ToolEffectLedgerStatus::Failed,
3123                        &args_for_side_events,
3124                        None,
3125                        None,
3126                        Some(&timeout_output),
3127                    );
3128                    publish_mutation_checkpoint(
3129                        invoke_part_id.as_deref(),
3130                        MutationCheckpointOutcome::Failed,
3131                    );
3132                    return Ok(Some(timeout_output));
3133                }
3134                if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
3135                    self.event_bus.publish(EngineEvent::new(
3136                        "mcp.auth.required",
3137                        json!({
3138                            "sessionID": session_id,
3139                            "messageID": message_id,
3140                            "tool": tool.clone(),
3141                            "server": auth.server,
3142                            "authorizationUrl": auth.authorization_url,
3143                            "message": auth.message,
3144                            "challengeId": auth.challenge_id
3145                        }),
3146                    ));
3147                    let auth_output = format!(
3148                        "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3149                        tool, auth.message, auth.authorization_url
3150                    );
3151                    let mut result_part = WireMessagePart::tool_result(
3152                        session_id,
3153                        message_id,
3154                        tool.clone(),
3155                        Some(args_for_side_events.clone()),
3156                        json!(auth_output.clone()),
3157                    );
3158                    result_part.id = invoke_part_id.clone();
3159                    self.event_bus.publish(EngineEvent::new(
3160                        "message.part.updated",
3161                        json!({"part": result_part}),
3162                    ));
3163                    publish_tool_effect(
3164                        invoke_part_id.as_deref(),
3165                        ToolEffectLedgerPhase::Outcome,
3166                        ToolEffectLedgerStatus::Blocked,
3167                        &args_for_side_events,
3168                        None,
3169                        Some(&auth_output),
3170                        Some(&auth.message),
3171                    );
3172                    publish_mutation_checkpoint(
3173                        invoke_part_id.as_deref(),
3174                        MutationCheckpointOutcome::Blocked,
3175                    );
3176                    return Ok(Some(truncate_text(
3177                        &format!("Tool `{tool}` result:\n{auth_output}"),
3178                        16_000,
3179                    )));
3180                }
3181                let mut failed_part = WireMessagePart::tool_result(
3182                    session_id,
3183                    message_id,
3184                    tool.clone(),
3185                    Some(args_for_side_events.clone()),
3186                    json!(null),
3187                );
3188                failed_part.id = invoke_part_id.clone();
3189                failed_part.state = Some("failed".to_string());
3190                failed_part.error = Some(err_text.clone());
3191                self.event_bus.publish(EngineEvent::new(
3192                    "message.part.updated",
3193                    json!({"part": failed_part}),
3194                ));
3195                publish_tool_effect(
3196                    invoke_part_id.as_deref(),
3197                    ToolEffectLedgerPhase::Outcome,
3198                    ToolEffectLedgerStatus::Failed,
3199                    &args_for_side_events,
3200                    None,
3201                    None,
3202                    Some(&err_text),
3203                );
3204                publish_mutation_checkpoint(
3205                    invoke_part_id.as_deref(),
3206                    MutationCheckpointOutcome::Failed,
3207                );
3208                return Err(err);
3209            }
3210        };
3211        if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3212            let event_name = if auth.pending && auth.blocked {
3213                "mcp.auth.pending"
3214            } else {
3215                "mcp.auth.required"
3216            };
3217            self.event_bus.publish(EngineEvent::new(
3218                event_name,
3219                json!({
3220                    "sessionID": session_id,
3221                    "messageID": message_id,
3222                    "tool": tool.clone(),
3223                    "server": auth.server,
3224                    "authorizationUrl": auth.authorization_url,
3225                    "message": auth.message,
3226                    "challengeId": auth.challenge_id,
3227                    "pending": auth.pending,
3228                    "blocked": auth.blocked,
3229                    "retryAfterMs": auth.retry_after_ms
3230                }),
3231            ));
3232        }
3233        emit_tool_side_events(
3234            self.storage.clone(),
3235            &self.event_bus,
3236            ToolSideEventContext {
3237                session_id,
3238                message_id,
3239                tool: &tool,
3240                args: &args_for_side_events,
3241                metadata: &result.metadata,
3242                workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
3243                effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
3244            },
3245        )
3246        .await;
3247        let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
3248            if auth.pending && auth.blocked {
3249                let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
3250                format!(
3251                    "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
3252                    tool, auth.message, auth.authorization_url, retry_after_secs
3253                )
3254            } else {
3255                format!(
3256                    "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
3257                    tool, auth.message, auth.authorization_url
3258                )
3259            }
3260        } else {
3261            self.plugins.transform_tool_output(result.output).await
3262        };
3263        let output = truncate_text(&output, 16_000);
3264        let mut result_part = WireMessagePart::tool_result(
3265            session_id,
3266            message_id,
3267            tool.clone(),
3268            Some(args_for_side_events.clone()),
3269            json!(output.clone()),
3270        );
3271        result_part.id = invoke_part_id.clone();
3272        self.event_bus.publish(EngineEvent::new(
3273            "message.part.updated",
3274            json!({"part": result_part}),
3275        ));
3276        publish_tool_effect(
3277            invoke_part_id.as_deref(),
3278            ToolEffectLedgerPhase::Outcome,
3279            ToolEffectLedgerStatus::Succeeded,
3280            &args_for_side_events,
3281            Some(&result.metadata),
3282            Some(&output),
3283            None,
3284        );
3285        publish_mutation_checkpoint(
3286            invoke_part_id.as_deref(),
3287            MutationCheckpointOutcome::Succeeded,
3288        );
3289        Ok(Some(truncate_text(
3290            &format!("Tool `{tool}` result:\n{output}"),
3291            16_000,
3292        )))
3293    }
3294
3295    async fn execute_tool_with_timeout(
3296        &self,
3297        tool: &str,
3298        args: Value,
3299        cancel: CancellationToken,
3300        progress: Option<SharedToolProgressSink>,
3301    ) -> anyhow::Result<tandem_types::ToolResult> {
3302        let timeout_ms = tool_exec_timeout_ms() as u64;
3303        match tokio::time::timeout(
3304            Duration::from_millis(timeout_ms),
3305            self.tools
3306                .execute_with_cancel_and_progress(tool, args, cancel, progress),
3307        )
3308        .await
3309        {
3310            Ok(result) => result,
3311            Err(_) => anyhow::bail!("TOOL_EXEC_TIMEOUT_MS_EXCEEDED({timeout_ms})"),
3312        }
3313    }
3314
3315    async fn find_recent_matching_user_message_id(
3316        &self,
3317        session_id: &str,
3318        text: &str,
3319    ) -> Option<String> {
3320        let session = self.storage.get_session(session_id).await?;
3321        let last = session.messages.last()?;
3322        if !matches!(last.role, MessageRole::User) {
3323            return None;
3324        }
3325        let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
3326        if age_ms > 10_000 {
3327            return None;
3328        }
3329        let last_text = last
3330            .parts
3331            .iter()
3332            .filter_map(|part| match part {
3333                MessagePart::Text { text } => Some(text.clone()),
3334                _ => None,
3335            })
3336            .collect::<Vec<_>>()
3337            .join("\n");
3338        if last_text == text {
3339            return Some(last.id.clone());
3340        }
3341        None
3342    }
3343
3344    async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
3345        let Some(mut session) = self.storage.get_session(session_id).await else {
3346            return;
3347        };
3348        if !title_needs_repair(&session.title) {
3349            return;
3350        }
3351
3352        let first_user_text = session.messages.iter().find_map(|message| {
3353            if !matches!(message.role, MessageRole::User) {
3354                return None;
3355            }
3356            message.parts.iter().find_map(|part| match part {
3357                MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
3358                _ => None,
3359            })
3360        });
3361
3362        let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
3363        let Some(title) = derive_session_title_from_prompt(&source, 60) else {
3364            return;
3365        };
3366
3367        session.title = title;
3368        session.time.updated = Utc::now();
3369        let _ = self.storage.save_session(session).await;
3370    }
3371
3372    async fn workspace_sandbox_violation(
3373        &self,
3374        session_id: &str,
3375        tool: &str,
3376        args: &Value,
3377    ) -> Option<String> {
3378        if self.workspace_override_active(session_id).await {
3379            return None;
3380        }
3381        // MCP tools: apply sandbox only if they supply path-like arguments.
3382        // Purely API-based MCP tools (no path args) are allowed through.
3383        // Operators can exempt specific MCP servers via TANDEM_MCP_SANDBOX_EXEMPT_SERVERS.
3384        if is_mcp_tool_name(tool) {
3385            if let Some(server) = mcp_server_from_tool_name(tool) {
3386                if is_mcp_sandbox_exempt_server(server) {
3387                    return None;
3388                }
3389            }
3390            let candidate_paths = extract_tool_candidate_paths(tool, args);
3391            if candidate_paths.is_empty() {
3392                // No path arguments — this is a remote/API-only call; allow it.
3393                return None;
3394            }
3395            // Has path args — apply workspace containment to those paths.
3396            let session = self.storage.get_session(session_id).await?;
3397            let workspace = session
3398                .workspace_root
3399                .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3400            let workspace_path = PathBuf::from(&workspace);
3401            if let Some(sensitive) = candidate_paths.iter().find(|path| {
3402                let raw = Path::new(path);
3403                let resolved = if raw.is_absolute() {
3404                    raw.to_path_buf()
3405                } else {
3406                    workspace_path.join(raw)
3407                };
3408                is_sensitive_path_candidate(&resolved)
3409            }) {
3410                return Some(format!(
3411                    "Sandbox blocked MCP tool `{tool}` path `{sensitive}` (sensitive path policy)."
3412                ));
3413            }
3414            let outside = candidate_paths.iter().find(|path| {
3415                let raw = Path::new(path);
3416                let resolved = if raw.is_absolute() {
3417                    raw.to_path_buf()
3418                } else {
3419                    workspace_path.join(raw)
3420                };
3421                !crate::is_within_workspace_root(&resolved, &workspace_path)
3422            })?;
3423            return Some(format!(
3424                "Sandbox blocked MCP tool `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3425            ));
3426        }
3427        let session = self.storage.get_session(session_id).await?;
3428        let workspace = session
3429            .workspace_root
3430            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3431        let workspace_path = PathBuf::from(&workspace);
3432        let candidate_paths = extract_tool_candidate_paths(tool, args);
3433        if candidate_paths.is_empty() {
3434            if is_shell_tool_name(tool) {
3435                if let Some(command) = extract_shell_command(args) {
3436                    if shell_command_targets_sensitive_path(&command) {
3437                        return Some(format!(
3438                            "Sandbox blocked `{tool}` command targeting sensitive paths."
3439                        ));
3440                    }
3441                }
3442            }
3443            return None;
3444        }
3445        if let Some(sensitive) = candidate_paths.iter().find(|path| {
3446            let raw = Path::new(path);
3447            let resolved = if raw.is_absolute() {
3448                raw.to_path_buf()
3449            } else {
3450                workspace_path.join(raw)
3451            };
3452            is_sensitive_path_candidate(&resolved)
3453        }) {
3454            return Some(format!(
3455                "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
3456            ));
3457        }
3458
3459        let outside = candidate_paths.iter().find(|path| {
3460            let raw = Path::new(path);
3461            let resolved = if raw.is_absolute() {
3462                raw.to_path_buf()
3463            } else {
3464                workspace_path.join(raw)
3465            };
3466            !crate::is_within_workspace_root(&resolved, &workspace_path)
3467        })?;
3468        Some(format!(
3469            "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
3470        ))
3471    }
3472
3473    async fn resolve_tool_execution_context(
3474        &self,
3475        session_id: &str,
3476    ) -> Option<(String, String, Option<String>)> {
3477        let session = self.storage.get_session(session_id).await?;
3478        let workspace_root = session
3479            .workspace_root
3480            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
3481        let effective_cwd = if session.directory.trim().is_empty()
3482            || session.directory.trim() == "."
3483        {
3484            workspace_root.clone()
3485        } else {
3486            crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
3487        };
3488        let project_id = session
3489            .project_id
3490            .clone()
3491            .or_else(|| crate::workspace_project_id(&workspace_root));
3492        Some((workspace_root, effective_cwd, project_id))
3493    }
3494
3495    async fn workspace_override_active(&self, session_id: &str) -> bool {
3496        let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
3497        let mut overrides = self.workspace_overrides.write().await;
3498        // Collect expired session IDs for audit events before pruning.
3499        let expired: Vec<String> = overrides
3500            .iter()
3501            .filter_map(|(id, &exp)| if exp <= now { Some(id.clone()) } else { None })
3502            .collect();
3503        overrides.retain(|_, expires_at| *expires_at > now);
3504        drop(overrides);
3505        for expired_id in expired {
3506            self.event_bus.publish(EngineEvent::new(
3507                "workspace.override.expired",
3508                json!({ "sessionID": expired_id }),
3509            ));
3510        }
3511        self.workspace_overrides
3512            .read()
3513            .await
3514            .get(session_id)
3515            .map(|expires_at| *expires_at > now)
3516            .unwrap_or(false)
3517    }
3518
3519    async fn generate_final_narrative_without_tools(
3520        &self,
3521        session_id: &str,
3522        active_agent: &AgentDefinition,
3523        provider_hint: Option<&str>,
3524        model_id: Option<&str>,
3525        cancel: CancellationToken,
3526        tool_outputs: &[String],
3527    ) -> Option<String> {
3528        if cancel.is_cancelled() {
3529            return None;
3530        }
3531        let mut messages = load_chat_history(
3532            self.storage.clone(),
3533            session_id,
3534            ChatHistoryProfile::Standard,
3535        )
3536        .await;
3537        let mut system_parts = vec![tandem_runtime_system_prompt(
3538            &self.host_runtime_context,
3539            &[],
3540        )];
3541        if let Some(system) = active_agent.system_prompt.as_ref() {
3542            system_parts.push(system.clone());
3543        }
3544        messages.insert(
3545            0,
3546            ChatMessage {
3547                role: "system".to_string(),
3548                content: system_parts.join("\n\n"),
3549                attachments: Vec::new(),
3550            },
3551        );
3552        messages.push(ChatMessage {
3553            role: "user".to_string(),
3554            content: build_post_tool_final_narrative_prompt(tool_outputs),
3555            attachments: Vec::new(),
3556        });
3557        let stream = self
3558            .providers
3559            .stream_for_provider(
3560                provider_hint,
3561                model_id,
3562                messages,
3563                ToolMode::None,
3564                None,
3565                cancel.clone(),
3566            )
3567            .await
3568            .ok()?;
3569        tokio::pin!(stream);
3570        let mut completion = String::new();
3571        while let Some(chunk) = stream.next().await {
3572            if cancel.is_cancelled() {
3573                return None;
3574            }
3575            match chunk {
3576                Ok(StreamChunk::TextDelta(delta)) => {
3577                    let delta = strip_model_control_markers(&delta);
3578                    if !delta.trim().is_empty() {
3579                        completion.push_str(&delta);
3580                    }
3581                }
3582                Ok(StreamChunk::Done { .. }) => break,
3583                Ok(_) => {}
3584                Err(_) => return None,
3585            }
3586        }
3587        let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
3588        if completion.trim().is_empty() {
3589            None
3590        } else {
3591            Some(completion)
3592        }
3593    }
3594}
3595
3596fn resolve_model_route(
3597    request_model: Option<&ModelSpec>,
3598    session_model: Option<&ModelSpec>,
3599) -> Option<(String, String)> {
3600    fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
3601        let provider_id = spec.provider_id.trim();
3602        let model_id = spec.model_id.trim();
3603        if provider_id.is_empty() || model_id.is_empty() {
3604            return None;
3605        }
3606        Some((provider_id.to_string(), model_id.to_string()))
3607    }
3608
3609    request_model
3610        .and_then(normalize)
3611        .or_else(|| session_model.and_then(normalize))
3612}
3613
3614fn strip_model_control_markers(input: &str) -> String {
3615    let mut cleaned = input.to_string();
3616    for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
3617        if cleaned.contains(marker) {
3618            cleaned = cleaned.replace(marker, "");
3619        }
3620    }
3621    cleaned
3622}
3623
3624fn truncate_text(input: &str, max_len: usize) -> String {
3625    if input.len() <= max_len {
3626        return input.to_string();
3627    }
3628    let mut out = input[..max_len].to_string();
3629    out.push_str("...<truncated>");
3630    out
3631}
3632
3633fn build_post_tool_final_narrative_prompt(tool_outputs: &[String]) -> String {
3634    format!(
3635        "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.",
3636        summarize_tool_outputs(tool_outputs)
3637    )
3638}
3639
3640fn provider_error_code(error_text: &str) -> &'static str {
3641    let lower = error_text.to_lowercase();
3642    if lower.contains("invalid_function_parameters")
3643        || lower.contains("array schema missing items")
3644        || lower.contains("tool schema")
3645    {
3646        return "TOOL_SCHEMA_INVALID";
3647    }
3648    if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
3649    {
3650        return "RATE_LIMIT_EXCEEDED";
3651    }
3652    if lower.contains("context length")
3653        || lower.contains("max tokens")
3654        || lower.contains("token limit")
3655    {
3656        return "CONTEXT_LENGTH_EXCEEDED";
3657    }
3658    if lower.contains("unauthorized")
3659        || lower.contains("authentication")
3660        || lower.contains("401")
3661        || lower.contains("403")
3662    {
3663        return "AUTHENTICATION_ERROR";
3664    }
3665    if lower.contains("timeout") || lower.contains("timed out") {
3666        return "TIMEOUT";
3667    }
3668    if lower.contains("server error")
3669        || lower.contains("500")
3670        || lower.contains("502")
3671        || lower.contains("503")
3672        || lower.contains("504")
3673    {
3674        return "PROVIDER_SERVER_ERROR";
3675    }
3676    "PROVIDER_REQUEST_FAILED"
3677}
3678
3679fn normalize_tool_name(name: &str) -> String {
3680    let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
3681    for prefix in [
3682        "default_api:",
3683        "default_api.",
3684        "functions.",
3685        "function.",
3686        "tools.",
3687        "tool.",
3688        "builtin:",
3689        "builtin.",
3690    ] {
3691        if let Some(rest) = normalized.strip_prefix(prefix) {
3692            let trimmed = rest.trim();
3693            if !trimmed.is_empty() {
3694                normalized = trimmed.to_string();
3695                break;
3696            }
3697        }
3698    }
3699    match normalized.as_str() {
3700        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
3701        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
3702        other => other.to_string(),
3703    }
3704}
3705
3706fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
3707    let mut parts = tool_name.split('.');
3708    let prefix = parts.next()?;
3709    if prefix != "mcp" {
3710        return None;
3711    }
3712    parts.next().filter(|server| !server.is_empty())
3713}
3714
3715fn requires_web_research_prompt(input: &str) -> bool {
3716    let lower = input.to_ascii_lowercase();
3717    [
3718        "research",
3719        "top news",
3720        "today's news",
3721        "todays news",
3722        "with links",
3723        "latest headlines",
3724        "current events",
3725    ]
3726    .iter()
3727    .any(|needle| lower.contains(needle))
3728}
3729
3730fn requires_email_delivery_prompt(input: &str) -> bool {
3731    let lower = input.to_ascii_lowercase();
3732    (lower.contains("send") && lower.contains("email"))
3733        || (lower.contains("send") && lower.contains('@') && lower.contains("to"))
3734        || lower.contains("email to")
3735}
3736
3737fn has_web_research_tools(schemas: &[ToolSchema]) -> bool {
3738    schemas.iter().any(|schema| {
3739        let name = normalize_tool_name(&schema.name);
3740        name == "websearch" || name == "webfetch" || name == "webfetch_html"
3741    })
3742}
3743
3744fn has_email_action_tools(schemas: &[ToolSchema]) -> bool {
3745    schemas
3746        .iter()
3747        .map(|schema| normalize_tool_name(&schema.name))
3748        .any(|name| tool_name_looks_like_email_action(&name))
3749}
3750
3751fn tool_name_looks_like_email_action(name: &str) -> bool {
3752    let normalized = normalize_tool_name(name);
3753    if normalized.starts_with("mcp.") {
3754        return normalized.contains("gmail")
3755            || normalized.contains("mail")
3756            || normalized.contains("email");
3757    }
3758    normalized.contains("mail") || normalized.contains("email")
3759}
3760
3761fn completion_claims_email_sent(text: &str) -> bool {
3762    let lower = text.to_ascii_lowercase();
3763    let has_email_marker = lower.contains("email status")
3764        || lower.contains("emailed")
3765        || lower.contains("email sent")
3766        || lower.contains("sent to");
3767    has_email_marker
3768        && (lower.contains("sent")
3769            || lower.contains("delivered")
3770            || lower.contains("has been sent"))
3771}
3772
3773fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
3774    let Some(obj) = args.as_object() else {
3775        return Vec::new();
3776    };
3777    // For MCP tools, probe a wider set of path-like keys since MCP schemas vary by server.
3778    let mcp_path_keys: &[&str] = &[
3779        "path",
3780        "file_path",
3781        "filePath",
3782        "filepath",
3783        "filename",
3784        "directory",
3785        "dir",
3786        "cwd",
3787        "target",
3788        "source",
3789        "dest",
3790        "destination",
3791    ];
3792    let keys: &[&str] = if tool.starts_with("mcp.") {
3793        mcp_path_keys
3794    } else {
3795        match tool {
3796            "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
3797            "glob" => &["pattern"],
3798            "lsp" => &["filePath", "path"],
3799            "bash" => &["cwd"],
3800            "apply_patch" => &[],
3801            _ => &["path", "cwd"],
3802        }
3803    };
3804    keys.iter()
3805        .filter_map(|key| obj.get(*key))
3806        .filter_map(|value| value.as_str())
3807        .filter(|s| {
3808            let t = s.trim();
3809            // Exclude placeholder/empty strings or obvious non-paths
3810            !t.is_empty()
3811                && (t.starts_with('/')
3812                    || t.starts_with('.')
3813                    || t.starts_with('~')
3814                    || t.contains('/'))
3815        })
3816        .map(ToString::to_string)
3817        .collect()
3818}
3819
3820/// Returns true if the MCP server name is in the operator-configured exemption list.
3821/// Set `TANDEM_MCP_SANDBOX_EXEMPT_SERVERS` to a comma-separated list of server names
3822/// (e.g. `composio,github`) to exempt those servers from workspace path containment.
3823fn is_mcp_sandbox_exempt_server(server_name: &str) -> bool {
3824    let Ok(raw) = std::env::var("TANDEM_MCP_SANDBOX_EXEMPT_SERVERS") else {
3825        return false;
3826    };
3827    raw.split(',')
3828        .any(|s| s.trim().eq_ignore_ascii_case(server_name))
3829}
3830
3831fn is_mcp_tool_name(tool_name: &str) -> bool {
3832    let normalized = normalize_tool_name(tool_name);
3833    normalized == "mcp_list" || normalized.starts_with("mcp.")
3834}
3835
3836fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
3837    let target = normalize_tool_name(tool_name);
3838    match agent.tools.as_ref() {
3839        None => true,
3840        Some(list) => {
3841            let normalized = list
3842                .iter()
3843                .map(|t| normalize_tool_name(t))
3844                .collect::<Vec<_>>();
3845            any_policy_matches(&normalized, &target)
3846        }
3847    }
3848}
3849
3850fn enforce_skill_scope(
3851    tool_name: &str,
3852    args: Value,
3853    equipped_skills: Option<&[String]>,
3854) -> Result<Value, String> {
3855    if normalize_tool_name(tool_name) != "skill" {
3856        return Ok(args);
3857    }
3858    let Some(configured) = equipped_skills else {
3859        return Ok(args);
3860    };
3861
3862    let mut allowed = configured
3863        .iter()
3864        .map(|s| s.trim().to_string())
3865        .filter(|s| !s.is_empty())
3866        .collect::<Vec<_>>();
3867    if allowed
3868        .iter()
3869        .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
3870    {
3871        return Ok(args);
3872    }
3873    allowed.sort();
3874    allowed.dedup();
3875    if allowed.is_empty() {
3876        return Err("No skills are equipped for this agent.".to_string());
3877    }
3878
3879    let requested = args
3880        .get("name")
3881        .and_then(|v| v.as_str())
3882        .map(|v| v.trim().to_string())
3883        .unwrap_or_default();
3884    if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
3885        return Err(format!(
3886            "Skill '{}' is not equipped for this agent. Equipped skills: {}",
3887            requested,
3888            allowed.join(", ")
3889        ));
3890    }
3891
3892    let mut out = if let Some(obj) = args.as_object() {
3893        Value::Object(obj.clone())
3894    } else {
3895        json!({})
3896    };
3897    if let Some(obj) = out.as_object_mut() {
3898        obj.insert("allowed_skills".to_string(), json!(allowed));
3899    }
3900    Ok(out)
3901}
3902
3903fn is_read_only_tool(tool_name: &str) -> bool {
3904    matches!(
3905        normalize_tool_name(tool_name).as_str(),
3906        "glob"
3907            | "read"
3908            | "grep"
3909            | "search"
3910            | "codesearch"
3911            | "list"
3912            | "ls"
3913            | "lsp"
3914            | "websearch"
3915            | "webfetch"
3916            | "webfetch_html"
3917    )
3918}
3919
3920fn is_workspace_write_tool(tool_name: &str) -> bool {
3921    matches!(
3922        normalize_tool_name(tool_name).as_str(),
3923        "write" | "edit" | "apply_patch"
3924    )
3925}
3926
3927fn should_start_prewrite_repair_before_first_write(
3928    repair_on_unmet_requirements: bool,
3929    productive_write_tool_calls_total: usize,
3930    prewrite_satisfied: bool,
3931    code_workflow_requested: bool,
3932) -> bool {
3933    (repair_on_unmet_requirements || code_workflow_requested)
3934        && productive_write_tool_calls_total == 0
3935        && !prewrite_satisfied
3936}
3937
3938fn is_batch_wrapper_tool_name(name: &str) -> bool {
3939    matches!(
3940        normalize_tool_name(name).as_str(),
3941        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
3942    )
3943}
3944
3945fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
3946    obj.get(key)
3947        .and_then(|v| v.as_str())
3948        .map(str::trim)
3949        .filter(|s| !s.is_empty())
3950}
3951
3952fn nested_non_empty_string_at<'a>(
3953    obj: &'a Map<String, Value>,
3954    parent: &str,
3955    key: &str,
3956) -> Option<&'a str> {
3957    obj.get(parent)
3958        .and_then(|v| v.as_object())
3959        .and_then(|nested| nested.get(key))
3960        .and_then(|v| v.as_str())
3961        .map(str::trim)
3962        .filter(|s| !s.is_empty())
3963}
3964
3965fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
3966    let calls = args
3967        .get("tool_calls")
3968        .and_then(|v| v.as_array())
3969        .cloned()
3970        .unwrap_or_default();
3971    calls
3972        .into_iter()
3973        .filter_map(|call| {
3974            let obj = call.as_object()?;
3975            let tool_raw = non_empty_string_at(obj, "tool")
3976                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
3977                .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
3978                .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
3979                .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
3980            let name_raw = non_empty_string_at(obj, "name")
3981                .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
3982                .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
3983                .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
3984                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
3985            let effective = match (tool_raw, name_raw) {
3986                (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
3987                (Some(t), _) => t,
3988                (None, Some(n)) => n,
3989                (None, None) => return None,
3990            };
3991            let normalized = normalize_tool_name(effective);
3992            let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
3993            Some((normalized, call_args))
3994        })
3995        .collect()
3996}
3997
3998fn is_read_only_batch_call(args: &Value) -> bool {
3999    let calls = extract_batch_calls(args);
4000    !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
4001}
4002
4003fn batch_tool_signature(args: &Value) -> Option<String> {
4004    let calls = extract_batch_calls(args);
4005    if calls.is_empty() {
4006        return None;
4007    }
4008    let parts = calls
4009        .into_iter()
4010        .map(|(tool, call_args)| tool_signature(&tool, &call_args))
4011        .collect::<Vec<_>>();
4012    Some(format!("batch:{}", parts.join("|")))
4013}
4014
4015fn is_productive_tool_output(tool_name: &str, output: &str) -> bool {
4016    let normalized_tool = normalize_tool_name(tool_name);
4017    if normalized_tool == "batch" && is_non_productive_batch_output(output) {
4018        return false;
4019    }
4020    if is_auth_required_tool_output(output) {
4021        return false;
4022    }
4023    if normalized_tool == "glob" {
4024        return true;
4025    }
4026    let Some(result_body) = extract_tool_result_body(output) else {
4027        return false;
4028    };
4029    !is_non_productive_tool_result_body(result_body)
4030}
4031
4032fn is_successful_web_research_output(tool_name: &str, output: &str) -> bool {
4033    if !is_web_research_tool(tool_name) {
4034        return false;
4035    }
4036    let Some(result_body) = extract_tool_result_body(output) else {
4037        return false;
4038    };
4039    if is_non_productive_tool_result_body(result_body) {
4040        return false;
4041    }
4042    let lower = result_body.to_ascii_lowercase();
4043    !(lower.contains("search timed out")
4044        || lower.contains("timed out")
4045        || lower.contains("no results received")
4046        || lower.contains("no search results")
4047        || lower.contains("no relevant results"))
4048}
4049
4050fn extract_tool_result_body(output: &str) -> Option<&str> {
4051    let trimmed = output.trim();
4052    let rest = trimmed.strip_prefix("Tool `")?;
4053    let (_, result_body) = rest.split_once("` result:")?;
4054    Some(result_body.trim())
4055}
4056
4057fn is_non_productive_tool_result_body(output: &str) -> bool {
4058    let trimmed = output.trim();
4059    if trimmed.is_empty() {
4060        return true;
4061    }
4062    let lower = trimmed.to_ascii_lowercase();
4063    lower.starts_with("unknown tool:")
4064        || lower.contains("call skipped")
4065        || lower.contains("guard budget exceeded")
4066        || lower.contains("invalid_function_parameters")
4067        || is_terminal_tool_error_reason(trimmed)
4068}
4069
4070fn is_terminal_tool_error_reason(output: &str) -> bool {
4071    let first_line = output.lines().next().unwrap_or_default().trim();
4072    if first_line.is_empty() {
4073        return false;
4074    }
4075    let normalized = first_line.to_ascii_uppercase();
4076    matches!(
4077        normalized.as_str(),
4078        "TOOL_ARGUMENTS_MISSING"
4079            | "WEBSEARCH_QUERY_MISSING"
4080            | "BASH_COMMAND_MISSING"
4081            | "FILE_PATH_MISSING"
4082            | "WRITE_CONTENT_MISSING"
4083            | "WRITE_ARGS_EMPTY_FROM_PROVIDER"
4084            | "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"
4085            | "WEBFETCH_URL_MISSING"
4086            | "PACK_BUILDER_PLAN_ID_MISSING"
4087            | "PACK_BUILDER_GOAL_MISSING"
4088            | "PROVIDER_REQUEST_FAILED"
4089            | "AUTHENTICATION_ERROR"
4090            | "CONTEXT_LENGTH_EXCEEDED"
4091            | "RATE_LIMIT_EXCEEDED"
4092    ) || normalized.ends_with("_MISSING")
4093        || normalized.ends_with("_ERROR")
4094}
4095
4096fn is_non_productive_batch_output(output: &str) -> bool {
4097    let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
4098        return false;
4099    };
4100    let Some(items) = value.as_array() else {
4101        return false;
4102    };
4103    if items.is_empty() {
4104        return true;
4105    }
4106    items.iter().all(|item| {
4107        let text = item
4108            .get("output")
4109            .and_then(|v| v.as_str())
4110            .map(str::trim)
4111            .unwrap_or_default()
4112            .to_ascii_lowercase();
4113        text.is_empty()
4114            || text.starts_with("unknown tool:")
4115            || text.contains("call skipped")
4116            || text.contains("guard budget exceeded")
4117    })
4118}
4119
4120fn is_auth_required_tool_output(output: &str) -> bool {
4121    let lower = output.to_ascii_lowercase();
4122    (lower.contains("authorization required")
4123        || lower.contains("requires authorization")
4124        || lower.contains("authorization pending"))
4125        && (lower.contains("authorize here") || lower.contains("http"))
4126}
4127
4128#[derive(Debug, Clone)]
4129struct McpAuthRequiredMetadata {
4130    challenge_id: String,
4131    authorization_url: String,
4132    message: String,
4133    server: Option<String>,
4134    pending: bool,
4135    blocked: bool,
4136    retry_after_ms: Option<u64>,
4137}
4138
4139fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
4140    let auth = metadata.get("mcpAuth")?;
4141    if !auth
4142        .get("required")
4143        .and_then(|v| v.as_bool())
4144        .unwrap_or(false)
4145    {
4146        return None;
4147    }
4148    let authorization_url = auth
4149        .get("authorizationUrl")
4150        .and_then(|v| v.as_str())
4151        .map(str::trim)
4152        .filter(|v| !v.is_empty())?
4153        .to_string();
4154    let message = auth
4155        .get("message")
4156        .and_then(|v| v.as_str())
4157        .map(str::trim)
4158        .filter(|v| !v.is_empty())
4159        .unwrap_or("This tool requires authorization before it can run.")
4160        .to_string();
4161    let challenge_id = auth
4162        .get("challengeId")
4163        .and_then(|v| v.as_str())
4164        .map(str::trim)
4165        .filter(|v| !v.is_empty())
4166        .unwrap_or("unknown")
4167        .to_string();
4168    let server = metadata
4169        .get("server")
4170        .and_then(|v| v.as_str())
4171        .map(str::trim)
4172        .filter(|v| !v.is_empty())
4173        .map(ToString::to_string);
4174    let pending = auth
4175        .get("pending")
4176        .and_then(|v| v.as_bool())
4177        .unwrap_or(false);
4178    let blocked = auth
4179        .get("blocked")
4180        .and_then(|v| v.as_bool())
4181        .unwrap_or(false);
4182    let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
4183    Some(McpAuthRequiredMetadata {
4184        challenge_id,
4185        authorization_url,
4186        message,
4187        server,
4188        pending,
4189        blocked,
4190        retry_after_ms,
4191    })
4192}
4193
4194fn extract_mcp_auth_required_from_error_text(
4195    tool_name: &str,
4196    error_text: &str,
4197) -> Option<McpAuthRequiredMetadata> {
4198    let lower = error_text.to_ascii_lowercase();
4199    let auth_hint = lower.contains("authorization")
4200        || lower.contains("oauth")
4201        || lower.contains("invalid oauth token")
4202        || lower.contains("requires authorization");
4203    if !auth_hint {
4204        return None;
4205    }
4206    let authorization_url = find_first_url(error_text)?;
4207    let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
4208    let server = tool_name
4209        .strip_prefix("mcp.")
4210        .and_then(|rest| rest.split('.').next())
4211        .filter(|s| !s.is_empty())
4212        .map(ToString::to_string);
4213    Some(McpAuthRequiredMetadata {
4214        challenge_id,
4215        authorization_url,
4216        message: "This integration requires authorization before this action can run.".to_string(),
4217        server,
4218        pending: false,
4219        blocked: false,
4220        retry_after_ms: None,
4221    })
4222}
4223
4224fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
4225    if outputs.is_empty()
4226        || !outputs
4227            .iter()
4228            .all(|output| is_auth_required_tool_output(output))
4229    {
4230        return None;
4231    }
4232    let mut auth_lines = outputs
4233        .iter()
4234        .filter_map(|output| {
4235            let trimmed = output.trim();
4236            if trimmed.is_empty() {
4237                None
4238            } else {
4239                Some(trimmed.to_string())
4240            }
4241        })
4242        .collect::<Vec<_>>();
4243    auth_lines.sort();
4244    auth_lines.dedup();
4245    if auth_lines.is_empty() {
4246        return None;
4247    }
4248    Some(format!(
4249        "Authorization is required before I can continue with this action.\n\n{}",
4250        auth_lines.join("\n\n")
4251    ))
4252}
4253
4254fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
4255    if outputs.is_empty()
4256        || !outputs
4257            .iter()
4258            .all(|output| is_guard_budget_tool_output(output))
4259    {
4260        return None;
4261    }
4262    let mut lines = outputs
4263        .iter()
4264        .filter_map(|output| {
4265            let trimmed = output.trim();
4266            if trimmed.is_empty() {
4267                None
4268            } else {
4269                Some(trimmed.to_string())
4270            }
4271        })
4272        .collect::<Vec<_>>();
4273    lines.sort();
4274    lines.dedup();
4275    if lines.is_empty() {
4276        return None;
4277    }
4278    Some(format!(
4279        "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.",
4280        lines.join("\n")
4281    ))
4282}
4283
4284fn summarize_duplicate_signature_outputs(outputs: &[String]) -> Option<String> {
4285    if outputs.is_empty()
4286        || !outputs
4287            .iter()
4288            .all(|output| is_duplicate_signature_limit_output(output))
4289    {
4290        return None;
4291    }
4292    let mut lines = outputs
4293        .iter()
4294        .filter_map(|output| {
4295            let trimmed = output.trim();
4296            if trimmed.is_empty() {
4297                None
4298            } else {
4299                Some(trimmed.to_string())
4300            }
4301        })
4302        .collect::<Vec<_>>();
4303    lines.sort();
4304    lines.dedup();
4305    if lines.is_empty() {
4306        return None;
4307    }
4308    Some(format!(
4309        "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.",
4310        lines.join("\n")
4311    ))
4312}
4313
4314const REQUIRED_TOOL_MODE_UNSATISFIED_REASON: &str = "TOOL_MODE_REQUIRED_NOT_SATISFIED";
4315
4316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4317enum RequiredToolFailureKind {
4318    NoToolCallEmitted,
4319    ToolCallParseFailed,
4320    ToolCallInvalidArgs,
4321    WriteArgsEmptyFromProvider,
4322    WriteArgsUnparseableFromProvider,
4323    ToolCallRejectedByPolicy,
4324    ToolCallExecutedNonProductive,
4325    WriteRequiredNotSatisfied,
4326    PrewriteRequirementsExhausted,
4327}
4328
4329impl RequiredToolFailureKind {
4330    fn code(self) -> &'static str {
4331        match self {
4332            Self::NoToolCallEmitted => "NO_TOOL_CALL_EMITTED",
4333            Self::ToolCallParseFailed => "TOOL_CALL_PARSE_FAILED",
4334            Self::ToolCallInvalidArgs => "TOOL_CALL_INVALID_ARGS",
4335            Self::WriteArgsEmptyFromProvider => "WRITE_ARGS_EMPTY_FROM_PROVIDER",
4336            Self::WriteArgsUnparseableFromProvider => "WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER",
4337            Self::ToolCallRejectedByPolicy => "TOOL_CALL_REJECTED_BY_POLICY",
4338            Self::ToolCallExecutedNonProductive => "TOOL_CALL_EXECUTED_NON_PRODUCTIVE",
4339            Self::WriteRequiredNotSatisfied => "WRITE_REQUIRED_NOT_SATISFIED",
4340            Self::PrewriteRequirementsExhausted => "PREWRITE_REQUIREMENTS_EXHAUSTED",
4341        }
4342    }
4343}
4344
4345fn required_tool_mode_unsatisfied_completion(reason: RequiredToolFailureKind) -> String {
4346    format!(
4347        "{REQUIRED_TOOL_MODE_UNSATISFIED_REASON}: {}: tool_mode=required but the model ended without executing a productive tool call.",
4348        reason.code()
4349    )
4350}
4351
4352#[allow(dead_code)]
4353fn prewrite_requirements_exhausted_completion(
4354    unmet_codes: &[&'static str],
4355    repair_attempt: usize,
4356    repair_attempts_remaining: usize,
4357) -> String {
4358    let unmet = if unmet_codes.is_empty() {
4359        "none".to_string()
4360    } else {
4361        unmet_codes.join(", ")
4362    };
4363    format!(
4364        "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\":{:?}}}",
4365        repair_attempt,
4366        repair_attempts_remaining,
4367        unmet_codes,
4368    )
4369}
4370
4371fn prewrite_repair_event_payload(
4372    repair_attempt: usize,
4373    repair_attempts_remaining: usize,
4374    unmet_codes: &[&'static str],
4375    repair_exhausted: bool,
4376) -> Value {
4377    json!({
4378        "repairAttempt": repair_attempt,
4379        "repairAttemptsRemaining": repair_attempts_remaining,
4380        "unmetRequirements": unmet_codes,
4381        "repairActive": repair_attempt > 0 && !repair_exhausted,
4382        "repairExhausted": repair_exhausted,
4383    })
4384}
4385
4386fn build_required_tool_retry_context(
4387    offered_tool_preview: &str,
4388    previous_reason: RequiredToolFailureKind,
4389) -> String {
4390    let offered = offered_tool_preview.trim();
4391    let available_tools = if offered.is_empty() {
4392        "Use one of the tools offered in this turn before you produce final text.".to_string()
4393    } else {
4394        format!("Use one of these offered tools before you produce final text: {offered}.")
4395    };
4396    let execution_instruction = if previous_reason
4397        == RequiredToolFailureKind::WriteRequiredNotSatisfied
4398    {
4399        "Inspection is complete; now create or modify workspace files with write, edit, or apply_patch.".to_string()
4400    } else if is_write_invalid_args_failure_kind(previous_reason) {
4401        "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()
4402    } else {
4403        available_tools
4404    };
4405    format!(
4406        "Tool access is mandatory for this request. Previous attempt failed with {}. Execute at least one valid offered tool call before any final text. {}",
4407        previous_reason.code(),
4408        execution_instruction
4409    )
4410}
4411
4412fn looks_like_code_target_path(path: &str) -> bool {
4413    let trimmed = path.trim();
4414    if trimmed.is_empty() {
4415        return false;
4416    }
4417    let normalized = trimmed.replace('\\', "/");
4418    let file_name = normalized
4419        .rsplit('/')
4420        .next()
4421        .unwrap_or(normalized.as_str())
4422        .to_ascii_lowercase();
4423    if matches!(
4424        file_name.as_str(),
4425        "cargo.toml"
4426            | "cargo.lock"
4427            | "package.json"
4428            | "pnpm-lock.yaml"
4429            | "package-lock.json"
4430            | "yarn.lock"
4431            | "makefile"
4432            | "dockerfile"
4433            | ".gitignore"
4434            | ".editorconfig"
4435            | "tsconfig.json"
4436            | "pyproject.toml"
4437            | "requirements.txt"
4438    ) {
4439        return true;
4440    }
4441    let extension = file_name.rsplit('.').next().unwrap_or_default();
4442    matches!(
4443        extension,
4444        "rs" | "ts"
4445            | "tsx"
4446            | "js"
4447            | "jsx"
4448            | "py"
4449            | "go"
4450            | "java"
4451            | "kt"
4452            | "kts"
4453            | "c"
4454            | "cc"
4455            | "cpp"
4456            | "h"
4457            | "hpp"
4458            | "cs"
4459            | "rb"
4460            | "php"
4461            | "swift"
4462            | "scala"
4463            | "sh"
4464            | "bash"
4465            | "zsh"
4466            | "toml"
4467            | "yaml"
4468            | "yml"
4469            | "json"
4470    )
4471}
4472
4473fn infer_code_workflow_from_text(text: &str) -> bool {
4474    let lowered = text.to_ascii_lowercase();
4475    if lowered.contains("code agent contract")
4476        || lowered.contains("inspect -> patch -> apply -> test -> repair")
4477        || lowered.contains("task kind: `code_change`")
4478        || lowered.contains("task kind: code_change")
4479        || lowered.contains("output contract kind: code_patch")
4480        || lowered.contains("verification expectation:")
4481        || lowered.contains("verification command:")
4482    {
4483        return true;
4484    }
4485    infer_required_output_target_path_from_text(text)
4486        .is_some_and(|path| looks_like_code_target_path(&path))
4487}
4488
4489fn infer_verification_command_from_text(text: &str) -> Option<String> {
4490    for marker in ["Verification expectation:", "verification expectation:"] {
4491        let Some(start) = text.find(marker) else {
4492            continue;
4493        };
4494        let remainder = text[start + marker.len()..].trim_start();
4495        let line = remainder.lines().next().unwrap_or_default().trim();
4496        if line.is_empty() {
4497            continue;
4498        }
4499        let cleaned = line
4500            .trim_matches('`')
4501            .trim_end_matches('.')
4502            .trim()
4503            .to_string();
4504        if !cleaned.is_empty() {
4505            return Some(cleaned);
4506        }
4507    }
4508    None
4509}
4510
4511fn build_required_tool_retry_context_for_task(
4512    offered_tool_preview: &str,
4513    previous_reason: RequiredToolFailureKind,
4514    latest_user_text: &str,
4515) -> String {
4516    let mut prompt = build_required_tool_retry_context(offered_tool_preview, previous_reason);
4517    if !infer_code_workflow_from_text(latest_user_text) {
4518        return prompt;
4519    }
4520    let output_target = infer_required_output_target_path_from_text(latest_user_text)
4521        .unwrap_or_else(|| "the declared source target".to_string());
4522    let verification = infer_verification_command_from_text(latest_user_text)
4523        .unwrap_or_else(|| "run the declared verification command with `bash`".to_string());
4524    prompt.push(' ');
4525    prompt.push_str(
4526        "This is a code workflow: follow inspect -> patch -> apply -> test -> repair before finalizing.",
4527    );
4528    prompt.push(' ');
4529    prompt.push_str(&format!(
4530        "Patch `{output_target}` using `apply_patch` (or `edit` for local edits); use `write` only when creating a brand-new file."
4531    ));
4532    prompt.push(' ');
4533    prompt.push_str(&format!(
4534        "After patching, run verification with `bash` (`{verification}`). If verification fails, repair the smallest root cause and re-run verification."
4535    ));
4536    prompt
4537}
4538
4539fn is_write_invalid_args_failure_kind(reason: RequiredToolFailureKind) -> bool {
4540    matches!(
4541        reason,
4542        RequiredToolFailureKind::ToolCallInvalidArgs
4543            | RequiredToolFailureKind::WriteArgsEmptyFromProvider
4544            | RequiredToolFailureKind::WriteArgsUnparseableFromProvider
4545    )
4546}
4547
4548fn should_retry_nonproductive_required_tool_cycle(
4549    requested_write_required: bool,
4550    write_tool_attempted_in_cycle: bool,
4551    progress_made_in_cycle: bool,
4552    required_tool_retry_count: usize,
4553) -> bool {
4554    if write_tool_attempted_in_cycle {
4555        return required_tool_retry_count == 0 && !requested_write_required;
4556    }
4557    if progress_made_in_cycle {
4558        return required_tool_retry_count < 2;
4559    }
4560    required_tool_retry_count == 0 && (!requested_write_required || !write_tool_attempted_in_cycle)
4561}
4562
4563fn build_write_required_retry_context(
4564    offered_tool_preview: &str,
4565    previous_reason: RequiredToolFailureKind,
4566    latest_user_text: &str,
4567    prewrite_requirements: &PrewriteRequirements,
4568    workspace_inspection_satisfied: bool,
4569    concrete_read_satisfied: bool,
4570    web_research_satisfied: bool,
4571    successful_web_research_satisfied: bool,
4572) -> String {
4573    let mut prompt = build_required_tool_retry_context_for_task(
4574        offered_tool_preview,
4575        previous_reason,
4576        latest_user_text,
4577    );
4578    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4579        prewrite_requirements,
4580        workspace_inspection_satisfied,
4581        concrete_read_satisfied,
4582        web_research_satisfied,
4583        successful_web_research_satisfied,
4584    );
4585    if !unmet.is_empty() {
4586        prompt.push(' ');
4587        prompt.push_str(&format!(
4588            "Before the final write, you still need to {}.",
4589            unmet.join(" and ")
4590        ));
4591    }
4592    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4593        prompt.push(' ');
4594        prompt.push_str(&format!(
4595            "The required output target for this task is `{path}`. Write or update that file now."
4596        ));
4597        prompt.push(' ');
4598        prompt.push_str(
4599            "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4600        );
4601        prompt.push(' ');
4602        prompt.push_str(
4603            "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.",
4604        );
4605    }
4606    prompt
4607}
4608
4609fn build_prewrite_repair_retry_context(
4610    offered_tool_preview: &str,
4611    previous_reason: RequiredToolFailureKind,
4612    latest_user_text: &str,
4613    prewrite_requirements: &PrewriteRequirements,
4614    workspace_inspection_satisfied: bool,
4615    concrete_read_satisfied: bool,
4616    web_research_satisfied: bool,
4617    successful_web_research_satisfied: bool,
4618) -> String {
4619    let mut prompt = build_required_tool_retry_context_for_task(
4620        offered_tool_preview,
4621        previous_reason,
4622        latest_user_text,
4623    );
4624    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4625        prewrite_requirements,
4626        workspace_inspection_satisfied,
4627        concrete_read_satisfied,
4628        web_research_satisfied,
4629        successful_web_research_satisfied,
4630    );
4631    if !unmet.is_empty() {
4632        prompt.push(' ');
4633        prompt.push_str(&format!(
4634            "Before the final write, you still need to {}.",
4635            unmet.join(" and ")
4636        ));
4637    }
4638    let mut repair_notes = Vec::new();
4639    if prewrite_requirements.concrete_read_required && !concrete_read_satisfied {
4640        repair_notes.push(
4641            "This task requires concrete `read` calls on relevant workspace files before you can write the output. Call `read` now on the files you discovered.",
4642        );
4643    }
4644    if prewrite_requirements.successful_web_research_required && !successful_web_research_satisfied
4645    {
4646        repair_notes.push(
4647            "Timed out or empty websearch attempts do not satisfy external-research requirements; call `websearch` with a concrete query now.",
4648        );
4649    }
4650    if !matches!(
4651        prewrite_requirements.coverage_mode,
4652        PrewriteCoverageMode::None
4653    ) {
4654        repair_notes.push(
4655            "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.",
4656        );
4657    }
4658    if !repair_notes.is_empty() {
4659        prompt.push(' ');
4660        prompt.push_str("Do not skip this step. ");
4661        prompt.push_str(&repair_notes.join(" "));
4662    }
4663    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4664        if infer_code_workflow_from_text(latest_user_text) {
4665            prompt.push(' ');
4666            prompt.push_str(&format!(
4667                "Use `read` to confirm the concrete code context, then patch `{path}` with `apply_patch` or `edit` and run verification before finalizing."
4668            ));
4669            prompt.push(' ');
4670            prompt.push_str(
4671                "Do not return a prose-only completion before patch + verification steps run.",
4672            );
4673        } else {
4674            prompt.push(' ');
4675            prompt.push_str(&format!(
4676                "Use `read` and `websearch` now to gather evidence, then write the artifact to `{path}`."
4677            ));
4678            prompt.push(' ');
4679            prompt.push_str(&format!(
4680                "Do not declare the output blocked while `read` and `websearch` remain available. Call them now."
4681            ));
4682        }
4683    }
4684    prompt
4685}
4686
4687fn build_prewrite_waived_write_context(
4688    latest_user_text: &str,
4689    unmet_codes: &[&'static str],
4690) -> String {
4691    let mut prompt = String::from(
4692        "Research prerequisites could not be fully satisfied after multiple repair attempts. \
4693         You must still write the output file using whatever information you have gathered so far. \
4694         Do not write a blocked or placeholder file. Write the best possible output with the evidence available.",
4695    );
4696    if !unmet_codes.is_empty() {
4697        prompt.push_str(&format!(
4698            " (Unmet prerequisites waived: {}.)",
4699            unmet_codes.join(", ")
4700        ));
4701    }
4702    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4703        prompt.push_str(&format!(
4704            " The required output file is `{path}`. Call the `write` tool now to create it."
4705        ));
4706    }
4707    prompt
4708}
4709
4710fn build_empty_completion_retry_context(
4711    offered_tool_preview: &str,
4712    latest_user_text: &str,
4713    prewrite_requirements: &PrewriteRequirements,
4714    workspace_inspection_satisfied: bool,
4715    concrete_read_satisfied: bool,
4716    web_research_satisfied: bool,
4717    successful_web_research_satisfied: bool,
4718) -> String {
4719    let mut prompt = String::from(
4720        "You already used tools in this session, but returned no final output. Do not stop now.",
4721    );
4722    let unmet = describe_unmet_prewrite_requirements_for_prompt(
4723        prewrite_requirements,
4724        workspace_inspection_satisfied,
4725        concrete_read_satisfied,
4726        web_research_satisfied,
4727        successful_web_research_satisfied,
4728    );
4729    if !unmet.is_empty() {
4730        prompt.push(' ');
4731        prompt.push_str(&format!(
4732            "You still need to {} before the final write.",
4733            unmet.join(" and ")
4734        ));
4735        prompt.push(' ');
4736        prompt.push_str(&build_required_tool_retry_context_for_task(
4737            offered_tool_preview,
4738            RequiredToolFailureKind::WriteRequiredNotSatisfied,
4739            latest_user_text,
4740        ));
4741    }
4742    if let Some(path) = infer_required_output_target_path_from_text(latest_user_text) {
4743        prompt.push(' ');
4744        prompt.push_str(&format!("The required output target is `{path}`."));
4745        if unmet.is_empty() {
4746            prompt.push(' ');
4747            prompt.push_str(
4748                "Your next response must be a `write` tool call for that file, not a prose-only reply.",
4749            );
4750        } else {
4751            prompt.push(' ');
4752            prompt.push_str(
4753                "After completing the missing requirement, immediately write that file instead of ending with prose.",
4754            );
4755        }
4756    }
4757    prompt
4758}
4759
4760fn synthesize_artifact_write_completion_from_tool_state(
4761    latest_user_text: &str,
4762    prewrite_satisfied: bool,
4763    prewrite_gate_waived: bool,
4764) -> String {
4765    let target = infer_required_output_target_path_from_text(latest_user_text)
4766        .unwrap_or_else(|| "the declared output artifact".to_string());
4767    let mut completion = format!("Completed the requested tool actions and wrote `{target}`.");
4768    if prewrite_gate_waived && !prewrite_satisfied {
4769        completion.push_str(
4770            "\n\nRuntime validation will decide whether the artifact can be accepted because some evidence requirements were waived in-run."
4771        );
4772    } else {
4773        completion
4774            .push_str("\n\nRuntime validation will verify the artifact and finalize node status.");
4775    }
4776    completion.push_str("\n\n{\"status\":\"completed\"}");
4777    completion
4778}
4779
4780fn should_generate_post_tool_final_narrative(
4781    requested_tool_mode: ToolMode,
4782    productive_tool_calls_total: usize,
4783) -> bool {
4784    !matches!(requested_tool_mode, ToolMode::Required) || productive_tool_calls_total > 0
4785}
4786
4787fn is_workspace_inspection_tool(tool_name: &str) -> bool {
4788    matches!(
4789        normalize_tool_name(tool_name).as_str(),
4790        "glob" | "read" | "grep" | "search" | "codesearch" | "ls" | "list"
4791    )
4792}
4793
4794fn is_web_research_tool(tool_name: &str) -> bool {
4795    matches!(
4796        normalize_tool_name(tool_name).as_str(),
4797        "websearch" | "webfetch" | "webfetch_html"
4798    )
4799}
4800
4801fn tool_matches_unmet_prewrite_repair_requirement(tool_name: &str, unmet_codes: &[&str]) -> bool {
4802    if is_workspace_write_tool(tool_name) {
4803        return false;
4804    }
4805    let normalized = normalize_tool_name(tool_name);
4806    let needs_workspace_inspection = unmet_codes.contains(&"workspace_inspection_required");
4807    let needs_concrete_read =
4808        unmet_codes.contains(&"concrete_read_required") || unmet_codes.contains(&"coverage_mode");
4809    let needs_web_research = unmet_codes.iter().any(|code| {
4810        matches!(
4811            *code,
4812            "web_research_required" | "successful_web_research_required"
4813        )
4814    });
4815    (needs_concrete_read && (normalized == "read" || normalized == "glob"))
4816        || (needs_workspace_inspection && is_workspace_inspection_tool(&normalized))
4817        || (needs_web_research && is_web_research_tool(&normalized))
4818}
4819
4820fn invalid_tool_args_retry_max_attempts() -> usize {
4821    2
4822}
4823
4824pub fn prewrite_repair_retry_max_attempts() -> usize {
4825    5
4826}
4827
4828/// When `TANDEM_PREWRITE_GATE_STRICT=true`, the engine refuses to waive the prewrite
4829/// evidence gate even after exhausting repair retries. Instead of proceeding with
4830/// an unverified write, it holds the gate and emits `prewrite.gate.strict_mode.blocked`.
4831pub(super) fn prewrite_gate_strict_mode() -> bool {
4832    std::env::var("TANDEM_PREWRITE_GATE_STRICT")
4833        .ok()
4834        .map(|v| {
4835            matches!(
4836                v.trim().to_ascii_lowercase().as_str(),
4837                "1" | "true" | "yes" | "on"
4838            )
4839        })
4840        .unwrap_or(false)
4841}
4842
4843fn build_invalid_tool_args_retry_context_from_outputs(
4844    outputs: &[String],
4845    previous_attempts: usize,
4846) -> Option<String> {
4847    if outputs
4848        .iter()
4849        .any(|output| output.contains("BASH_COMMAND_MISSING"))
4850    {
4851        let emphasis = if previous_attempts > 0 {
4852            "You already tried `bash` without a valid command. Do not repeat an empty bash call."
4853        } else {
4854            "If you use `bash`, include a full non-empty command string."
4855        };
4856        return Some(format!(
4857            "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."
4858        ));
4859    }
4860    if outputs
4861        .iter()
4862        .any(|output| output.contains("WEBSEARCH_QUERY_MISSING"))
4863    {
4864        return Some(
4865            "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(),
4866        );
4867    }
4868    if outputs
4869        .iter()
4870        .any(|output| output.contains("WEBFETCH_URL_MISSING"))
4871    {
4872        return Some(
4873            "Previous webfetch tool call was invalid because it did not include a URL. If you use `webfetch`, include a full absolute `url`.".to_string(),
4874        );
4875    }
4876    if outputs
4877        .iter()
4878        .any(|output| output.contains("FILE_PATH_MISSING"))
4879    {
4880        return Some(
4881            "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(),
4882        );
4883    }
4884    if outputs
4885        .iter()
4886        .any(|output| output.contains("WRITE_CONTENT_MISSING"))
4887    {
4888        return Some(
4889            "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(),
4890        );
4891    }
4892    None
4893}
4894
4895fn looks_like_unparsed_tool_payload(output: &str) -> bool {
4896    let trimmed = output.trim();
4897    if trimmed.is_empty() {
4898        return false;
4899    }
4900    let lower = trimmed.to_ascii_lowercase();
4901    lower.contains("\"tool_calls\"")
4902        || lower.contains("\"function_call\"")
4903        || lower.contains("\"function\":{")
4904        || lower.contains("\"type\":\"tool_call\"")
4905        || lower.contains("\"type\":\"function_call\"")
4906        || lower.contains("\"type\":\"tool_use\"")
4907}
4908
4909fn is_policy_rejection_output(output: &str) -> bool {
4910    let lower = output.trim().to_ascii_lowercase();
4911    lower.contains("call skipped")
4912        || lower.contains("authorization required")
4913        || lower.contains("not allowed")
4914        || lower.contains("permission denied")
4915}
4916
4917fn classify_required_tool_failure(
4918    outputs: &[String],
4919    saw_tool_call_candidate: bool,
4920    accepted_tool_calls: usize,
4921    parse_failed: bool,
4922    rejected_by_policy: bool,
4923) -> RequiredToolFailureKind {
4924    if parse_failed {
4925        return RequiredToolFailureKind::ToolCallParseFailed;
4926    }
4927    if !saw_tool_call_candidate {
4928        return RequiredToolFailureKind::NoToolCallEmitted;
4929    }
4930    if accepted_tool_calls == 0 || rejected_by_policy {
4931        return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4932    }
4933    if outputs
4934        .iter()
4935        .any(|output| output.contains("WRITE_ARGS_EMPTY_FROM_PROVIDER"))
4936    {
4937        return RequiredToolFailureKind::WriteArgsEmptyFromProvider;
4938    }
4939    if outputs
4940        .iter()
4941        .any(|output| output.contains("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER"))
4942    {
4943        return RequiredToolFailureKind::WriteArgsUnparseableFromProvider;
4944    }
4945    if outputs
4946        .iter()
4947        .any(|output| is_terminal_tool_error_reason(output))
4948    {
4949        return RequiredToolFailureKind::ToolCallInvalidArgs;
4950    }
4951    if outputs
4952        .iter()
4953        .any(|output| is_policy_rejection_output(output))
4954    {
4955        return RequiredToolFailureKind::ToolCallRejectedByPolicy;
4956    }
4957    RequiredToolFailureKind::ToolCallExecutedNonProductive
4958}
4959
4960fn find_first_url(text: &str) -> Option<String> {
4961    text.split_whitespace().find_map(|token| {
4962        if token.starts_with("https://") || token.starts_with("http://") {
4963            let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
4964            if cleaned.len() > "https://".len() {
4965                return Some(cleaned.to_string());
4966            }
4967        }
4968        None
4969    })
4970}
4971
4972fn max_tool_iterations() -> usize {
4973    let default_iterations = 25usize;
4974    std::env::var("TANDEM_MAX_TOOL_ITERATIONS")
4975        .ok()
4976        .and_then(|raw| raw.trim().parse::<usize>().ok())
4977        .filter(|value| *value > 0)
4978        .unwrap_or(default_iterations)
4979}
4980
4981fn strict_write_retry_max_attempts() -> usize {
4982    std::env::var("TANDEM_STRICT_WRITE_RETRY_MAX_ATTEMPTS")
4983        .ok()
4984        .and_then(|raw| raw.trim().parse::<usize>().ok())
4985        .filter(|value| *value > 0)
4986        .unwrap_or(3)
4987}
4988
4989fn provider_stream_connect_timeout_ms() -> usize {
4990    std::env::var("TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS")
4991        .ok()
4992        .and_then(|raw| raw.trim().parse::<usize>().ok())
4993        .filter(|value| *value > 0)
4994        .unwrap_or(90_000)
4995}
4996
4997fn provider_stream_idle_timeout_ms() -> usize {
4998    std::env::var("TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS")
4999        .ok()
5000        .and_then(|raw| raw.trim().parse::<usize>().ok())
5001        .filter(|value| *value > 0)
5002        .unwrap_or(90_000)
5003}
5004
5005fn prompt_context_hook_timeout_ms() -> usize {
5006    std::env::var("TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS")
5007        .ok()
5008        .and_then(|raw| raw.trim().parse::<usize>().ok())
5009        .filter(|value| *value > 0)
5010        .unwrap_or(5_000)
5011}
5012
5013fn permission_wait_timeout_ms() -> usize {
5014    std::env::var("TANDEM_PERMISSION_WAIT_TIMEOUT_MS")
5015        .ok()
5016        .and_then(|raw| raw.trim().parse::<usize>().ok())
5017        .filter(|value| *value > 0)
5018        .unwrap_or(15_000)
5019}
5020
5021fn tool_exec_timeout_ms() -> usize {
5022    std::env::var("TANDEM_TOOL_EXEC_TIMEOUT_MS")
5023        .ok()
5024        .and_then(|raw| raw.trim().parse::<usize>().ok())
5025        .filter(|value| *value > 0)
5026        .unwrap_or(45_000)
5027}
5028
5029fn is_guard_budget_tool_output(output: &str) -> bool {
5030    output
5031        .to_ascii_lowercase()
5032        .contains("per-run guard budget exceeded")
5033}
5034
5035fn is_duplicate_signature_limit_output(output: &str) -> bool {
5036    output
5037        .to_ascii_lowercase()
5038        .contains("duplicate call signature retry limit reached")
5039}
5040
5041fn is_sensitive_path_candidate(path: &Path) -> bool {
5042    let lowered = path.to_string_lossy().to_ascii_lowercase();
5043
5044    // SSH / GPG directories
5045    if lowered.contains("/.ssh/") || lowered.ends_with("/.ssh") {
5046        return true;
5047    }
5048    if lowered.contains("/.gnupg/") || lowered.ends_with("/.gnupg") {
5049        return true;
5050    }
5051
5052    // Cloud credential files
5053    if lowered.contains("/.aws/credentials")
5054        || lowered.contains("/.config/gcloud/")
5055        || lowered.contains("/.docker/config.json")
5056        || lowered.contains("/.kube/config")
5057        || lowered.contains("/.git-credentials")
5058    {
5059        return true;
5060    }
5061
5062    // Package manager / tool secrets
5063    if lowered.ends_with("/.npmrc") || lowered.ends_with("/.netrc") || lowered.ends_with("/.pypirc")
5064    {
5065        return true;
5066    }
5067
5068    // Known private key file names (use file_name() to avoid false positives on paths)
5069    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
5070        let n = name.to_ascii_lowercase();
5071        // .env files (but not .env.example — check no extra extension after .env)
5072        if n == ".env"
5073            || n.starts_with(".env.") && !n.ends_with(".example") && !n.ends_with(".sample")
5074        {
5075            return true;
5076        }
5077        // Key identity files
5078        if n.starts_with("id_rsa")
5079            || n.starts_with("id_ed25519")
5080            || n.starts_with("id_ecdsa")
5081            || n.starts_with("id_dsa")
5082        {
5083            return true;
5084        }
5085    }
5086
5087    // Certificate / private key extensions — use extension() to avoid substring false positives
5088    // e.g. keyboard.rs has no .key extension, so it won't match here.
5089    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
5090        let ext_lower = ext.to_ascii_lowercase();
5091        if matches!(
5092            ext_lower.as_str(),
5093            "pem" | "p12" | "pfx" | "key" | "keystore" | "jks"
5094        ) {
5095            return true;
5096        }
5097    }
5098
5099    false
5100}
5101
5102fn shell_command_targets_sensitive_path(command: &str) -> bool {
5103    let lower = command.to_ascii_lowercase();
5104    let patterns = [
5105        "/.ssh/",
5106        "/.gnupg/",
5107        "/.aws/credentials",
5108        "/.config/gcloud/",
5109        "/.docker/config.json",
5110        "/.kube/config",
5111        "/.git-credentials",
5112        "id_rsa",
5113        "id_ed25519",
5114        "id_ecdsa",
5115        "id_dsa",
5116        ".npmrc",
5117        ".netrc",
5118        ".pypirc",
5119    ];
5120    // Check structural path patterns
5121    if patterns.iter().any(|p| lower.contains(p)) {
5122        return true;
5123    }
5124    // Check .env (standalone, not .env.example)
5125    if let Some(pos) = lower.find(".env") {
5126        let after = &lower[pos + 4..];
5127        if after.is_empty() || after.starts_with(' ') || after.starts_with('/') {
5128            return true;
5129        }
5130    }
5131    false
5132}
5133
5134#[derive(Debug, Clone)]
5135struct NormalizedToolArgs {
5136    args: Value,
5137    args_source: String,
5138    args_integrity: String,
5139    raw_args_state: RawToolArgsState,
5140    query: Option<String>,
5141    missing_terminal: bool,
5142    missing_terminal_reason: Option<String>,
5143}
5144
5145#[derive(Debug, Clone)]
5146struct ParsedToolCall {
5147    tool: String,
5148    args: Value,
5149    call_id: Option<String>,
5150}
5151
5152#[cfg(test)]
5153fn normalize_tool_args(
5154    tool_name: &str,
5155    raw_args: Value,
5156    latest_user_text: &str,
5157    latest_assistant_context: &str,
5158) -> NormalizedToolArgs {
5159    normalize_tool_args_with_mode(
5160        tool_name,
5161        raw_args,
5162        latest_user_text,
5163        latest_assistant_context,
5164        WritePathRecoveryMode::Heuristic,
5165    )
5166}
5167
5168fn normalize_tool_args_with_mode(
5169    tool_name: &str,
5170    raw_args: Value,
5171    latest_user_text: &str,
5172    latest_assistant_context: &str,
5173    write_path_recovery_mode: WritePathRecoveryMode,
5174) -> NormalizedToolArgs {
5175    let normalized_tool = normalize_tool_name(tool_name);
5176    let original_args = raw_args.clone();
5177    let mut args = raw_args;
5178    let mut args_source = if args.is_string() {
5179        "provider_string".to_string()
5180    } else {
5181        "provider_json".to_string()
5182    };
5183    let mut args_integrity = "ok".to_string();
5184    let raw_args_state = classify_raw_tool_args_state(&args);
5185    let mut query = None;
5186    let mut missing_terminal = false;
5187    let mut missing_terminal_reason = None;
5188
5189    if normalized_tool == "websearch" {
5190        if let Some(found) = extract_websearch_query(&args) {
5191            query = Some(found);
5192            args = set_websearch_query_and_source(args, query.clone(), "tool_args");
5193        } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
5194            args_source = "inferred_from_user".to_string();
5195            args_integrity = "recovered".to_string();
5196            query = Some(inferred);
5197            args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
5198        } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
5199            args_source = "recovered_from_context".to_string();
5200            args_integrity = "recovered".to_string();
5201            query = Some(recovered);
5202            args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
5203        } else {
5204            args_source = "missing".to_string();
5205            args_integrity = "empty".to_string();
5206            missing_terminal = true;
5207            missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
5208        }
5209    } else if is_shell_tool_name(&normalized_tool) {
5210        if let Some(command) = extract_shell_command(&args) {
5211            args = set_shell_command(args, command);
5212        } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
5213            args_source = "inferred_from_context".to_string();
5214            args_integrity = "recovered".to_string();
5215            args = set_shell_command(args, inferred);
5216        } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
5217            args_source = "inferred_from_user".to_string();
5218            args_integrity = "recovered".to_string();
5219            args = set_shell_command(args, inferred);
5220        } else {
5221            args_source = "missing".to_string();
5222            args_integrity = "empty".to_string();
5223            missing_terminal = true;
5224            missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
5225        }
5226    } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5227        if let Some(path) = extract_file_path_arg(&args) {
5228            args = set_file_path_arg(args, path);
5229        } else if normalized_tool == "write" || normalized_tool == "edit" {
5230            // Check if the model explicitly provided a non-trivial path argument that was
5231            // rejected by sanitization. In that case, do NOT silently recover with a
5232            // heuristic path — that creates garbage files. Return a terminal error so the
5233            // model can retry with a correct path.
5234            //
5235            // We exclude trivial/placeholder paths ("./", ".", "") because those indicate
5236            // the model didn't actually know the path and recovery is appropriate.
5237            let model_explicit_path_value = args
5238                .as_object()
5239                .and_then(|obj| obj.get("path"))
5240                .and_then(Value::as_str)
5241                .map(str::trim)
5242                .filter(|p| !p.is_empty());
5243            let path_is_trivial_placeholder = model_explicit_path_value
5244                .is_some_and(|p| matches!(p, "./" | "." | ".." | "/" | "~"));
5245            let model_explicitly_set_nontrivial_path = model_explicit_path_value
5246                .is_some_and(|p| p.len() > 2)
5247                && !path_is_trivial_placeholder;
5248            if model_explicitly_set_nontrivial_path {
5249                args_source = "rejected".to_string();
5250                args_integrity = "rejected_path".to_string();
5251                missing_terminal = true;
5252                missing_terminal_reason = Some("WRITE_PATH_REJECTED".to_string());
5253            } else if let Some(inferred) =
5254                infer_required_output_target_path_from_text(latest_user_text).or_else(|| {
5255                    infer_required_output_target_path_from_text(latest_assistant_context)
5256                })
5257            {
5258                args_source = "recovered_from_context".to_string();
5259                args_integrity = "recovered".to_string();
5260                args = set_file_path_arg(args, inferred);
5261            } else if write_path_recovery_mode == WritePathRecoveryMode::Heuristic {
5262                if let Some(inferred) = infer_write_file_path_from_text(latest_user_text) {
5263                    args_source = "inferred_from_user".to_string();
5264                    args_integrity = "recovered".to_string();
5265                    args = set_file_path_arg(args, inferred);
5266                } else {
5267                    args_source = "missing".to_string();
5268                    args_integrity = "empty".to_string();
5269                    missing_terminal = true;
5270                    missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5271                }
5272            } else {
5273                args_source = "missing".to_string();
5274                args_integrity = "empty".to_string();
5275                missing_terminal = true;
5276                missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5277            }
5278        } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
5279            args_source = "inferred_from_user".to_string();
5280            args_integrity = "recovered".to_string();
5281            args = set_file_path_arg(args, inferred);
5282        } else {
5283            args_source = "missing".to_string();
5284            args_integrity = "empty".to_string();
5285            missing_terminal = true;
5286            missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
5287        }
5288
5289        if !missing_terminal && normalized_tool == "write" {
5290            if let Some(content) = extract_write_content_arg(&args) {
5291                args = set_write_content_arg(args, content);
5292            } else if let Some(recovered) =
5293                infer_write_content_from_assistant_context(latest_assistant_context)
5294            {
5295                args_source = "recovered_from_context".to_string();
5296                args_integrity = "recovered".to_string();
5297                args = set_write_content_arg(args, recovered);
5298            } else {
5299                args_source = "missing".to_string();
5300                args_integrity = "empty".to_string();
5301                missing_terminal = true;
5302                missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
5303            }
5304        }
5305    } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
5306        if let Some(url) = extract_webfetch_url_arg(&args) {
5307            args = set_webfetch_url_arg(args, url);
5308        } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
5309            args_source = "inferred_from_context".to_string();
5310            args_integrity = "recovered".to_string();
5311            args = set_webfetch_url_arg(args, inferred);
5312        } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
5313            args_source = "inferred_from_user".to_string();
5314            args_integrity = "recovered".to_string();
5315            args = set_webfetch_url_arg(args, inferred);
5316        } else {
5317            args_source = "missing".to_string();
5318            args_integrity = "empty".to_string();
5319            missing_terminal = true;
5320            missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
5321        }
5322    } else if normalized_tool == "pack_builder" {
5323        let mode = extract_pack_builder_mode_arg(&args);
5324        let plan_id = extract_pack_builder_plan_id_arg(&args);
5325        if mode.as_deref() == Some("apply") && plan_id.is_none() {
5326            if let Some(inferred_plan) =
5327                infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5328            {
5329                args_source = "recovered_from_context".to_string();
5330                args_integrity = "recovered".to_string();
5331                args = set_pack_builder_apply_args(args, inferred_plan);
5332            } else {
5333                args_source = "missing".to_string();
5334                args_integrity = "empty".to_string();
5335                missing_terminal = true;
5336                missing_terminal_reason = Some("PACK_BUILDER_PLAN_ID_MISSING".to_string());
5337            }
5338        } else if mode.as_deref() == Some("apply") {
5339            args = ensure_pack_builder_default_mode(args);
5340        } else if let Some(inferred_plan) =
5341            infer_pack_builder_apply_plan_id(latest_user_text, latest_assistant_context)
5342        {
5343            args_source = "recovered_from_context".to_string();
5344            args_integrity = "recovered".to_string();
5345            args = set_pack_builder_apply_args(args, inferred_plan);
5346        } else if let Some(goal) = extract_pack_builder_goal_arg(&args) {
5347            args = set_pack_builder_goal_arg(args, goal);
5348        } else if let Some(inferred) = infer_pack_builder_goal_from_text(latest_user_text) {
5349            args_source = "inferred_from_user".to_string();
5350            args_integrity = "recovered".to_string();
5351            args = set_pack_builder_goal_arg(args, inferred);
5352        } else if let Some(recovered) = infer_pack_builder_goal_from_text(latest_assistant_context)
5353        {
5354            args_source = "recovered_from_context".to_string();
5355            args_integrity = "recovered".to_string();
5356            args = set_pack_builder_goal_arg(args, recovered);
5357        } else {
5358            args_source = "missing".to_string();
5359            args_integrity = "empty".to_string();
5360            missing_terminal = true;
5361            missing_terminal_reason = Some("PACK_BUILDER_GOAL_MISSING".to_string());
5362        }
5363        args = ensure_pack_builder_default_mode(args);
5364    } else if is_email_delivery_tool_name(&normalized_tool) {
5365        let sanitized = sanitize_email_attachment_args(args);
5366        if sanitized != original_args {
5367            args_source = "sanitized_attachment".to_string();
5368            args_integrity = "recovered".to_string();
5369        }
5370        args = sanitized;
5371    }
5372
5373    NormalizedToolArgs {
5374        args,
5375        args_source,
5376        args_integrity,
5377        raw_args_state,
5378        query,
5379        missing_terminal,
5380        missing_terminal_reason,
5381    }
5382}
5383
5384fn classify_raw_tool_args_state(raw_args: &Value) -> RawToolArgsState {
5385    match raw_args {
5386        Value::Null => RawToolArgsState::Empty,
5387        Value::Object(obj) => {
5388            if obj.is_empty() {
5389                RawToolArgsState::Empty
5390            } else {
5391                RawToolArgsState::Present
5392            }
5393        }
5394        Value::Array(items) => {
5395            if items.is_empty() {
5396                RawToolArgsState::Empty
5397            } else {
5398                RawToolArgsState::Present
5399            }
5400        }
5401        Value::String(raw) => {
5402            let trimmed = raw.trim();
5403            if trimmed.is_empty() {
5404                return RawToolArgsState::Empty;
5405            }
5406            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5407                return classify_raw_tool_args_state(&parsed);
5408            }
5409            if parse_function_style_args(trimmed).is_empty() {
5410                return RawToolArgsState::Unparseable;
5411            }
5412            RawToolArgsState::Present
5413        }
5414        _ => RawToolArgsState::Present,
5415    }
5416}
5417
5418fn args_missing_or_empty(args: &Value) -> bool {
5419    match args {
5420        Value::Null => true,
5421        Value::Object(obj) => obj.is_empty(),
5422        Value::Array(items) => items.is_empty(),
5423        Value::String(raw) => raw.trim().is_empty(),
5424        _ => false,
5425    }
5426}
5427
5428fn persisted_failed_tool_args(raw_args: &Value, normalized_args: &Value) -> Value {
5429    if args_missing_or_empty(raw_args) && !args_missing_or_empty(normalized_args) {
5430        normalized_args.clone()
5431    } else {
5432        raw_args.clone()
5433    }
5434}
5435
5436fn provider_specific_write_reason(
5437    tool: &str,
5438    missing_reason: &str,
5439    raw_args_state: RawToolArgsState,
5440) -> Option<String> {
5441    if tool != "write"
5442        || !matches!(
5443            missing_reason,
5444            "FILE_PATH_MISSING" | "WRITE_CONTENT_MISSING"
5445        )
5446    {
5447        return None;
5448    }
5449    match raw_args_state {
5450        RawToolArgsState::Empty => Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
5451        RawToolArgsState::Unparseable => Some("WRITE_ARGS_UNPARSEABLE_FROM_PROVIDER".to_string()),
5452        RawToolArgsState::Present => None,
5453    }
5454}
5455
5456fn is_shell_tool_name(tool_name: &str) -> bool {
5457    matches!(
5458        tool_name.trim().to_ascii_lowercase().as_str(),
5459        "bash" | "shell" | "powershell" | "cmd"
5460    )
5461}
5462
5463fn email_tool_name_tokens(tool_name: &str) -> Vec<String> {
5464    tool_name
5465        .trim()
5466        .to_ascii_lowercase()
5467        .chars()
5468        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { ' ' })
5469        .collect::<String>()
5470        .split_whitespace()
5471        .map(str::to_string)
5472        .collect::<Vec<_>>()
5473}
5474
5475fn email_tool_name_compact(tool_name: &str) -> String {
5476    tool_name
5477        .trim()
5478        .to_ascii_lowercase()
5479        .chars()
5480        .filter(|ch| ch.is_ascii_alphanumeric())
5481        .collect::<String>()
5482}
5483
5484fn is_email_delivery_tool_name(tool_name: &str) -> bool {
5485    let tokens = email_tool_name_tokens(tool_name);
5486    let compact = email_tool_name_compact(tool_name);
5487    let looks_like_email_provider = tokens.iter().any(|token| {
5488        matches!(
5489            token.as_str(),
5490            "email"
5491                | "mail"
5492                | "gmail"
5493                | "outlook"
5494                | "smtp"
5495                | "imap"
5496                | "inbox"
5497                | "mailbox"
5498                | "mailer"
5499                | "exchange"
5500                | "sendgrid"
5501                | "mailgun"
5502                | "postmark"
5503                | "resend"
5504                | "ses"
5505        )
5506    });
5507    if !looks_like_email_provider {
5508        return false;
5509    }
5510    tokens.iter().any(|token| {
5511        matches!(
5512            token.as_str(),
5513            "send" | "deliver" | "reply" | "draft" | "compose" | "create"
5514        )
5515    }) || compact.contains("sendemail")
5516        || compact.contains("emailsend")
5517        || compact.contains("replyemail")
5518        || compact.contains("emailreply")
5519        || compact.contains("draftemail")
5520        || compact.contains("emaildraft")
5521        || compact.contains("composeemail")
5522        || compact.contains("emailcompose")
5523        || compact.contains("createemaildraft")
5524        || compact.contains("emailcreatedraft")
5525}
5526
5527fn sanitize_email_attachment_args(args: Value) -> Value {
5528    let mut obj = match args {
5529        Value::Object(map) => map,
5530        other => return other,
5531    };
5532    if let Some(Value::Object(attachment)) = obj.get("attachment") {
5533        let s3key = attachment
5534            .get("s3key")
5535            .and_then(Value::as_str)
5536            .map(str::trim)
5537            .unwrap_or("");
5538        if s3key.is_empty() {
5539            obj.remove("attachment");
5540        }
5541    } else if obj.get("attachment").is_some() && obj.get("attachment").is_some_and(Value::is_null) {
5542        obj.remove("attachment");
5543    }
5544    if let Some(Value::Array(attachments)) = obj.get_mut("attachments") {
5545        attachments.retain(|entry| {
5546            entry
5547                .get("s3key")
5548                .and_then(Value::as_str)
5549                .map(str::trim)
5550                .map(|value| !value.is_empty())
5551                .unwrap_or(false)
5552        });
5553        if attachments.is_empty() {
5554            obj.remove("attachments");
5555        }
5556    }
5557    Value::Object(obj)
5558}
5559
5560fn set_file_path_arg(args: Value, path: String) -> Value {
5561    let mut obj = args.as_object().cloned().unwrap_or_default();
5562    obj.insert("path".to_string(), Value::String(path));
5563    Value::Object(obj)
5564}
5565
5566fn normalize_workspace_alias_path(path: &str, workspace_root: &str) -> Option<String> {
5567    let trimmed = path.trim();
5568    if trimmed.is_empty() {
5569        return None;
5570    }
5571    let normalized = trimmed.replace('\\', "/");
5572    if normalized == "/workspace" {
5573        return Some(workspace_root.to_string());
5574    }
5575    if let Some(rest) = normalized.strip_prefix("/workspace/") {
5576        if rest.trim().is_empty() {
5577            return Some(workspace_root.to_string());
5578        }
5579        return Some(rest.trim().to_string());
5580    }
5581    None
5582}
5583
5584fn rewrite_workspace_alias_tool_args(tool: &str, args: Value, workspace_root: &str) -> Value {
5585    let normalized_tool = normalize_tool_name(tool);
5586    if !matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
5587        return args;
5588    }
5589    let Some(path) = extract_file_path_arg(&args) else {
5590        return args;
5591    };
5592    let Some(rewritten) = normalize_workspace_alias_path(&path, workspace_root) else {
5593        return args;
5594    };
5595    set_file_path_arg(args, rewritten)
5596}
5597
5598fn set_write_content_arg(args: Value, content: String) -> Value {
5599    let mut obj = args.as_object().cloned().unwrap_or_default();
5600    obj.insert("content".to_string(), Value::String(content));
5601    Value::Object(obj)
5602}
5603
5604fn extract_file_path_arg(args: &Value) -> Option<String> {
5605    extract_file_path_arg_internal(args, 0)
5606}
5607
5608fn extract_write_content_arg(args: &Value) -> Option<String> {
5609    extract_write_content_arg_internal(args, 0)
5610}
5611
5612fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
5613    if depth > 5 {
5614        return None;
5615    }
5616
5617    match args {
5618        Value::String(raw) => {
5619            let trimmed = raw.trim();
5620            if trimmed.is_empty() {
5621                return None;
5622            }
5623            // If the provider sent plain string args, treat it as a path directly.
5624            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5625                return sanitize_path_candidate(trimmed);
5626            }
5627            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5628                return extract_file_path_arg_internal(&parsed, depth + 1);
5629            }
5630            sanitize_path_candidate(trimmed)
5631        }
5632        Value::Array(items) => items
5633            .iter()
5634            .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
5635        Value::Object(obj) => {
5636            for key in FILE_PATH_KEYS {
5637                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5638                    if let Some(path) = sanitize_path_candidate(raw) {
5639                        return Some(path);
5640                    }
5641                }
5642            }
5643            for container in NESTED_ARGS_KEYS {
5644                if let Some(nested) = obj.get(container) {
5645                    if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
5646                        return Some(path);
5647                    }
5648                }
5649            }
5650            None
5651        }
5652        _ => None,
5653    }
5654}
5655
5656fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
5657    if depth > 5 {
5658        return None;
5659    }
5660
5661    match args {
5662        Value::String(raw) => {
5663            let trimmed = raw.trim();
5664            if trimmed.is_empty() {
5665                return None;
5666            }
5667            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5668                return extract_write_content_arg_internal(&parsed, depth + 1);
5669            }
5670            // Some providers collapse args to a plain string. Recover as content only when
5671            // it does not look like a standalone file path token.
5672            if sanitize_path_candidate(trimmed).is_some()
5673                && !trimmed.contains('\n')
5674                && trimmed.split_whitespace().count() <= 3
5675            {
5676                return None;
5677            }
5678            Some(trimmed.to_string())
5679        }
5680        Value::Array(items) => items
5681            .iter()
5682            .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
5683        Value::Object(obj) => {
5684            for key in WRITE_CONTENT_KEYS {
5685                if let Some(value) = obj.get(key) {
5686                    if let Some(raw) = value.as_str() {
5687                        if !raw.is_empty() {
5688                            return Some(raw.to_string());
5689                        }
5690                    } else if let Some(recovered) =
5691                        extract_write_content_arg_internal(value, depth + 1)
5692                    {
5693                        return Some(recovered);
5694                    }
5695                }
5696            }
5697            for container in NESTED_ARGS_KEYS {
5698                if let Some(nested) = obj.get(container) {
5699                    if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
5700                        return Some(content);
5701                    }
5702                }
5703            }
5704            None
5705        }
5706        _ => None,
5707    }
5708}
5709
5710fn infer_write_content_from_assistant_context(latest_assistant_context: &str) -> Option<String> {
5711    let text = latest_assistant_context.trim();
5712    if text.len() < 32 {
5713        return None;
5714    }
5715    Some(text.to_string())
5716}
5717
5718fn set_shell_command(args: Value, command: String) -> Value {
5719    let mut obj = args.as_object().cloned().unwrap_or_default();
5720    obj.insert("command".to_string(), Value::String(command));
5721    Value::Object(obj)
5722}
5723
5724fn extract_shell_command(args: &Value) -> Option<String> {
5725    extract_shell_command_internal(args, 0)
5726}
5727
5728fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
5729    if depth > 5 {
5730        return None;
5731    }
5732
5733    match args {
5734        Value::String(raw) => {
5735            let trimmed = raw.trim();
5736            if trimmed.is_empty() {
5737                return None;
5738            }
5739            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
5740                return sanitize_shell_command_candidate(trimmed);
5741            }
5742            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
5743                return extract_shell_command_internal(&parsed, depth + 1);
5744            }
5745            sanitize_shell_command_candidate(trimmed)
5746        }
5747        Value::Array(items) => items
5748            .iter()
5749            .find_map(|item| extract_shell_command_internal(item, depth + 1)),
5750        Value::Object(obj) => {
5751            for key in SHELL_COMMAND_KEYS {
5752                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
5753                    if let Some(command) = sanitize_shell_command_candidate(raw) {
5754                        return Some(command);
5755                    }
5756                }
5757            }
5758            for container in NESTED_ARGS_KEYS {
5759                if let Some(nested) = obj.get(container) {
5760                    if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
5761                        return Some(command);
5762                    }
5763                }
5764            }
5765            None
5766        }
5767        _ => None,
5768    }
5769}
5770
5771fn infer_shell_command_from_text(text: &str) -> Option<String> {
5772    let trimmed = text.trim();
5773    if trimmed.is_empty() {
5774        return None;
5775    }
5776
5777    // Prefer explicit backtick commands first.
5778    let mut in_tick = false;
5779    let mut tick_buf = String::new();
5780    for ch in trimmed.chars() {
5781        if ch == '`' {
5782            if in_tick {
5783                if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
5784                    if looks_like_shell_command(&candidate) {
5785                        return Some(candidate);
5786                    }
5787                }
5788                tick_buf.clear();
5789            }
5790            in_tick = !in_tick;
5791            continue;
5792        }
5793        if in_tick {
5794            tick_buf.push(ch);
5795        }
5796    }
5797
5798    for line in trimmed.lines() {
5799        let line = line.trim();
5800        if line.is_empty() {
5801            continue;
5802        }
5803        let lower = line.to_ascii_lowercase();
5804        for prefix in [
5805            "run ",
5806            "execute ",
5807            "call ",
5808            "use bash ",
5809            "use shell ",
5810            "bash ",
5811            "shell ",
5812            "powershell ",
5813            "pwsh ",
5814        ] {
5815            if lower.starts_with(prefix) {
5816                let candidate = line[prefix.len()..].trim();
5817                if let Some(command) = sanitize_shell_command_candidate(candidate) {
5818                    if looks_like_shell_command(&command) {
5819                        return Some(command);
5820                    }
5821                }
5822            }
5823        }
5824    }
5825
5826    None
5827}
5828
5829fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
5830    let mut obj = args.as_object().cloned().unwrap_or_default();
5831    if let Some(q) = query {
5832        obj.insert("query".to_string(), Value::String(q));
5833    }
5834    obj.insert(
5835        "__query_source".to_string(),
5836        Value::String(query_source.to_string()),
5837    );
5838    Value::Object(obj)
5839}
5840
5841fn set_webfetch_url_arg(args: Value, url: String) -> Value {
5842    let mut obj = args.as_object().cloned().unwrap_or_default();
5843    obj.insert("url".to_string(), Value::String(url));
5844    Value::Object(obj)
5845}
5846
5847fn set_pack_builder_goal_arg(args: Value, goal: String) -> Value {
5848    let mut obj = args.as_object().cloned().unwrap_or_default();
5849    obj.insert("goal".to_string(), Value::String(goal));
5850    Value::Object(obj)
5851}
5852
5853fn set_pack_builder_apply_args(args: Value, plan_id: String) -> Value {
5854    let mut obj = args.as_object().cloned().unwrap_or_default();
5855    obj.insert("mode".to_string(), Value::String("apply".to_string()));
5856    obj.insert("plan_id".to_string(), Value::String(plan_id));
5857    obj.insert(
5858        "approve_connector_registration".to_string(),
5859        Value::Bool(true),
5860    );
5861    obj.insert("approve_pack_install".to_string(), Value::Bool(true));
5862    obj.insert("approve_enable_routines".to_string(), Value::Bool(false));
5863    Value::Object(obj)
5864}
5865
5866fn extract_pack_builder_mode_arg(args: &Value) -> Option<String> {
5867    for key in ["mode"] {
5868        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5869            let mode = value.trim().to_ascii_lowercase();
5870            if !mode.is_empty() {
5871                return Some(mode);
5872            }
5873        }
5874    }
5875    for container in ["arguments", "args", "input", "params"] {
5876        if let Some(obj) = args.get(container) {
5877            if let Some(value) = obj.get("mode").and_then(|v| v.as_str()) {
5878                let mode = value.trim().to_ascii_lowercase();
5879                if !mode.is_empty() {
5880                    return Some(mode);
5881                }
5882            }
5883        }
5884    }
5885    None
5886}
5887
5888fn extract_pack_builder_plan_id_arg(args: &Value) -> Option<String> {
5889    for key in ["plan_id", "planId"] {
5890        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
5891            let plan_id = value.trim();
5892            if !plan_id.is_empty() {
5893                return Some(plan_id.to_string());
5894            }
5895        }
5896    }
5897    for container in ["arguments", "args", "input", "params"] {
5898        if let Some(obj) = args.get(container) {
5899            for key in ["plan_id", "planId"] {
5900                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
5901                    let plan_id = value.trim();
5902                    if !plan_id.is_empty() {
5903                        return Some(plan_id.to_string());
5904                    }
5905                }
5906            }
5907        }
5908    }
5909    None
5910}
5911
5912fn extract_pack_builder_plan_id_from_text(text: &str) -> Option<String> {
5913    if text.trim().is_empty() {
5914        return None;
5915    }
5916    let bytes = text.as_bytes();
5917    let mut idx = 0usize;
5918    while idx + 5 <= bytes.len() {
5919        if &bytes[idx..idx + 5] != b"plan-" {
5920            idx += 1;
5921            continue;
5922        }
5923        let mut end = idx + 5;
5924        while end < bytes.len() {
5925            let ch = bytes[end] as char;
5926            if ch.is_ascii_alphanumeric() || ch == '-' {
5927                end += 1;
5928            } else {
5929                break;
5930            }
5931        }
5932        if end > idx + 5 {
5933            let candidate = &text[idx..end];
5934            if candidate.len() >= 10 {
5935                return Some(candidate.to_string());
5936            }
5937        }
5938        idx = end.saturating_add(1);
5939    }
5940    None
5941}
5942
5943fn is_pack_builder_confirmation_text(text: &str) -> bool {
5944    let trimmed = text.trim();
5945    if trimmed.is_empty() {
5946        return false;
5947    }
5948    let lower = trimmed.to_ascii_lowercase();
5949    matches!(
5950        lower.as_str(),
5951        "confirm"
5952            | "confirmed"
5953            | "approve"
5954            | "approved"
5955            | "yes"
5956            | "y"
5957            | "ok"
5958            | "okay"
5959            | "go"
5960            | "go ahead"
5961            | "ship it"
5962            | "do it"
5963            | "apply"
5964            | "run it"
5965            | "✅"
5966            | "👍"
5967    )
5968}
5969
5970fn infer_pack_builder_apply_plan_id(
5971    latest_user_text: &str,
5972    latest_assistant_context: &str,
5973) -> Option<String> {
5974    if let Some(plan_id) = extract_pack_builder_plan_id_from_text(latest_user_text) {
5975        return Some(plan_id);
5976    }
5977    if !is_pack_builder_confirmation_text(latest_user_text) {
5978        return None;
5979    }
5980    extract_pack_builder_plan_id_from_text(latest_assistant_context)
5981}
5982
5983fn ensure_pack_builder_default_mode(args: Value) -> Value {
5984    let mut obj = args.as_object().cloned().unwrap_or_default();
5985    let has_mode = obj
5986        .get("mode")
5987        .and_then(Value::as_str)
5988        .map(str::trim)
5989        .is_some_and(|v| !v.is_empty());
5990    if !has_mode {
5991        obj.insert("mode".to_string(), Value::String("preview".to_string()));
5992    }
5993    Value::Object(obj)
5994}
5995
5996fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
5997    const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
5998    for key in URL_KEYS {
5999        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6000            if let Some(url) = sanitize_url_candidate(value) {
6001                return Some(url);
6002            }
6003        }
6004    }
6005    for container in ["arguments", "args", "input", "params"] {
6006        if let Some(obj) = args.get(container) {
6007            for key in URL_KEYS {
6008                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6009                    if let Some(url) = sanitize_url_candidate(value) {
6010                        return Some(url);
6011                    }
6012                }
6013            }
6014        }
6015    }
6016    args.as_str().and_then(sanitize_url_candidate)
6017}
6018
6019fn extract_pack_builder_goal_arg(args: &Value) -> Option<String> {
6020    const GOAL_KEYS: [&str; 1] = ["goal"];
6021    for key in GOAL_KEYS {
6022        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6023            let trimmed = value.trim();
6024            if !trimmed.is_empty() {
6025                return Some(trimmed.to_string());
6026            }
6027        }
6028    }
6029    for container in ["arguments", "args", "input", "params"] {
6030        if let Some(obj) = args.get(container) {
6031            for key in GOAL_KEYS {
6032                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6033                    let trimmed = value.trim();
6034                    if !trimmed.is_empty() {
6035                        return Some(trimmed.to_string());
6036                    }
6037                }
6038            }
6039        }
6040    }
6041    args.as_str()
6042        .map(str::trim)
6043        .filter(|v| !v.is_empty())
6044        .map(ToString::to_string)
6045}
6046
6047fn extract_websearch_query(args: &Value) -> Option<String> {
6048    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
6049    for key in QUERY_KEYS {
6050        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
6051            if let Some(query) = sanitize_websearch_query_candidate(value) {
6052                return Some(query);
6053            }
6054        }
6055    }
6056    for container in ["arguments", "args", "input", "params"] {
6057        if let Some(obj) = args.get(container) {
6058            for key in QUERY_KEYS {
6059                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
6060                    if let Some(query) = sanitize_websearch_query_candidate(value) {
6061                        return Some(query);
6062                    }
6063                }
6064            }
6065        }
6066    }
6067    args.as_str().and_then(sanitize_websearch_query_candidate)
6068}
6069
6070fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
6071    let trimmed = raw.trim();
6072    if trimmed.is_empty() {
6073        return None;
6074    }
6075
6076    let lower = trimmed.to_ascii_lowercase();
6077    if let Some(start) = lower.find("<arg_value>") {
6078        let value_start = start + "<arg_value>".len();
6079        let tail = &trimmed[value_start..];
6080        let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
6081            &tail[..end]
6082        } else {
6083            tail
6084        };
6085        let cleaned = value.trim();
6086        if !cleaned.is_empty() {
6087            return Some(cleaned.to_string());
6088        }
6089    }
6090
6091    let without_wrappers = trimmed
6092        .replace("<arg_key>", " ")
6093        .replace("</arg_key>", " ")
6094        .replace("<arg_value>", " ")
6095        .replace("</arg_value>", " ");
6096    let collapsed = without_wrappers
6097        .split_whitespace()
6098        .collect::<Vec<_>>()
6099        .join(" ");
6100    if collapsed.is_empty() {
6101        return None;
6102    }
6103
6104    let collapsed_lower = collapsed.to_ascii_lowercase();
6105    if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
6106        let offset = collapsed.len() - rest.len();
6107        let q = collapsed[offset..].trim();
6108        if !q.is_empty() {
6109            return Some(q.to_string());
6110        }
6111    }
6112    if let Some(rest) = collapsed_lower.strip_prefix("query ") {
6113        let offset = collapsed.len() - rest.len();
6114        let q = collapsed[offset..].trim();
6115        if !q.is_empty() {
6116            return Some(q.to_string());
6117        }
6118    }
6119
6120    Some(collapsed)
6121}
6122
6123fn infer_websearch_query_from_text(text: &str) -> Option<String> {
6124    let trimmed = text.trim();
6125    if trimmed.is_empty() {
6126        return None;
6127    }
6128
6129    let lower = trimmed.to_lowercase();
6130    const PREFIXES: [&str; 11] = [
6131        "web search",
6132        "websearch",
6133        "search web for",
6134        "search web",
6135        "search for",
6136        "search",
6137        "look up",
6138        "lookup",
6139        "find",
6140        "web lookup",
6141        "query",
6142    ];
6143
6144    let mut candidate = trimmed;
6145    for prefix in PREFIXES {
6146        if lower.starts_with(prefix) && lower.len() >= prefix.len() {
6147            let remainder = trimmed[prefix.len()..]
6148                .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
6149            candidate = remainder;
6150            break;
6151        }
6152    }
6153
6154    let normalized = candidate
6155        .trim()
6156        .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
6157        .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
6158        .trim()
6159        .to_string();
6160
6161    if normalized.split_whitespace().count() < 2 {
6162        return None;
6163    }
6164    Some(normalized)
6165}
6166
6167fn infer_file_path_from_text(text: &str) -> Option<String> {
6168    let trimmed = text.trim();
6169    if trimmed.is_empty() {
6170        return None;
6171    }
6172
6173    let mut candidates: Vec<String> = Vec::new();
6174
6175    // Prefer backtick-delimited paths when available.
6176    let mut in_tick = false;
6177    let mut tick_buf = String::new();
6178    for ch in trimmed.chars() {
6179        if ch == '`' {
6180            if in_tick {
6181                let cand = sanitize_path_candidate(&tick_buf);
6182                if let Some(path) = cand {
6183                    candidates.push(path);
6184                }
6185                tick_buf.clear();
6186            }
6187            in_tick = !in_tick;
6188            continue;
6189        }
6190        if in_tick {
6191            tick_buf.push(ch);
6192        }
6193    }
6194
6195    // Fallback: scan whitespace tokens.
6196    for raw in trimmed.split_whitespace() {
6197        if let Some(path) = sanitize_path_candidate(raw) {
6198            candidates.push(path);
6199        }
6200    }
6201
6202    let mut deduped = Vec::new();
6203    let mut seen = HashSet::new();
6204    for candidate in candidates {
6205        if seen.insert(candidate.clone()) {
6206            deduped.push(candidate);
6207        }
6208    }
6209
6210    deduped.into_iter().next()
6211}
6212
6213fn infer_workspace_root_from_text(text: &str) -> Option<String> {
6214    text.lines().find_map(|line| {
6215        let trimmed = line.trim();
6216        let value = trimmed.strip_prefix("Workspace:")?.trim();
6217        sanitize_path_candidate(value)
6218    })
6219}
6220
6221fn infer_required_output_target_path_from_text(text: &str) -> Option<String> {
6222    // Format 1: structured JSON marker used by regular sessions
6223    //   "Required output target: {"path": "some-file.md"}"
6224    let marker = "Required output target:";
6225    if let Some(idx) = text.find(marker) {
6226        let tail = text[idx + marker.len()..].trim_start();
6227        if let Some(start) = tail.find('{') {
6228            let json_candidate = tail[start..]
6229                .lines()
6230                .take_while(|line| {
6231                    let trimmed = line.trim();
6232                    !(trimmed.is_empty() && !trimmed.starts_with('{'))
6233                })
6234                .collect::<Vec<_>>()
6235                .join("\n");
6236            if let Ok(parsed) = serde_json::from_str::<Value>(&json_candidate) {
6237                if let Some(path) = parsed.get("path").and_then(|v| v.as_str()) {
6238                    if let Some(clean) = sanitize_explicit_output_target_path(path) {
6239                        return Some(clean);
6240                    }
6241                }
6242            }
6243        }
6244    }
6245    // Format 2: automation prompt "Required Workspace Output" section
6246    //   "Create or update `some-file.md` relative to the workspace root."
6247    let auto_marker = "Create or update `";
6248    if let Some(idx) = text.find(auto_marker) {
6249        let after = &text[idx + auto_marker.len()..];
6250        if let Some(end) = after.find('`') {
6251            let path = after[..end].trim();
6252            if let Some(clean) = sanitize_explicit_output_target_path(path) {
6253                return Some(clean);
6254            }
6255        }
6256    }
6257    None
6258}
6259
6260fn infer_write_file_path_from_text(text: &str) -> Option<String> {
6261    let inferred = infer_file_path_from_text(text)?;
6262    let workspace_root = infer_workspace_root_from_text(text);
6263    if workspace_root
6264        .as_deref()
6265        .is_some_and(|root| root == inferred)
6266    {
6267        return None;
6268    }
6269    Some(inferred)
6270}
6271
6272fn infer_url_from_text(text: &str) -> Option<String> {
6273    let trimmed = text.trim();
6274    if trimmed.is_empty() {
6275        return None;
6276    }
6277
6278    let mut candidates: Vec<String> = Vec::new();
6279
6280    // Prefer backtick-delimited URLs when available.
6281    let mut in_tick = false;
6282    let mut tick_buf = String::new();
6283    for ch in trimmed.chars() {
6284        if ch == '`' {
6285            if in_tick {
6286                if let Some(url) = sanitize_url_candidate(&tick_buf) {
6287                    candidates.push(url);
6288                }
6289                tick_buf.clear();
6290            }
6291            in_tick = !in_tick;
6292            continue;
6293        }
6294        if in_tick {
6295            tick_buf.push(ch);
6296        }
6297    }
6298
6299    // Fallback: scan whitespace tokens.
6300    for raw in trimmed.split_whitespace() {
6301        if let Some(url) = sanitize_url_candidate(raw) {
6302            candidates.push(url);
6303        }
6304    }
6305
6306    let mut seen = HashSet::new();
6307    candidates
6308        .into_iter()
6309        .find(|candidate| seen.insert(candidate.clone()))
6310}
6311
6312fn infer_pack_builder_goal_from_text(text: &str) -> Option<String> {
6313    let trimmed = text.trim();
6314    if trimmed.is_empty() {
6315        None
6316    } else {
6317        Some(trimmed.to_string())
6318    }
6319}
6320
6321fn sanitize_url_candidate(raw: &str) -> Option<String> {
6322    let token = raw
6323        .trim()
6324        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
6325        .trim_start_matches(['(', '[', '{', '<'])
6326        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
6327        .trim_end_matches('.')
6328        .trim();
6329
6330    if token.is_empty() {
6331        return None;
6332    }
6333    let lower = token.to_ascii_lowercase();
6334    if !(lower.starts_with("http://") || lower.starts_with("https://")) {
6335        return None;
6336    }
6337    Some(token.to_string())
6338}
6339
6340fn clean_path_candidate_token(raw: &str) -> Option<String> {
6341    let token = raw.trim();
6342    let token = token.trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'));
6343    let token = token.trim_start_matches(['(', '[', '{', '<']);
6344    let token = token.trim_end_matches([',', ';', ':', ')', ']', '}', '>']);
6345    let token = token.trim_end_matches('.').trim();
6346
6347    if token.is_empty() {
6348        return None;
6349    }
6350    Some(token.to_string())
6351}
6352
6353fn sanitize_explicit_output_target_path(raw: &str) -> Option<String> {
6354    let token = clean_path_candidate_token(raw)?;
6355    let lower = token.to_ascii_lowercase();
6356    if lower.starts_with("http://") || lower.starts_with("https://") {
6357        return None;
6358    }
6359    if is_malformed_tool_path_token(&token) {
6360        return None;
6361    }
6362    if is_root_only_path_token(&token) {
6363        return None;
6364    }
6365    if is_placeholder_path_token(&token) {
6366        return None;
6367    }
6368    if token.ends_with('/') || token.ends_with('\\') {
6369        return None;
6370    }
6371    Some(token.to_string())
6372}
6373
6374fn sanitize_path_candidate(raw: &str) -> Option<String> {
6375    let token = clean_path_candidate_token(raw)?;
6376    let lower = token.to_ascii_lowercase();
6377    if lower.starts_with("http://") || lower.starts_with("https://") {
6378        return None;
6379    }
6380    if is_malformed_tool_path_token(token.as_str()) {
6381        return None;
6382    }
6383    if is_root_only_path_token(token.as_str()) {
6384        return None;
6385    }
6386    if is_placeholder_path_token(token.as_str()) {
6387        return None;
6388    }
6389    if token.ends_with('/') || token.ends_with('\\') {
6390        return None;
6391    }
6392
6393    let looks_like_path = token.contains('/') || token.contains('\\');
6394    let has_file_ext = [
6395        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
6396        ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
6397        ".html", ".htm", ".css", ".scss", ".sass", ".less", ".svg", ".xml", ".sql", ".sh",
6398    ]
6399    .iter()
6400    .any(|ext| lower.ends_with(ext));
6401
6402    if !looks_like_path && !has_file_ext {
6403        return None;
6404    }
6405
6406    Some(token)
6407}
6408
6409fn is_placeholder_path_token(token: &str) -> bool {
6410    let lowered = token.trim().to_ascii_lowercase();
6411    if lowered.is_empty() {
6412        return true;
6413    }
6414    matches!(
6415        lowered.as_str(),
6416        "files/directories"
6417            | "file/directory"
6418            | "relative/or/absolute/path"
6419            | "path/to/file"
6420            | "path/to/your/file"
6421            | "tool/policy"
6422            | "tools/policy"
6423            | "the expected artifact file"
6424            | "workspace/file"
6425    )
6426}
6427
6428fn is_malformed_tool_path_token(token: &str) -> bool {
6429    let lower = token.to_ascii_lowercase();
6430    // XML-ish tool-call wrappers emitted by some model responses.
6431    if lower.contains("<tool_call")
6432        || lower.contains("</tool_call")
6433        || lower.contains("<function=")
6434        || lower.contains("<parameter=")
6435        || lower.contains("</function>")
6436        || lower.contains("</parameter>")
6437    {
6438        return true;
6439    }
6440    // Multiline payloads are not valid single file paths.
6441    if token.contains('\n') || token.contains('\r') {
6442        return true;
6443    }
6444    // Glob patterns are not concrete file paths for read/write/edit.
6445    if token.contains('*') || token.contains('?') {
6446        return true;
6447    }
6448    // Context object IDs from runtime_context_partition bindings are not file paths.
6449    // These look like "ctx:wfplan-...:assess:assess.artifact" and the model sometimes
6450    // confuses them for filesystem paths.
6451    if lower.starts_with("ctx:") {
6452        return true;
6453    }
6454    // Colon-separated identifiers that look like context bindings (e.g. "routine:assess:artifact")
6455    // but aren't Windows drive paths (which are caught above).
6456    if token.matches(':').count() >= 2 {
6457        return true;
6458    }
6459    false
6460}
6461
6462fn is_root_only_path_token(token: &str) -> bool {
6463    let trimmed = token.trim();
6464    if trimmed.is_empty() {
6465        return true;
6466    }
6467    if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
6468        return true;
6469    }
6470    // Windows drive root placeholders, e.g. `C:` or `C:\`.
6471    let bytes = trimmed.as_bytes();
6472    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
6473        return true;
6474    }
6475    if bytes.len() == 3
6476        && bytes[1] == b':'
6477        && (bytes[0] as char).is_ascii_alphabetic()
6478        && (bytes[2] == b'\\' || bytes[2] == b'/')
6479    {
6480        return true;
6481    }
6482    false
6483}
6484
6485fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
6486    let token = raw
6487        .trim()
6488        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
6489        .trim();
6490    if token.is_empty() {
6491        return None;
6492    }
6493    Some(token.to_string())
6494}
6495
6496fn looks_like_shell_command(candidate: &str) -> bool {
6497    let lower = candidate.to_ascii_lowercase();
6498    if lower.is_empty() {
6499        return false;
6500    }
6501    let first = lower.split_whitespace().next().unwrap_or_default();
6502    let common = [
6503        "rg",
6504        "git",
6505        "cargo",
6506        "pnpm",
6507        "npm",
6508        "node",
6509        "python",
6510        "pytest",
6511        "pwsh",
6512        "powershell",
6513        "cmd",
6514        "dir",
6515        "ls",
6516        "cat",
6517        "type",
6518        "echo",
6519        "cd",
6520        "mkdir",
6521        "cp",
6522        "copy",
6523        "move",
6524        "del",
6525        "rm",
6526    ];
6527    common.contains(&first)
6528        || first.starts_with("get-")
6529        || first.starts_with("./")
6530        || first.starts_with(".\\")
6531        || lower.contains(" | ")
6532        || lower.contains(" && ")
6533        || lower.contains(" ; ")
6534}
6535
6536const FILE_PATH_KEYS: [&str; 10] = [
6537    "path",
6538    "file_path",
6539    "filePath",
6540    "filepath",
6541    "filename",
6542    "file",
6543    "target",
6544    "targetFile",
6545    "absolutePath",
6546    "uri",
6547];
6548
6549const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
6550
6551const WRITE_CONTENT_KEYS: [&str; 8] = [
6552    "content",
6553    "text",
6554    "body",
6555    "value",
6556    "markdown",
6557    "document",
6558    "output",
6559    "file_content",
6560];
6561
6562const NESTED_ARGS_KEYS: [&str; 10] = [
6563    "arguments",
6564    "args",
6565    "input",
6566    "params",
6567    "payload",
6568    "data",
6569    "tool_input",
6570    "toolInput",
6571    "tool_args",
6572    "toolArgs",
6573];
6574
6575fn tool_signature(tool_name: &str, args: &Value) -> String {
6576    let normalized = normalize_tool_name(tool_name);
6577    if normalized == "websearch" {
6578        let query = extract_websearch_query(args)
6579            .unwrap_or_default()
6580            .to_lowercase();
6581        let limit = args
6582            .get("limit")
6583            .or_else(|| args.get("numResults"))
6584            .or_else(|| args.get("num_results"))
6585            .and_then(|v| v.as_u64())
6586            .unwrap_or(8);
6587        let domains = args
6588            .get("domains")
6589            .or_else(|| args.get("domain"))
6590            .map(|v| v.to_string())
6591            .unwrap_or_default();
6592        let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
6593        return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
6594    }
6595    format!("{}:{}", normalized, args)
6596}
6597
6598fn stable_hash(input: &str) -> String {
6599    let mut hasher = DefaultHasher::new();
6600    input.hash(&mut hasher);
6601    format!("{:016x}", hasher.finish())
6602}
6603
6604fn summarize_tool_outputs(outputs: &[String]) -> String {
6605    outputs
6606        .iter()
6607        .take(6)
6608        .map(|output| truncate_text(output, 600))
6609        .collect::<Vec<_>>()
6610        .join("\n\n")
6611}
6612
6613fn is_os_mismatch_tool_output(output: &str) -> bool {
6614    let lower = output.to_ascii_lowercase();
6615    lower.contains("os error 3")
6616        || lower.contains("system cannot find the path specified")
6617        || lower.contains("command not found")
6618        || lower.contains("is not recognized as an internal or external command")
6619        || lower.contains("shell command blocked on windows")
6620}
6621
6622fn format_context_mode(requested: &ContextMode, auto_compact: bool) -> &'static str {
6623    match requested {
6624        ContextMode::Full => "full",
6625        ContextMode::Compact => "compact",
6626        ContextMode::Auto => {
6627            if auto_compact {
6628                "auto_compact"
6629            } else {
6630                "auto_standard"
6631            }
6632        }
6633    }
6634}
6635
6636fn tandem_runtime_system_prompt(host: &HostRuntimeContext, mcp_server_names: &[String]) -> String {
6637    let mut sections = Vec::new();
6638    if os_aware_prompts_enabled() {
6639        sections.push(format!(
6640            "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
6641            host_os_label(host.os),
6642            shell_family_label(host.shell_family),
6643            path_style_label(host.path_style),
6644            host.arch
6645        ));
6646    }
6647    sections.push(
6648        "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
6649Use tool calls to inspect and modify the workspace when needed instead of asking the user
6650to manually run basic discovery steps. Permission prompts may occur for some tools; if
6651a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
6652            .to_string(),
6653    );
6654    sections.push(
6655        "For greetings or simple conversational messages (for example: hi, hello, thanks),
6656respond directly without calling tools."
6657            .to_string(),
6658    );
6659    if host.os == HostOs::Windows {
6660        sections.push(
6661            "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
6662Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
6663If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
6664                .to_string(),
6665        );
6666    } else {
6667        sections.push(
6668            "POSIX guidance: standard shell commands are available.
6669Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
6670                .to_string(),
6671        );
6672    }
6673    if !mcp_server_names.is_empty() {
6674        let cap = mcp_catalog_max_servers();
6675        let mut listed = mcp_server_names
6676            .iter()
6677            .take(cap)
6678            .cloned()
6679            .collect::<Vec<_>>();
6680        listed.sort();
6681        let mut catalog = listed
6682            .iter()
6683            .map(|name| format!("- {name}"))
6684            .collect::<Vec<_>>();
6685        if mcp_server_names.len() > cap {
6686            catalog.push(format!("- (+{} more)", mcp_server_names.len() - cap));
6687        }
6688        sections.push(format!(
6689            "[Connected Integrations]\nThe following external integrations are currently connected and available:\n{}",
6690            catalog.join("\n")
6691        ));
6692    }
6693    sections.join("\n\n")
6694}
6695
6696fn os_aware_prompts_enabled() -> bool {
6697    std::env::var("TANDEM_OS_AWARE_PROMPTS")
6698        .ok()
6699        .map(|v| {
6700            let normalized = v.trim().to_ascii_lowercase();
6701            !(normalized == "0" || normalized == "false" || normalized == "off")
6702        })
6703        .unwrap_or(true)
6704}
6705
6706fn semantic_tool_retrieval_enabled() -> bool {
6707    std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL")
6708        .ok()
6709        .map(|raw| {
6710            !matches!(
6711                raw.trim().to_ascii_lowercase().as_str(),
6712                "0" | "false" | "off" | "no"
6713            )
6714        })
6715        .unwrap_or(true)
6716}
6717
6718fn semantic_tool_retrieval_k() -> usize {
6719    std::env::var("TANDEM_SEMANTIC_TOOL_RETRIEVAL_K")
6720        .ok()
6721        .and_then(|raw| raw.trim().parse::<usize>().ok())
6722        .filter(|value| *value > 0)
6723        .unwrap_or_else(max_tools_per_call_expanded)
6724}
6725
6726fn mcp_catalog_in_system_prompt_enabled() -> bool {
6727    std::env::var("TANDEM_MCP_CATALOG_IN_SYSTEM_PROMPT")
6728        .ok()
6729        .map(|raw| {
6730            !matches!(
6731                raw.trim().to_ascii_lowercase().as_str(),
6732                "0" | "false" | "off" | "no"
6733            )
6734        })
6735        .unwrap_or(true)
6736}
6737
6738fn mcp_catalog_max_servers() -> usize {
6739    std::env::var("TANDEM_MCP_CATALOG_MAX_SERVERS")
6740        .ok()
6741        .and_then(|raw| raw.trim().parse::<usize>().ok())
6742        .filter(|value| *value > 0)
6743        .unwrap_or(20)
6744}
6745
6746fn host_os_label(os: HostOs) -> &'static str {
6747    match os {
6748        HostOs::Windows => "windows",
6749        HostOs::Linux => "linux",
6750        HostOs::Macos => "macos",
6751    }
6752}
6753
6754fn shell_family_label(shell: ShellFamily) -> &'static str {
6755    match shell {
6756        ShellFamily::Powershell => "powershell",
6757        ShellFamily::Posix => "posix",
6758    }
6759}
6760
6761fn path_style_label(path_style: PathStyle) -> &'static str {
6762    match path_style {
6763        PathStyle::Windows => "windows",
6764        PathStyle::Posix => "posix",
6765    }
6766}
6767
6768fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
6769    let user = user_text.to_lowercase();
6770    let reply = completion.to_lowercase();
6771
6772    let asked_for_project_context = [
6773        "what is this project",
6774        "what's this project",
6775        "what project is this",
6776        "explain this project",
6777        "analyze this project",
6778        "inspect this project",
6779        "look at the project",
6780        "summarize this project",
6781        "show me this project",
6782        "what files are in",
6783        "show files",
6784        "list files",
6785        "read files",
6786        "browse files",
6787        "use glob",
6788        "run glob",
6789    ]
6790    .iter()
6791    .any(|needle| user.contains(needle));
6792
6793    if !asked_for_project_context {
6794        return false;
6795    }
6796
6797    let assistant_claimed_no_access = [
6798        "can't inspect",
6799        "cannot inspect",
6800        "unable to inspect",
6801        "unable to directly inspect",
6802        "can't access",
6803        "cannot access",
6804        "unable to access",
6805        "can't read files",
6806        "cannot read files",
6807        "unable to read files",
6808        "tool restriction",
6809        "tool restrictions",
6810        "don't have visibility",
6811        "no visibility",
6812        "haven't been able to inspect",
6813        "i don't know what this project is",
6814        "need your help to",
6815        "sandbox",
6816        "restriction",
6817        "system restriction",
6818        "permissions restrictions",
6819    ]
6820    .iter()
6821    .any(|needle| reply.contains(needle));
6822
6823    // If the user is explicitly asking for project inspection and the model replies with
6824    // a no-access narrative instead of making a tool call, force a minimal read-only probe.
6825    asked_for_project_context && assistant_claimed_no_access
6826}
6827
6828fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
6829    let raw = input.trim();
6830    if !raw.starts_with("/tool ") {
6831        return None;
6832    }
6833    let rest = raw.trim_start_matches("/tool ").trim();
6834    let mut split = rest.splitn(2, ' ');
6835    let tool = normalize_tool_name(split.next()?.trim());
6836    let args = split
6837        .next()
6838        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
6839        .unwrap_or_else(|| json!({}));
6840    Some((tool, args))
6841}
6842
6843fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
6844    let trimmed = input.trim();
6845    if trimmed.is_empty() {
6846        return Vec::new();
6847    }
6848
6849    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
6850        if let Some(found) = extract_tool_call_from_value(&parsed) {
6851            return vec![found];
6852        }
6853    }
6854
6855    if let Some(block) = extract_first_json_object(trimmed) {
6856        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
6857            if let Some(found) = extract_tool_call_from_value(&parsed) {
6858                return vec![found];
6859            }
6860        }
6861    }
6862
6863    parse_function_style_tool_calls(trimmed)
6864}
6865
6866#[cfg(test)]
6867fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
6868    parse_tool_invocations_from_response(input)
6869        .into_iter()
6870        .next()
6871}
6872
6873fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
6874    let mut calls = Vec::new();
6875    let lower = input.to_lowercase();
6876    let names = [
6877        "todo_write",
6878        "todowrite",
6879        "update_todo_list",
6880        "update_todos",
6881    ];
6882    let mut cursor = 0usize;
6883
6884    while cursor < lower.len() {
6885        let mut best: Option<(usize, &str)> = None;
6886        for name in names {
6887            let needle = format!("{name}(");
6888            if let Some(rel_idx) = lower[cursor..].find(&needle) {
6889                let idx = cursor + rel_idx;
6890                if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
6891                    best = Some((idx, name));
6892                }
6893            }
6894        }
6895
6896        let Some((tool_start, tool_name)) = best else {
6897            break;
6898        };
6899
6900        let open_paren = tool_start + tool_name.len();
6901        if let Some(close_paren) = find_matching_paren(input, open_paren) {
6902            if let Some(args_text) = input.get(open_paren + 1..close_paren) {
6903                let args = parse_function_style_args(args_text.trim());
6904                calls.push((normalize_tool_name(tool_name), Value::Object(args)));
6905            }
6906            cursor = close_paren.saturating_add(1);
6907        } else {
6908            cursor = tool_start.saturating_add(tool_name.len());
6909        }
6910    }
6911
6912    calls
6913}
6914
6915fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
6916    if input.as_bytes().get(open_paren).copied()? != b'(' {
6917        return None;
6918    }
6919
6920    let mut depth = 0usize;
6921    let mut in_single = false;
6922    let mut in_double = false;
6923    let mut escaped = false;
6924
6925    for (offset, ch) in input.get(open_paren..)?.char_indices() {
6926        if escaped {
6927            escaped = false;
6928            continue;
6929        }
6930        if ch == '\\' && (in_single || in_double) {
6931            escaped = true;
6932            continue;
6933        }
6934        if ch == '\'' && !in_double {
6935            in_single = !in_single;
6936            continue;
6937        }
6938        if ch == '"' && !in_single {
6939            in_double = !in_double;
6940            continue;
6941        }
6942        if in_single || in_double {
6943            continue;
6944        }
6945
6946        match ch {
6947            '(' => depth += 1,
6948            ')' => {
6949                depth = depth.saturating_sub(1);
6950                if depth == 0 {
6951                    return Some(open_paren + offset);
6952                }
6953            }
6954            _ => {}
6955        }
6956    }
6957
6958    None
6959}
6960
6961fn parse_function_style_args(input: &str) -> Map<String, Value> {
6962    let mut args = Map::new();
6963    if input.trim().is_empty() {
6964        return args;
6965    }
6966
6967    let mut parts = Vec::<String>::new();
6968    let mut current = String::new();
6969    let mut in_single = false;
6970    let mut in_double = false;
6971    let mut escaped = false;
6972    let mut depth_paren = 0usize;
6973    let mut depth_bracket = 0usize;
6974    let mut depth_brace = 0usize;
6975
6976    for ch in input.chars() {
6977        if escaped {
6978            current.push(ch);
6979            escaped = false;
6980            continue;
6981        }
6982        if ch == '\\' && (in_single || in_double) {
6983            current.push(ch);
6984            escaped = true;
6985            continue;
6986        }
6987        if ch == '\'' && !in_double {
6988            in_single = !in_single;
6989            current.push(ch);
6990            continue;
6991        }
6992        if ch == '"' && !in_single {
6993            in_double = !in_double;
6994            current.push(ch);
6995            continue;
6996        }
6997        if in_single || in_double {
6998            current.push(ch);
6999            continue;
7000        }
7001
7002        match ch {
7003            '(' => depth_paren += 1,
7004            ')' => depth_paren = depth_paren.saturating_sub(1),
7005            '[' => depth_bracket += 1,
7006            ']' => depth_bracket = depth_bracket.saturating_sub(1),
7007            '{' => depth_brace += 1,
7008            '}' => depth_brace = depth_brace.saturating_sub(1),
7009            ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
7010                let part = current.trim();
7011                if !part.is_empty() {
7012                    parts.push(part.to_string());
7013                }
7014                current.clear();
7015                continue;
7016            }
7017            _ => {}
7018        }
7019        current.push(ch);
7020    }
7021    let tail = current.trim();
7022    if !tail.is_empty() {
7023        parts.push(tail.to_string());
7024    }
7025
7026    for part in parts {
7027        let Some((raw_key, raw_value)) = part
7028            .split_once('=')
7029            .or_else(|| part.split_once(':'))
7030            .map(|(k, v)| (k.trim(), v.trim()))
7031        else {
7032            continue;
7033        };
7034        let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
7035        if key.is_empty() {
7036            continue;
7037        }
7038        if !is_valid_function_style_key(key) {
7039            continue;
7040        }
7041        let value = parse_scalar_like_value(raw_value);
7042        args.insert(key.to_string(), value);
7043    }
7044
7045    args
7046}
7047
7048fn is_valid_function_style_key(key: &str) -> bool {
7049    // Accept common tool-style identifiers such as `path`, `allow_empty`,
7050    // `search.query`, and `arg-name`, but reject malformed fragments that
7051    // commonly come from broken JSON streams (e.g. `{"allow_empty`).
7052    let mut chars = key.chars();
7053    let Some(first) = chars.next() else {
7054        return false;
7055    };
7056    if !(first.is_ascii_alphanumeric() || first == '_') {
7057        return false;
7058    }
7059    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
7060}
7061
7062fn parse_scalar_like_value(raw: &str) -> Value {
7063    let trimmed = raw.trim();
7064    if trimmed.is_empty() {
7065        return Value::Null;
7066    }
7067
7068    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
7069        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
7070    {
7071        if trimmed.len() < 2 {
7072            return Value::String(trimmed.to_string());
7073        }
7074        return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
7075    }
7076
7077    if trimmed.eq_ignore_ascii_case("true") {
7078        return Value::Bool(true);
7079    }
7080    if trimmed.eq_ignore_ascii_case("false") {
7081        return Value::Bool(false);
7082    }
7083    if trimmed.eq_ignore_ascii_case("null") {
7084        return Value::Null;
7085    }
7086
7087    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
7088        return v;
7089    }
7090    if let Ok(v) = trimmed.parse::<i64>() {
7091        return Value::Number(Number::from(v));
7092    }
7093    if let Ok(v) = trimmed.parse::<f64>() {
7094        if let Some(n) = Number::from_f64(v) {
7095            return Value::Number(n);
7096        }
7097    }
7098
7099    Value::String(trimmed.to_string())
7100}
7101
7102fn recover_write_args_from_malformed_json(raw: &str) -> Option<Value> {
7103    let content = extract_loose_json_string_field(raw, "content")?;
7104    let mut obj = Map::new();
7105    if let Some(path) = extract_loose_json_string_field(raw, "path") {
7106        obj.insert("path".to_string(), Value::String(path));
7107    }
7108    obj.insert("content".to_string(), Value::String(content));
7109    Some(Value::Object(obj))
7110}
7111
7112fn extract_loose_json_string_field(input: &str, key: &str) -> Option<String> {
7113    let pattern = format!("\"{key}\"");
7114    let start = input.find(&pattern)?;
7115    let remainder = input.get(start + pattern.len()..)?;
7116    let colon = remainder.find(':')?;
7117    let value = remainder.get(colon + 1..)?.trim_start();
7118    let value = value.strip_prefix('"')?;
7119    Some(parse_loose_json_string_value(value))
7120}
7121
7122fn parse_loose_json_string_value(input: &str) -> String {
7123    let mut out = String::new();
7124    let mut chars = input.chars().peekable();
7125    let mut closed = false;
7126
7127    while let Some(ch) = chars.next() {
7128        if ch == '"' {
7129            closed = true;
7130            break;
7131        }
7132        if ch != '\\' {
7133            out.push(ch);
7134            continue;
7135        }
7136
7137        let Some(escaped) = chars.next() else {
7138            out.push('\\');
7139            break;
7140        };
7141        match escaped {
7142            '"' => out.push('"'),
7143            '\\' => out.push('\\'),
7144            '/' => out.push('/'),
7145            'b' => out.push('\u{0008}'),
7146            'f' => out.push('\u{000C}'),
7147            'n' => out.push('\n'),
7148            'r' => out.push('\r'),
7149            't' => out.push('\t'),
7150            'u' => {
7151                let mut hex = String::new();
7152                for _ in 0..4 {
7153                    let Some(next) = chars.next() else {
7154                        break;
7155                    };
7156                    hex.push(next);
7157                }
7158                if hex.len() == 4 {
7159                    if let Ok(codepoint) = u16::from_str_radix(&hex, 16) {
7160                        if let Some(decoded) = char::from_u32(codepoint as u32) {
7161                            out.push(decoded);
7162                            continue;
7163                        }
7164                    }
7165                }
7166                out.push('\\');
7167                out.push('u');
7168                out.push_str(&hex);
7169            }
7170            other => {
7171                out.push('\\');
7172                out.push(other);
7173            }
7174        }
7175    }
7176
7177    if !closed {
7178        return out;
7179    }
7180    out
7181}
7182
7183fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
7184    if is_todo_status_update_args(&args) {
7185        return args;
7186    }
7187
7188    let mut obj = match args {
7189        Value::Object(map) => map,
7190        Value::Array(items) => {
7191            return json!({ "todos": normalize_todo_arg_items(items) });
7192        }
7193        Value::String(text) => {
7194            let derived = extract_todo_candidates_from_text(&text);
7195            if !derived.is_empty() {
7196                return json!({ "todos": derived });
7197            }
7198            return json!({});
7199        }
7200        _ => return json!({}),
7201    };
7202
7203    if obj
7204        .get("todos")
7205        .and_then(|v| v.as_array())
7206        .map(|arr| !arr.is_empty())
7207        .unwrap_or(false)
7208    {
7209        return Value::Object(obj);
7210    }
7211
7212    for alias in ["tasks", "items", "list", "checklist"] {
7213        if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
7214            let normalized = normalize_todo_arg_items(items.clone());
7215            if !normalized.is_empty() {
7216                obj.insert("todos".to_string(), Value::Array(normalized));
7217                return Value::Object(obj);
7218            }
7219        }
7220    }
7221
7222    let derived = extract_todo_candidates_from_text(completion);
7223    if !derived.is_empty() {
7224        obj.insert("todos".to_string(), Value::Array(derived));
7225    }
7226    Value::Object(obj)
7227}
7228
7229fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
7230    items
7231        .into_iter()
7232        .filter_map(|item| match item {
7233            Value::String(text) => {
7234                let content = text.trim();
7235                if content.is_empty() {
7236                    None
7237                } else {
7238                    Some(json!({"content": content}))
7239                }
7240            }
7241            Value::Object(mut obj) => {
7242                if !obj.contains_key("content") {
7243                    if let Some(text) = obj.get("text").cloned() {
7244                        obj.insert("content".to_string(), text);
7245                    } else if let Some(title) = obj.get("title").cloned() {
7246                        obj.insert("content".to_string(), title);
7247                    } else if let Some(name) = obj.get("name").cloned() {
7248                        obj.insert("content".to_string(), name);
7249                    }
7250                }
7251                let content = obj
7252                    .get("content")
7253                    .and_then(|v| v.as_str())
7254                    .map(str::trim)
7255                    .unwrap_or("");
7256                if content.is_empty() {
7257                    None
7258                } else {
7259                    Some(Value::Object(obj))
7260                }
7261            }
7262            _ => None,
7263        })
7264        .collect()
7265}
7266
7267fn is_todo_status_update_args(args: &Value) -> bool {
7268    let Some(obj) = args.as_object() else {
7269        return false;
7270    };
7271    let has_status = obj
7272        .get("status")
7273        .and_then(|v| v.as_str())
7274        .map(|s| !s.trim().is_empty())
7275        .unwrap_or(false);
7276    let has_target =
7277        obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
7278    has_status && has_target
7279}
7280
7281fn is_empty_todo_write_args(args: &Value) -> bool {
7282    if is_todo_status_update_args(args) {
7283        return false;
7284    }
7285    let Some(obj) = args.as_object() else {
7286        return true;
7287    };
7288    !obj.get("todos")
7289        .and_then(|v| v.as_array())
7290        .map(|arr| !arr.is_empty())
7291        .unwrap_or(false)
7292}
7293
7294fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
7295    let trimmed = raw_args.trim();
7296    if trimmed.is_empty() {
7297        return json!({});
7298    }
7299
7300    let normalized_tool = normalize_tool_name(tool_name);
7301    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
7302        return normalize_streamed_tool_args(&normalized_tool, parsed, trimmed);
7303    }
7304
7305    if normalized_tool == "write" {
7306        if let Some(recovered) = recover_write_args_from_malformed_json(trimmed) {
7307            return recovered;
7308        }
7309    }
7310
7311    // Some providers emit non-JSON argument text (for example: raw query strings
7312    // or key=value fragments). Recover the common forms instead of dropping to {}.
7313    let kv_args = parse_function_style_args(trimmed);
7314    if !kv_args.is_empty() {
7315        return normalize_streamed_tool_args(&normalized_tool, Value::Object(kv_args), trimmed);
7316    }
7317
7318    if normalized_tool == "websearch" {
7319        if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
7320            return json!({ "query": query });
7321        }
7322        return json!({});
7323    }
7324
7325    Value::String(trimmed.to_string())
7326}
7327
7328fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
7329    let normalized_tool = normalize_tool_name(tool_name);
7330    if normalized_tool != "websearch" {
7331        return parsed;
7332    }
7333
7334    match parsed {
7335        Value::Object(mut obj) => {
7336            if !has_websearch_query(&obj) && !raw.trim().is_empty() {
7337                if let Some(query) = sanitize_websearch_query_candidate(raw) {
7338                    obj.insert("query".to_string(), Value::String(query));
7339                }
7340            }
7341            Value::Object(obj)
7342        }
7343        Value::String(s) => match sanitize_websearch_query_candidate(&s) {
7344            Some(query) => json!({ "query": query }),
7345            None => json!({}),
7346        },
7347        other => other,
7348    }
7349}
7350
7351fn has_websearch_query(obj: &Map<String, Value>) -> bool {
7352    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
7353    QUERY_KEYS.iter().any(|key| {
7354        obj.get(*key)
7355            .and_then(|v| v.as_str())
7356            .map(|s| !s.trim().is_empty())
7357            .unwrap_or(false)
7358    })
7359}
7360
7361fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
7362    if let Some(obj) = value.as_object() {
7363        if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
7364            return Some((
7365                normalize_tool_name(tool),
7366                obj.get("args").cloned().unwrap_or_else(|| json!({})),
7367            ));
7368        }
7369
7370        if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
7371            let args = obj
7372                .get("args")
7373                .cloned()
7374                .or_else(|| obj.get("arguments").cloned())
7375                .unwrap_or_else(|| json!({}));
7376            let normalized_tool = normalize_tool_name(tool);
7377            let args = if let Some(raw) = args.as_str() {
7378                parse_streamed_tool_args(&normalized_tool, raw)
7379            } else {
7380                args
7381            };
7382            return Some((normalized_tool, args));
7383        }
7384
7385        for key in [
7386            "tool_call",
7387            "toolCall",
7388            "call",
7389            "function_call",
7390            "functionCall",
7391        ] {
7392            if let Some(nested) = obj.get(key) {
7393                if let Some(found) = extract_tool_call_from_value(nested) {
7394                    return Some(found);
7395                }
7396            }
7397        }
7398
7399        if let Some(calls) = obj.get("tool_calls").and_then(|v| v.as_array()) {
7400            for call in calls {
7401                if let Some(found) = extract_tool_call_from_value(call) {
7402                    return Some(found);
7403                }
7404            }
7405        }
7406    }
7407
7408    if let Some(items) = value.as_array() {
7409        for item in items {
7410            if let Some(found) = extract_tool_call_from_value(item) {
7411                return Some(found);
7412            }
7413        }
7414    }
7415
7416    None
7417}
7418
7419fn extract_first_json_object(input: &str) -> Option<String> {
7420    let mut start = None;
7421    let mut depth = 0usize;
7422    for (idx, ch) in input.char_indices() {
7423        if ch == '{' {
7424            if start.is_none() {
7425                start = Some(idx);
7426            }
7427            depth += 1;
7428        } else if ch == '}' {
7429            if depth == 0 {
7430                continue;
7431            }
7432            depth -= 1;
7433            if depth == 0 {
7434                let begin = start?;
7435                let block = input.get(begin..=idx)?;
7436                return Some(block.to_string());
7437            }
7438        }
7439    }
7440    None
7441}
7442
7443fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
7444    let mut seen = HashSet::<String>::new();
7445    let mut todos = Vec::new();
7446
7447    for raw_line in input.lines() {
7448        let mut line = raw_line.trim();
7449        let mut structured_line = false;
7450        if line.is_empty() {
7451            continue;
7452        }
7453        if line.starts_with("```") {
7454            continue;
7455        }
7456        if line.ends_with(':') {
7457            continue;
7458        }
7459        if let Some(rest) = line
7460            .strip_prefix("- [ ]")
7461            .or_else(|| line.strip_prefix("* [ ]"))
7462            .or_else(|| line.strip_prefix("- [x]"))
7463            .or_else(|| line.strip_prefix("* [x]"))
7464        {
7465            line = rest.trim();
7466            structured_line = true;
7467        } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
7468            line = rest.trim();
7469            structured_line = true;
7470        } else {
7471            let bytes = line.as_bytes();
7472            let mut i = 0usize;
7473            while i < bytes.len() && bytes[i].is_ascii_digit() {
7474                i += 1;
7475            }
7476            if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
7477                line = line[i + 1..].trim();
7478                structured_line = true;
7479            }
7480        }
7481        if !structured_line {
7482            continue;
7483        }
7484
7485        let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
7486        if content.len() < 5 || content.len() > 180 {
7487            continue;
7488        }
7489        let key = content.to_lowercase();
7490        if seen.contains(&key) {
7491            continue;
7492        }
7493        seen.insert(key);
7494        todos.push(json!({ "content": content }));
7495        if todos.len() >= 25 {
7496            break;
7497        }
7498    }
7499
7500    todos
7501}
7502
7503async fn emit_plan_todo_fallback(
7504    storage: std::sync::Arc<Storage>,
7505    bus: &EventBus,
7506    session_id: &str,
7507    message_id: &str,
7508    completion: &str,
7509) {
7510    let todos = extract_todo_candidates_from_text(completion);
7511    if todos.is_empty() {
7512        return;
7513    }
7514
7515    let invoke_part = WireMessagePart::tool_invocation(
7516        session_id,
7517        message_id,
7518        "todo_write",
7519        json!({"todos": todos.clone()}),
7520    );
7521    let call_id = invoke_part.id.clone();
7522    bus.publish(EngineEvent::new(
7523        "message.part.updated",
7524        json!({"part": invoke_part}),
7525    ));
7526
7527    if storage.set_todos(session_id, todos.clone()).await.is_err() {
7528        let mut failed_part = WireMessagePart::tool_result(
7529            session_id,
7530            message_id,
7531            "todo_write",
7532            Some(json!({"todos": todos.clone()})),
7533            json!(null),
7534        );
7535        failed_part.id = call_id;
7536        failed_part.state = Some("failed".to_string());
7537        failed_part.error = Some("failed to persist plan todos".to_string());
7538        bus.publish(EngineEvent::new(
7539            "message.part.updated",
7540            json!({"part": failed_part}),
7541        ));
7542        return;
7543    }
7544
7545    let normalized = storage.get_todos(session_id).await;
7546    let mut result_part = WireMessagePart::tool_result(
7547        session_id,
7548        message_id,
7549        "todo_write",
7550        Some(json!({"todos": todos.clone()})),
7551        json!({ "todos": normalized }),
7552    );
7553    result_part.id = call_id;
7554    bus.publish(EngineEvent::new(
7555        "message.part.updated",
7556        json!({"part": result_part}),
7557    ));
7558    bus.publish(EngineEvent::new(
7559        "todo.updated",
7560        json!({
7561            "sessionID": session_id,
7562            "todos": normalized
7563        }),
7564    ));
7565}
7566
7567async fn emit_plan_question_fallback(
7568    storage: std::sync::Arc<Storage>,
7569    bus: &EventBus,
7570    session_id: &str,
7571    message_id: &str,
7572    completion: &str,
7573) {
7574    let trimmed = completion.trim();
7575    if trimmed.is_empty() {
7576        return;
7577    }
7578
7579    let hints = extract_todo_candidates_from_text(trimmed)
7580        .into_iter()
7581        .take(6)
7582        .filter_map(|v| {
7583            v.get("content")
7584                .and_then(|c| c.as_str())
7585                .map(ToString::to_string)
7586        })
7587        .collect::<Vec<_>>();
7588
7589    let mut options = hints
7590        .iter()
7591        .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
7592        .collect::<Vec<_>>();
7593    if options.is_empty() {
7594        options = vec![
7595            json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
7596            json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
7597            json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
7598        ];
7599    }
7600
7601    let question_payload = vec![json!({
7602        "header":"Planning Input",
7603        "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
7604        "options": options,
7605        "multiple": true,
7606        "custom": true
7607    })];
7608
7609    let request = storage
7610        .add_question_request(session_id, message_id, question_payload.clone())
7611        .await
7612        .ok();
7613    bus.publish(EngineEvent::new(
7614        "question.asked",
7615        json!({
7616            "id": request
7617                .as_ref()
7618                .map(|req| req.id.clone())
7619                .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7620            "sessionID": session_id,
7621            "messageID": message_id,
7622            "questions": question_payload,
7623            "tool": request.and_then(|req| {
7624                req.tool.map(|tool| {
7625                    json!({
7626                        "callID": tool.call_id,
7627                        "messageID": tool.message_id
7628                    })
7629                })
7630            })
7631        }),
7632    ));
7633}
7634
7635#[derive(Debug, Clone, Copy)]
7636enum ChatHistoryProfile {
7637    Full,
7638    Standard,
7639    Compact,
7640}
7641
7642async fn load_chat_history(
7643    storage: std::sync::Arc<Storage>,
7644    session_id: &str,
7645    profile: ChatHistoryProfile,
7646) -> Vec<ChatMessage> {
7647    let Some(session) = storage.get_session(session_id).await else {
7648        return Vec::new();
7649    };
7650    let messages = session
7651        .messages
7652        .into_iter()
7653        .map(|m| {
7654            let role = format!("{:?}", m.role).to_lowercase();
7655            let content = m
7656                .parts
7657                .into_iter()
7658                .map(|part| match part {
7659                    MessagePart::Text { text } => text,
7660                    MessagePart::Reasoning { text } => text,
7661                    MessagePart::ToolInvocation {
7662                        tool,
7663                        args,
7664                        result,
7665                        error,
7666                    } => summarize_tool_invocation_for_history(
7667                        &tool,
7668                        &args,
7669                        result.as_ref(),
7670                        error.as_deref(),
7671                    ),
7672                })
7673                .collect::<Vec<_>>()
7674                .join("\n");
7675            ChatMessage {
7676                role,
7677                content,
7678                attachments: Vec::new(),
7679            }
7680        })
7681        .collect::<Vec<_>>();
7682    compact_chat_history(messages, profile)
7683}
7684
7685fn summarize_tool_invocation_for_history(
7686    tool: &str,
7687    args: &Value,
7688    result: Option<&Value>,
7689    error: Option<&str>,
7690) -> String {
7691    let mut segments = vec![format!("Tool {tool}")];
7692    if !args.is_null()
7693        && !args.as_object().is_some_and(|value| value.is_empty())
7694        && !args
7695            .as_str()
7696            .map(|value| value.trim().is_empty())
7697            .unwrap_or(false)
7698    {
7699        segments.push(format!("args={args}"));
7700    }
7701    if let Some(error) = error.map(str::trim).filter(|value| !value.is_empty()) {
7702        segments.push(format!("error={error}"));
7703    }
7704    if let Some(result) = result.filter(|value| !value.is_null()) {
7705        segments.push(format!("result={result}"));
7706    }
7707    if segments.len() == 1 {
7708        segments.push("result={}".to_string());
7709    }
7710    segments.join(" ")
7711}
7712
7713fn attach_to_last_user_message(messages: &mut [ChatMessage], attachments: &[ChatAttachment]) {
7714    if attachments.is_empty() {
7715        return;
7716    }
7717    if let Some(message) = messages.iter_mut().rev().find(|m| m.role == "user") {
7718        message.attachments = attachments.to_vec();
7719    }
7720}
7721
7722async fn build_runtime_attachments(
7723    provider_id: &str,
7724    parts: &[MessagePartInput],
7725) -> Vec<ChatAttachment> {
7726    if !supports_image_attachments(provider_id) {
7727        return Vec::new();
7728    }
7729
7730    let mut attachments = Vec::new();
7731    for part in parts {
7732        let MessagePartInput::File { mime, url, .. } = part else {
7733            continue;
7734        };
7735        if !mime.to_ascii_lowercase().starts_with("image/") {
7736            continue;
7737        }
7738        if let Some(source_url) = normalize_attachment_source_url(url, mime).await {
7739            attachments.push(ChatAttachment::ImageUrl { url: source_url });
7740        }
7741    }
7742
7743    attachments
7744}
7745
7746fn supports_image_attachments(provider_id: &str) -> bool {
7747    matches!(
7748        provider_id,
7749        "openai"
7750            | "openrouter"
7751            | "ollama"
7752            | "groq"
7753            | "mistral"
7754            | "together"
7755            | "azure"
7756            | "bedrock"
7757            | "vertex"
7758            | "copilot"
7759    )
7760}
7761
7762async fn normalize_attachment_source_url(url: &str, mime: &str) -> Option<String> {
7763    let trimmed = url.trim();
7764    if trimmed.is_empty() {
7765        return None;
7766    }
7767    if trimmed.starts_with("http://")
7768        || trimmed.starts_with("https://")
7769        || trimmed.starts_with("data:")
7770    {
7771        return Some(trimmed.to_string());
7772    }
7773
7774    let file_path = trimmed
7775        .strip_prefix("file://")
7776        .map(PathBuf::from)
7777        .unwrap_or_else(|| PathBuf::from(trimmed));
7778    if !file_path.exists() {
7779        return None;
7780    }
7781
7782    let max_bytes = std::env::var("TANDEM_CHANNEL_MAX_ATTACHMENT_BYTES")
7783        .ok()
7784        .and_then(|v| v.parse::<usize>().ok())
7785        .unwrap_or(20 * 1024 * 1024);
7786
7787    let bytes = match tokio::fs::read(&file_path).await {
7788        Ok(bytes) => bytes,
7789        Err(err) => {
7790            tracing::warn!(
7791                "failed reading local attachment '{}': {}",
7792                file_path.to_string_lossy(),
7793                err
7794            );
7795            return None;
7796        }
7797    };
7798    if bytes.len() > max_bytes {
7799        tracing::warn!(
7800            "local attachment '{}' exceeds max bytes ({} > {})",
7801            file_path.to_string_lossy(),
7802            bytes.len(),
7803            max_bytes
7804        );
7805        return None;
7806    }
7807
7808    use base64::Engine as _;
7809    let b64 = base64::engine::general_purpose::STANDARD.encode(bytes);
7810    Some(format!("data:{mime};base64,{b64}"))
7811}
7812
7813struct ToolSideEventContext<'a> {
7814    session_id: &'a str,
7815    message_id: &'a str,
7816    tool: &'a str,
7817    args: &'a serde_json::Value,
7818    metadata: &'a serde_json::Value,
7819    workspace_root: Option<&'a str>,
7820    effective_cwd: Option<&'a str>,
7821}
7822
7823async fn emit_tool_side_events(
7824    storage: std::sync::Arc<Storage>,
7825    bus: &EventBus,
7826    ctx: ToolSideEventContext<'_>,
7827) {
7828    let ToolSideEventContext {
7829        session_id,
7830        message_id,
7831        tool,
7832        args,
7833        metadata,
7834        workspace_root,
7835        effective_cwd,
7836    } = ctx;
7837    if tool == "todo_write" {
7838        let todos_from_metadata = metadata
7839            .get("todos")
7840            .and_then(|v| v.as_array())
7841            .cloned()
7842            .unwrap_or_default();
7843
7844        if !todos_from_metadata.is_empty() {
7845            let _ = storage.set_todos(session_id, todos_from_metadata).await;
7846        } else {
7847            let current = storage.get_todos(session_id).await;
7848            if let Some(updated) = apply_todo_updates_from_args(current, args) {
7849                let _ = storage.set_todos(session_id, updated).await;
7850            }
7851        }
7852
7853        let normalized = storage.get_todos(session_id).await;
7854        bus.publish(EngineEvent::new(
7855            "todo.updated",
7856            json!({
7857                "sessionID": session_id,
7858                "todos": normalized,
7859                "workspaceRoot": workspace_root,
7860                "effectiveCwd": effective_cwd
7861            }),
7862        ));
7863    }
7864    if tool == "question" {
7865        let questions = metadata
7866            .get("questions")
7867            .and_then(|v| v.as_array())
7868            .cloned()
7869            .unwrap_or_default();
7870        if questions.is_empty() {
7871            tracing::warn!(
7872                "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
7873                session_id,
7874                message_id
7875            );
7876        } else {
7877            let request = storage
7878                .add_question_request(session_id, message_id, questions.clone())
7879                .await
7880                .ok();
7881            bus.publish(EngineEvent::new(
7882                "question.asked",
7883                json!({
7884                    "id": request
7885                        .as_ref()
7886                        .map(|req| req.id.clone())
7887                        .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
7888                    "sessionID": session_id,
7889                    "messageID": message_id,
7890                    "questions": questions,
7891                    "tool": request.and_then(|req| {
7892                        req.tool.map(|tool| {
7893                            json!({
7894                                "callID": tool.call_id,
7895                                "messageID": tool.message_id
7896                            })
7897                        })
7898                    }),
7899                    "workspaceRoot": workspace_root,
7900                    "effectiveCwd": effective_cwd
7901                }),
7902            ));
7903        }
7904    }
7905    if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
7906        for event in events {
7907            let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
7908                continue;
7909            };
7910            if !event_type.starts_with("agent_team.") {
7911                continue;
7912            }
7913            let mut properties = event
7914                .get("properties")
7915                .and_then(|v| v.as_object())
7916                .cloned()
7917                .unwrap_or_default();
7918            properties
7919                .entry("sessionID".to_string())
7920                .or_insert(json!(session_id));
7921            properties
7922                .entry("messageID".to_string())
7923                .or_insert(json!(message_id));
7924            properties
7925                .entry("workspaceRoot".to_string())
7926                .or_insert(json!(workspace_root));
7927            properties
7928                .entry("effectiveCwd".to_string())
7929                .or_insert(json!(effective_cwd));
7930            bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
7931        }
7932    }
7933}
7934
7935fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
7936    let obj = args.as_object()?;
7937    let mut todos = current;
7938    let mut changed = false;
7939
7940    if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
7941        for item in items {
7942            let Some(item_obj) = item.as_object() else {
7943                continue;
7944            };
7945            let status = item_obj
7946                .get("status")
7947                .and_then(|v| v.as_str())
7948                .map(normalize_todo_status);
7949            let target = item_obj
7950                .get("task_id")
7951                .or_else(|| item_obj.get("todo_id"))
7952                .or_else(|| item_obj.get("id"));
7953
7954            if let (Some(status), Some(target)) = (status, target) {
7955                changed |= apply_single_todo_status_update(&mut todos, target, &status);
7956            }
7957        }
7958    }
7959
7960    let status = obj
7961        .get("status")
7962        .and_then(|v| v.as_str())
7963        .map(normalize_todo_status);
7964    let target = obj
7965        .get("task_id")
7966        .or_else(|| obj.get("todo_id"))
7967        .or_else(|| obj.get("id"));
7968    if let (Some(status), Some(target)) = (status, target) {
7969        changed |= apply_single_todo_status_update(&mut todos, target, &status);
7970    }
7971
7972    if changed {
7973        Some(todos)
7974    } else {
7975        None
7976    }
7977}
7978
7979fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
7980    let idx_from_value = match target {
7981        Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
7982        Value::String(s) => {
7983            let trimmed = s.trim();
7984            trimmed
7985                .parse::<usize>()
7986                .ok()
7987                .map(|v| v.saturating_sub(1))
7988                .or_else(|| {
7989                    let digits = trimmed
7990                        .chars()
7991                        .rev()
7992                        .take_while(|c| c.is_ascii_digit())
7993                        .collect::<String>()
7994                        .chars()
7995                        .rev()
7996                        .collect::<String>();
7997                    digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
7998                })
7999        }
8000        _ => None,
8001    };
8002
8003    if let Some(idx) = idx_from_value {
8004        if idx < todos.len() {
8005            if let Some(obj) = todos[idx].as_object_mut() {
8006                obj.insert("status".to_string(), Value::String(status.to_string()));
8007                return true;
8008            }
8009        }
8010    }
8011
8012    let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
8013    if let Some(id_target) = id_target {
8014        for todo in todos.iter_mut() {
8015            if let Some(obj) = todo.as_object_mut() {
8016                if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
8017                    obj.insert("status".to_string(), Value::String(status.to_string()));
8018                    return true;
8019                }
8020            }
8021        }
8022    }
8023
8024    false
8025}
8026
8027fn normalize_todo_status(raw: &str) -> String {
8028    match raw.trim().to_lowercase().as_str() {
8029        "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
8030        "done" | "complete" | "completed" => "completed".to_string(),
8031        "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
8032        "open" | "todo" | "pending" => "pending".to_string(),
8033        other => other.to_string(),
8034    }
8035}
8036
8037fn compact_chat_history(
8038    messages: Vec<ChatMessage>,
8039    profile: ChatHistoryProfile,
8040) -> Vec<ChatMessage> {
8041    let (max_context_chars, keep_recent_messages) = match profile {
8042        ChatHistoryProfile::Full => (usize::MAX, usize::MAX),
8043        ChatHistoryProfile::Standard => (80_000usize, 40usize),
8044        ChatHistoryProfile::Compact => (12_000usize, 12usize),
8045    };
8046
8047    if messages.len() <= keep_recent_messages {
8048        let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
8049        if total_chars <= max_context_chars {
8050            return messages;
8051        }
8052    }
8053
8054    let mut kept = messages;
8055    let mut dropped_count = 0usize;
8056    let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
8057
8058    while kept.len() > keep_recent_messages || total_chars > max_context_chars {
8059        if kept.is_empty() {
8060            break;
8061        }
8062        let removed = kept.remove(0);
8063        total_chars = total_chars.saturating_sub(removed.content.len());
8064        dropped_count += 1;
8065    }
8066
8067    if dropped_count > 0 {
8068        kept.insert(
8069            0,
8070            ChatMessage {
8071                role: "system".to_string(),
8072                content: format!(
8073                    "[history compacted: omitted {} older messages to fit context window]",
8074                    dropped_count
8075                ),
8076                attachments: Vec::new(),
8077            },
8078        );
8079    }
8080    kept
8081}
8082
8083#[cfg(test)]
8084mod tests {
8085    use super::*;
8086    use crate::{EventBus, Storage};
8087    use std::sync::{Mutex, OnceLock};
8088    use tandem_types::Session;
8089    use uuid::Uuid;
8090
8091    fn env_test_lock() -> std::sync::MutexGuard<'static, ()> {
8092        static ENV_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
8093        ENV_TEST_LOCK
8094            .get_or_init(|| Mutex::new(()))
8095            .lock()
8096            .expect("env test lock")
8097    }
8098
8099    #[tokio::test]
8100    async fn todo_updated_event_is_normalized() {
8101        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8102        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8103        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8104        let session_id = session.id.clone();
8105        storage.save_session(session).await.expect("save session");
8106
8107        let bus = EventBus::new();
8108        let mut rx = bus.subscribe();
8109        emit_tool_side_events(
8110            storage.clone(),
8111            &bus,
8112            ToolSideEventContext {
8113                session_id: &session_id,
8114                message_id: "m1",
8115                tool: "todo_write",
8116                args: &json!({"todos":[{"content":"ship parity"}]}),
8117                metadata: &json!({"todos":[{"content":"ship parity"}]}),
8118                workspace_root: Some("."),
8119                effective_cwd: Some("."),
8120            },
8121        )
8122        .await;
8123
8124        let event = rx.recv().await.expect("event");
8125        assert_eq!(event.event_type, "todo.updated");
8126        let todos = event
8127            .properties
8128            .get("todos")
8129            .and_then(|v| v.as_array())
8130            .cloned()
8131            .unwrap_or_default();
8132        assert_eq!(todos.len(), 1);
8133        assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
8134        assert_eq!(
8135            todos[0].get("content").and_then(|v| v.as_str()),
8136            Some("ship parity")
8137        );
8138        assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
8139    }
8140
8141    #[tokio::test]
8142    async fn question_asked_event_contains_tool_reference() {
8143        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
8144        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8145        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
8146        let session_id = session.id.clone();
8147        storage.save_session(session).await.expect("save session");
8148
8149        let bus = EventBus::new();
8150        let mut rx = bus.subscribe();
8151        emit_tool_side_events(
8152            storage,
8153            &bus,
8154            ToolSideEventContext {
8155                session_id: &session_id,
8156                message_id: "msg-1",
8157                tool: "question",
8158                args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8159                metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
8160                workspace_root: Some("."),
8161                effective_cwd: Some("."),
8162            },
8163        )
8164        .await;
8165
8166        let event = rx.recv().await.expect("event");
8167        assert_eq!(event.event_type, "question.asked");
8168        assert_eq!(
8169            event
8170                .properties
8171                .get("sessionID")
8172                .and_then(|v| v.as_str())
8173                .unwrap_or(""),
8174            session_id
8175        );
8176        let tool = event
8177            .properties
8178            .get("tool")
8179            .cloned()
8180            .unwrap_or_else(|| json!({}));
8181        assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
8182        assert_eq!(
8183            tool.get("messageID").and_then(|v| v.as_str()),
8184            Some("msg-1")
8185        );
8186    }
8187
8188    #[test]
8189    fn compact_chat_history_keeps_recent_and_inserts_summary() {
8190        let mut messages = Vec::new();
8191        for i in 0..60 {
8192            messages.push(ChatMessage {
8193                role: "user".to_string(),
8194                content: format!("message-{i}"),
8195                attachments: Vec::new(),
8196            });
8197        }
8198        let compacted = compact_chat_history(messages, ChatHistoryProfile::Standard);
8199        assert!(compacted.len() <= 41);
8200        assert_eq!(compacted[0].role, "system");
8201        assert!(compacted[0].content.contains("history compacted"));
8202        assert!(compacted.iter().any(|m| m.content.contains("message-59")));
8203    }
8204
8205    #[tokio::test]
8206    async fn load_chat_history_preserves_tool_args_and_error_context() {
8207        let base = std::env::temp_dir().join(format!(
8208            "tandem-core-load-chat-history-error-{}",
8209            uuid::Uuid::new_v4()
8210        ));
8211        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8212        let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8213        let session_id = session.id.clone();
8214        storage.save_session(session).await.expect("save session");
8215
8216        let message = Message::new(
8217            MessageRole::User,
8218            vec![
8219                MessagePart::Text {
8220                    text: "build the page".to_string(),
8221                },
8222                MessagePart::ToolInvocation {
8223                    tool: "write".to_string(),
8224                    args: json!({"path":"game.html","content":"<html>draft</html>"}),
8225                    result: None,
8226                    error: Some("WRITE_ARGS_EMPTY_FROM_PROVIDER".to_string()),
8227                },
8228            ],
8229        );
8230        storage
8231            .append_message(&session_id, message)
8232            .await
8233            .expect("append message");
8234
8235        let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8236        let content = history
8237            .iter()
8238            .find(|message| message.role == "user")
8239            .map(|message| message.content.clone())
8240            .unwrap_or_default();
8241        assert!(content.contains("build the page"));
8242        assert!(content.contains("Tool write"));
8243        assert!(content.contains(r#"args={"content":"<html>draft</html>","path":"game.html"}"#));
8244        assert!(content.contains("error=WRITE_ARGS_EMPTY_FROM_PROVIDER"));
8245    }
8246
8247    #[tokio::test]
8248    async fn load_chat_history_preserves_tool_args_and_result_context() {
8249        let base = std::env::temp_dir().join(format!(
8250            "tandem-core-load-chat-history-result-{}",
8251            uuid::Uuid::new_v4()
8252        ));
8253        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
8254        let session = Session::new(Some("chat history".to_string()), Some(".".to_string()));
8255        let session_id = session.id.clone();
8256        storage.save_session(session).await.expect("save session");
8257
8258        let message = Message::new(
8259            MessageRole::Assistant,
8260            vec![MessagePart::ToolInvocation {
8261                tool: "glob".to_string(),
8262                args: json!({"pattern":"src/**/*.rs"}),
8263                result: Some(json!({"output":"src/lib.rs\nsrc/main.rs"})),
8264                error: None,
8265            }],
8266        );
8267        storage
8268            .append_message(&session_id, message)
8269            .await
8270            .expect("append message");
8271
8272        let history = load_chat_history(storage, &session_id, ChatHistoryProfile::Standard).await;
8273        let content = history
8274            .iter()
8275            .find(|message| message.role == "assistant")
8276            .map(|message| message.content.clone())
8277            .unwrap_or_default();
8278        assert!(content.contains("Tool glob"));
8279        assert!(content.contains(r#"args={"pattern":"src/**/*.rs"}"#));
8280        assert!(content.contains(r#"result={"output":"src/lib.rs\nsrc/main.rs"}"#));
8281    }
8282
8283    #[test]
8284    fn extracts_todos_from_checklist_and_numbered_lines() {
8285        let input = r#"
8286Plan:
8287- [ ] Audit current implementation
8288- [ ] Add planner fallback
82891. Add regression test coverage
8290"#;
8291        let todos = extract_todo_candidates_from_text(input);
8292        assert_eq!(todos.len(), 3);
8293        assert_eq!(
8294            todos[0].get("content").and_then(|v| v.as_str()),
8295            Some("Audit current implementation")
8296        );
8297    }
8298
8299    #[test]
8300    fn does_not_extract_todos_from_plain_prose_lines() {
8301        let input = r#"
8302I need more information to proceed.
8303Can you tell me the event size and budget?
8304Once I have that, I can provide a detailed plan.
8305"#;
8306        let todos = extract_todo_candidates_from_text(input);
8307        assert!(todos.is_empty());
8308    }
8309
8310    #[test]
8311    fn parses_wrapped_tool_call_from_markdown_response() {
8312        let input = r#"
8313Here is the tool call:
8314```json
8315{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
8316```
8317"#;
8318        let parsed = parse_tool_invocation_from_response(input).expect("tool call");
8319        assert_eq!(parsed.0, "todo_write");
8320        assert!(parsed.1.get("todos").is_some());
8321    }
8322
8323    #[test]
8324    fn parses_top_level_name_args_tool_call() {
8325        let input = r#"{"name":"bash","args":{"command":"echo hi"}}"#;
8326        let parsed = parse_tool_invocation_from_response(input).expect("top-level tool call");
8327        assert_eq!(parsed.0, "bash");
8328        assert_eq!(
8329            parsed.1.get("command").and_then(|v| v.as_str()),
8330            Some("echo hi")
8331        );
8332    }
8333
8334    #[test]
8335    fn parses_function_style_todowrite_call() {
8336        let input = r#"Status: Completed
8337Call: todowrite(task_id=2, status="completed")"#;
8338        let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
8339        assert_eq!(parsed.0, "todo_write");
8340        assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8341        assert_eq!(
8342            parsed.1.get("status").and_then(|v| v.as_str()),
8343            Some("completed")
8344        );
8345    }
8346
8347    #[test]
8348    fn parses_multiple_function_style_todowrite_calls() {
8349        let input = r#"
8350Call: todowrite(task_id=2, status="completed")
8351Call: todowrite(task_id=3, status="in_progress")
8352"#;
8353        let parsed = parse_tool_invocations_from_response(input);
8354        assert_eq!(parsed.len(), 2);
8355        assert_eq!(parsed[0].0, "todo_write");
8356        assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
8357        assert_eq!(
8358            parsed[0].1.get("status").and_then(|v| v.as_str()),
8359            Some("completed")
8360        );
8361        assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
8362        assert_eq!(
8363            parsed[1].1.get("status").and_then(|v| v.as_str()),
8364            Some("in_progress")
8365        );
8366    }
8367
8368    #[test]
8369    fn applies_todo_status_update_from_task_id_args() {
8370        let current = vec![
8371            json!({"id":"todo-1","content":"a","status":"pending"}),
8372            json!({"id":"todo-2","content":"b","status":"pending"}),
8373            json!({"id":"todo-3","content":"c","status":"pending"}),
8374        ];
8375        let updated =
8376            apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
8377                .expect("status update");
8378        assert_eq!(
8379            updated[1].get("status").and_then(|v| v.as_str()),
8380            Some("completed")
8381        );
8382    }
8383
8384    #[test]
8385    fn normalizes_todo_write_tasks_alias() {
8386        let normalized = normalize_todo_write_args(
8387            json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
8388            "",
8389        );
8390        let todos = normalized
8391            .get("todos")
8392            .and_then(|v| v.as_array())
8393            .cloned()
8394            .unwrap_or_default();
8395        assert_eq!(todos.len(), 2);
8396        assert_eq!(
8397            todos[0].get("content").and_then(|v| v.as_str()),
8398            Some("Book venue")
8399        );
8400        assert_eq!(
8401            todos[1].get("content").and_then(|v| v.as_str()),
8402            Some("Send invites")
8403        );
8404    }
8405
8406    #[test]
8407    fn normalizes_todo_write_from_completion_when_args_empty() {
8408        let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
8409        let normalized = normalize_todo_write_args(json!({}), completion);
8410        let todos = normalized
8411            .get("todos")
8412            .and_then(|v| v.as_array())
8413            .cloned()
8414            .unwrap_or_default();
8415        assert_eq!(todos.len(), 3);
8416        assert!(!is_empty_todo_write_args(&normalized));
8417    }
8418
8419    #[test]
8420    fn empty_todo_write_args_allows_status_updates() {
8421        let args = json!({"task_id": 2, "status":"completed"});
8422        assert!(!is_empty_todo_write_args(&args));
8423    }
8424
8425    #[test]
8426    fn streamed_websearch_args_fallback_to_query_string() {
8427        let parsed = parse_streamed_tool_args("websearch", "meaning of life");
8428        assert_eq!(
8429            parsed.get("query").and_then(|v| v.as_str()),
8430            Some("meaning of life")
8431        );
8432    }
8433
8434    #[test]
8435    fn parse_scalar_like_value_handles_single_quote_character_without_panicking() {
8436        assert_eq!(
8437            parse_scalar_like_value("\""),
8438            Value::String("\"".to_string())
8439        );
8440        assert_eq!(parse_scalar_like_value("'"), Value::String("'".to_string()));
8441    }
8442
8443    #[test]
8444    fn streamed_websearch_stringified_json_args_are_unwrapped() {
8445        let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
8446        assert_eq!(
8447            parsed.get("query").and_then(|v| v.as_str()),
8448            Some("donkey gestation period")
8449        );
8450    }
8451
8452    #[test]
8453    fn streamed_websearch_args_strip_arg_key_value_wrappers() {
8454        let parsed = parse_streamed_tool_args(
8455            "websearch",
8456            "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
8457        );
8458        assert_eq!(
8459            parsed.get("query").and_then(|v| v.as_str()),
8460            Some("taj card what is it benefits how to apply")
8461        );
8462    }
8463
8464    #[test]
8465    fn normalize_tool_args_websearch_infers_from_user_text() {
8466        let normalized =
8467            normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
8468        assert_eq!(
8469            normalized.args.get("query").and_then(|v| v.as_str()),
8470            Some("meaning of life")
8471        );
8472        assert_eq!(normalized.args_source, "inferred_from_user");
8473        assert_eq!(normalized.args_integrity, "recovered");
8474    }
8475
8476    #[test]
8477    fn normalize_tool_args_websearch_keeps_existing_query() {
8478        let normalized = normalize_tool_args(
8479            "websearch",
8480            json!({"query":"already set"}),
8481            "web search should not override",
8482            "",
8483        );
8484        assert_eq!(
8485            normalized.args.get("query").and_then(|v| v.as_str()),
8486            Some("already set")
8487        );
8488        assert_eq!(normalized.args_source, "provider_json");
8489        assert_eq!(normalized.args_integrity, "ok");
8490    }
8491
8492    #[test]
8493    fn normalize_tool_args_websearch_fails_when_unrecoverable() {
8494        let normalized = normalize_tool_args("websearch", json!({}), "search", "");
8495        assert!(normalized.query.is_none());
8496        assert!(normalized.missing_terminal);
8497        assert_eq!(normalized.args_source, "missing");
8498        assert_eq!(normalized.args_integrity, "empty");
8499    }
8500
8501    #[test]
8502    fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
8503        let normalized = normalize_tool_args(
8504            "webfetch",
8505            json!({}),
8506            "Please fetch `https://docs.tandem.ac/` in markdown mode",
8507            "",
8508        );
8509        assert!(!normalized.missing_terminal);
8510        assert_eq!(
8511            normalized.args.get("url").and_then(|v| v.as_str()),
8512            Some("https://docs.tandem.ac/")
8513        );
8514        assert_eq!(normalized.args_source, "inferred_from_user");
8515        assert_eq!(normalized.args_integrity, "recovered");
8516    }
8517
8518    #[test]
8519    fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
8520        let normalized = normalize_tool_args(
8521            "webfetch",
8522            json!({"args":{"uri":"https://example.com/page"}}),
8523            "",
8524            "",
8525        );
8526        assert!(!normalized.missing_terminal);
8527        assert_eq!(
8528            normalized.args.get("url").and_then(|v| v.as_str()),
8529            Some("https://example.com/page")
8530        );
8531        assert_eq!(normalized.args_source, "provider_json");
8532    }
8533
8534    #[test]
8535    fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
8536        let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
8537        assert!(normalized.missing_terminal);
8538        assert_eq!(
8539            normalized.missing_terminal_reason.as_deref(),
8540            Some("WEBFETCH_URL_MISSING")
8541        );
8542    }
8543
8544    #[test]
8545    fn normalize_tool_args_pack_builder_infers_goal_from_user_prompt() {
8546        let user_text =
8547            "Create a pack that checks latest headline news every day at 8 AM and emails me.";
8548        let normalized = normalize_tool_args("pack_builder", json!({}), user_text, "");
8549        assert!(!normalized.missing_terminal);
8550        assert_eq!(
8551            normalized.args.get("goal").and_then(|v| v.as_str()),
8552            Some(user_text)
8553        );
8554        assert_eq!(
8555            normalized.args.get("mode").and_then(|v| v.as_str()),
8556            Some("preview")
8557        );
8558        assert_eq!(normalized.args_source, "inferred_from_user");
8559        assert_eq!(normalized.args_integrity, "recovered");
8560    }
8561
8562    #[test]
8563    fn normalize_tool_args_pack_builder_keeps_existing_goal_and_mode() {
8564        let normalized = normalize_tool_args(
8565            "pack_builder",
8566            json!({"mode":"apply","goal":"existing goal","plan_id":"plan-1"}),
8567            "new goal should not override",
8568            "",
8569        );
8570        assert!(!normalized.missing_terminal);
8571        assert_eq!(
8572            normalized.args.get("goal").and_then(|v| v.as_str()),
8573            Some("existing goal")
8574        );
8575        assert_eq!(
8576            normalized.args.get("mode").and_then(|v| v.as_str()),
8577            Some("apply")
8578        );
8579        assert_eq!(normalized.args_source, "provider_json");
8580        assert_eq!(normalized.args_integrity, "ok");
8581    }
8582
8583    #[test]
8584    fn normalize_tool_args_pack_builder_confirm_reuses_plan_from_context() {
8585        let assistant_context =
8586            "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8587        let normalized =
8588            normalize_tool_args("pack_builder", json!({}), "confirm", assistant_context);
8589        assert!(!normalized.missing_terminal);
8590        assert_eq!(
8591            normalized.args.get("mode").and_then(|v| v.as_str()),
8592            Some("apply")
8593        );
8594        assert_eq!(
8595            normalized.args.get("plan_id").and_then(|v| v.as_str()),
8596            Some("plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
8597        );
8598        assert_eq!(
8599            normalized
8600                .args
8601                .get("approve_pack_install")
8602                .and_then(|v| v.as_bool()),
8603            Some(true)
8604        );
8605        assert_eq!(normalized.args_source, "recovered_from_context");
8606    }
8607
8608    #[test]
8609    fn normalize_tool_args_pack_builder_apply_recovers_missing_plan_id() {
8610        let assistant_context =
8611            "{\"mode\":\"preview\",\"plan_id\":\"plan-11111111-2222-3333-4444-555555555555\"}";
8612        let normalized = normalize_tool_args(
8613            "pack_builder",
8614            json!({"mode":"apply"}),
8615            "yes",
8616            assistant_context,
8617        );
8618        assert!(!normalized.missing_terminal);
8619        assert_eq!(
8620            normalized.args.get("mode").and_then(|v| v.as_str()),
8621            Some("apply")
8622        );
8623        assert_eq!(
8624            normalized.args.get("plan_id").and_then(|v| v.as_str()),
8625            Some("plan-11111111-2222-3333-4444-555555555555")
8626        );
8627    }
8628
8629    #[test]
8630    fn normalize_tool_args_pack_builder_short_new_goal_does_not_force_apply() {
8631        let assistant_context =
8632            "Pack Builder Preview\n- Plan ID: plan-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
8633        let normalized = normalize_tool_args(
8634            "pack_builder",
8635            json!({}),
8636            "create jira sync",
8637            assistant_context,
8638        );
8639        assert!(!normalized.missing_terminal);
8640        assert_eq!(
8641            normalized.args.get("mode").and_then(|v| v.as_str()),
8642            Some("preview")
8643        );
8644        assert_eq!(
8645            normalized.args.get("goal").and_then(|v| v.as_str()),
8646            Some("create jira sync")
8647        );
8648    }
8649
8650    #[test]
8651    fn normalize_tool_args_write_requires_path() {
8652        let normalized = normalize_tool_args("write", json!({}), "", "");
8653        assert!(normalized.missing_terminal);
8654        assert_eq!(
8655            normalized.missing_terminal_reason.as_deref(),
8656            Some("FILE_PATH_MISSING")
8657        );
8658    }
8659
8660    #[test]
8661    fn persisted_failed_tool_args_prefers_normalized_when_raw_is_empty() {
8662        let args = persisted_failed_tool_args(
8663            &json!({}),
8664            &json!({"path":"game.html","content":"<html></html>"}),
8665        );
8666        assert_eq!(args["path"], "game.html");
8667        assert_eq!(args["content"], "<html></html>");
8668    }
8669
8670    #[test]
8671    fn persisted_failed_tool_args_keeps_non_empty_raw_payload() {
8672        let args = persisted_failed_tool_args(
8673            &json!("path=game.html content"),
8674            &json!({"path":"game.html"}),
8675        );
8676        assert_eq!(args, json!("path=game.html content"));
8677    }
8678
8679    #[test]
8680    fn normalize_tool_args_write_recovers_alias_path_key() {
8681        let normalized = normalize_tool_args(
8682            "write",
8683            json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
8684            "",
8685            "",
8686        );
8687        assert!(!normalized.missing_terminal);
8688        assert_eq!(
8689            normalized.args.get("path").and_then(|v| v.as_str()),
8690            Some("docs/CONCEPT.md")
8691        );
8692        assert_eq!(
8693            normalized.args.get("content").and_then(|v| v.as_str()),
8694            Some("hello")
8695        );
8696    }
8697
8698    #[test]
8699    fn normalize_tool_args_write_recovers_html_output_target_path() {
8700        let normalized = normalize_tool_args_with_mode(
8701            "write",
8702            json!({"content":"<html></html>"}),
8703            "Execute task.\n\nRequired output target:\n{\n  \"path\": \"game.html\",\n  \"kind\": \"source\",\n  \"operation\": \"create_or_update\"\n}\n",
8704            "",
8705            WritePathRecoveryMode::OutputTargetOnly,
8706        );
8707        assert!(!normalized.missing_terminal);
8708        assert_eq!(
8709            normalized.args.get("path").and_then(|v| v.as_str()),
8710            Some("game.html")
8711        );
8712    }
8713
8714    #[test]
8715    fn normalize_tool_args_read_infers_path_from_user_prompt() {
8716        let normalized = normalize_tool_args(
8717            "read",
8718            json!({}),
8719            "Please inspect `FEATURE_LIST.md` and summarize key sections.",
8720            "",
8721        );
8722        assert!(!normalized.missing_terminal);
8723        assert_eq!(
8724            normalized.args.get("path").and_then(|v| v.as_str()),
8725            Some("FEATURE_LIST.md")
8726        );
8727        assert_eq!(normalized.args_source, "inferred_from_user");
8728        assert_eq!(normalized.args_integrity, "recovered");
8729    }
8730
8731    #[test]
8732    fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
8733        let normalized = normalize_tool_args(
8734            "read",
8735            json!({}),
8736            "generic instruction",
8737            "I will read src-tauri/src/orchestrator/engine.rs first.",
8738        );
8739        assert!(normalized.missing_terminal);
8740        assert_eq!(
8741            normalized.missing_terminal_reason.as_deref(),
8742            Some("FILE_PATH_MISSING")
8743        );
8744    }
8745
8746    #[test]
8747    fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
8748        let normalized = normalize_tool_args(
8749            "write",
8750            json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
8751            "",
8752            "",
8753        );
8754        assert!(!normalized.missing_terminal);
8755        assert_eq!(
8756            normalized.args.get("path").and_then(|v| v.as_str()),
8757            Some("docs/CONCEPT.md")
8758        );
8759    }
8760
8761    #[test]
8762    fn normalize_tool_args_write_recovers_content_alias() {
8763        let normalized = normalize_tool_args(
8764            "write",
8765            json!({"path":"docs/FEATURES.md","body":"feature notes"}),
8766            "",
8767            "",
8768        );
8769        assert!(!normalized.missing_terminal);
8770        assert_eq!(
8771            normalized.args.get("content").and_then(|v| v.as_str()),
8772            Some("feature notes")
8773        );
8774    }
8775
8776    #[test]
8777    fn normalize_tool_args_write_fails_when_content_missing() {
8778        let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
8779        assert!(normalized.missing_terminal);
8780        assert_eq!(
8781            normalized.missing_terminal_reason.as_deref(),
8782            Some("WRITE_CONTENT_MISSING")
8783        );
8784    }
8785
8786    #[test]
8787    fn normalize_tool_args_write_output_target_only_rejects_freeform_guess() {
8788        let normalized = normalize_tool_args_with_mode(
8789            "write",
8790            json!({}),
8791            "Please implement the screen/state structure in the workspace.",
8792            "",
8793            WritePathRecoveryMode::OutputTargetOnly,
8794        );
8795        assert!(normalized.missing_terminal);
8796        assert_eq!(
8797            normalized.missing_terminal_reason.as_deref(),
8798            Some("FILE_PATH_MISSING")
8799        );
8800    }
8801
8802    #[test]
8803    fn normalize_tool_args_write_output_target_only_recovers_from_dot_slash_path() {
8804        let normalized = normalize_tool_args_with_mode(
8805            "write",
8806            json!({"path":"./","content":"{}"}),
8807            "Required Workspace Output:\n- Create or update `.tandem/runs/automation-v2-run-123/artifacts/research-sources.json` relative to the workspace root.",
8808            "",
8809            WritePathRecoveryMode::OutputTargetOnly,
8810        );
8811        assert!(!normalized.missing_terminal);
8812        assert_eq!(
8813            normalized.args.get("path").and_then(|v| v.as_str()),
8814            Some(".tandem/runs/automation-v2-run-123/artifacts/research-sources.json")
8815        );
8816    }
8817
8818    #[test]
8819    fn normalize_tool_args_write_recovers_content_from_assistant_context() {
8820        let normalized = normalize_tool_args(
8821            "write",
8822            json!({"path":"docs/FEATURES.md"}),
8823            "",
8824            "## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure\n",
8825        );
8826        assert!(!normalized.missing_terminal);
8827        assert_eq!(
8828            normalized.args.get("path").and_then(|v| v.as_str()),
8829            Some("docs/FEATURES.md")
8830        );
8831        assert_eq!(
8832            normalized.args.get("content").and_then(|v| v.as_str()),
8833            Some("## Features\n\n- Neon arcade gameplay\n- Single-file HTML structure")
8834        );
8835        assert_eq!(normalized.args_source, "recovered_from_context");
8836        assert_eq!(normalized.args_integrity, "recovered");
8837    }
8838
8839    #[test]
8840    fn normalize_tool_args_write_recovers_raw_nested_string_content() {
8841        let normalized = normalize_tool_args(
8842            "write",
8843            json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
8844            "",
8845            "",
8846        );
8847        assert!(!normalized.missing_terminal);
8848        assert_eq!(
8849            normalized.args.get("path").and_then(|v| v.as_str()),
8850            Some("docs/FEATURES.md")
8851        );
8852        assert_eq!(
8853            normalized.args.get("content").and_then(|v| v.as_str()),
8854            Some("Line 1\nLine 2")
8855        );
8856    }
8857
8858    #[test]
8859    fn normalize_tool_args_write_does_not_treat_path_as_content() {
8860        let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
8861        assert!(normalized.missing_terminal);
8862        assert_eq!(
8863            normalized.missing_terminal_reason.as_deref(),
8864            Some("WRITE_CONTENT_MISSING")
8865        );
8866    }
8867
8868    #[test]
8869    fn normalize_tool_args_gmail_send_email_omits_empty_attachment() {
8870        let normalized = normalize_tool_args(
8871            "mcp.composio_1.gmail_send_email",
8872            json!({
8873                "to": "user123@example.com",
8874                "subject": "Test",
8875                "body": "Hello",
8876                "attachment": {
8877                    "s3key": ""
8878                }
8879            }),
8880            "",
8881            "",
8882        );
8883        assert!(normalized.args.get("attachment").is_none());
8884        assert_eq!(normalized.args_source, "sanitized_attachment");
8885    }
8886
8887    #[test]
8888    fn normalize_tool_args_gmail_send_email_keeps_valid_attachment() {
8889        let normalized = normalize_tool_args(
8890            "mcp.composio_1.gmail_send_email",
8891            json!({
8892                "to": "user123@example.com",
8893                "subject": "Test",
8894                "body": "Hello",
8895                "attachment": {
8896                    "s3key": "file_123"
8897                }
8898            }),
8899            "",
8900            "",
8901        );
8902        assert_eq!(
8903            normalized
8904                .args
8905                .get("attachment")
8906                .and_then(|value| value.get("s3key"))
8907                .and_then(|value| value.as_str()),
8908            Some("file_123")
8909        );
8910    }
8911
8912    #[test]
8913    fn classify_required_tool_failure_detects_empty_provider_write_args() {
8914        let reason = classify_required_tool_failure(
8915            &[String::from("WRITE_ARGS_EMPTY_FROM_PROVIDER")],
8916            true,
8917            1,
8918            false,
8919            false,
8920        );
8921        assert_eq!(reason, RequiredToolFailureKind::WriteArgsEmptyFromProvider);
8922    }
8923
8924    #[test]
8925    fn normalize_tool_args_read_infers_path_from_bold_markdown() {
8926        let normalized = normalize_tool_args(
8927            "read",
8928            json!({}),
8929            "Please read **FEATURE_LIST.md** and summarize.",
8930            "",
8931        );
8932        assert!(!normalized.missing_terminal);
8933        assert_eq!(
8934            normalized.args.get("path").and_then(|v| v.as_str()),
8935            Some("FEATURE_LIST.md")
8936        );
8937    }
8938
8939    #[test]
8940    fn normalize_tool_args_shell_infers_command_from_user_prompt() {
8941        let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
8942        assert!(!normalized.missing_terminal);
8943        assert_eq!(
8944            normalized.args.get("command").and_then(|v| v.as_str()),
8945            Some("rg -n \"TODO\" .")
8946        );
8947        assert_eq!(normalized.args_source, "inferred_from_user");
8948        assert_eq!(normalized.args_integrity, "recovered");
8949    }
8950
8951    #[test]
8952    fn normalize_tool_args_read_rejects_root_only_path() {
8953        let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
8954        assert!(normalized.missing_terminal);
8955        assert_eq!(
8956            normalized.missing_terminal_reason.as_deref(),
8957            Some("FILE_PATH_MISSING")
8958        );
8959    }
8960
8961    #[test]
8962    fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
8963        let normalized =
8964            normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
8965        assert!(!normalized.missing_terminal);
8966        assert_eq!(
8967            normalized.args.get("path").and_then(|v| v.as_str()),
8968            Some("CONCEPT.md")
8969        );
8970        assert_eq!(normalized.args_source, "inferred_from_user");
8971        assert_eq!(normalized.args_integrity, "recovered");
8972    }
8973
8974    #[test]
8975    fn normalize_tool_args_read_rejects_tool_call_markup_path() {
8976        let normalized = normalize_tool_args(
8977            "read",
8978            json!({
8979                "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
8980            }),
8981            "",
8982            "",
8983        );
8984        assert!(normalized.missing_terminal);
8985        assert_eq!(
8986            normalized.missing_terminal_reason.as_deref(),
8987            Some("FILE_PATH_MISSING")
8988        );
8989    }
8990
8991    #[test]
8992    fn normalize_tool_args_read_rejects_glob_pattern_path() {
8993        let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
8994        assert!(normalized.missing_terminal);
8995        assert_eq!(
8996            normalized.missing_terminal_reason.as_deref(),
8997            Some("FILE_PATH_MISSING")
8998        );
8999    }
9000
9001    #[test]
9002    fn normalize_tool_args_read_rejects_placeholder_path() {
9003        let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
9004        assert!(normalized.missing_terminal);
9005        assert_eq!(
9006            normalized.missing_terminal_reason.as_deref(),
9007            Some("FILE_PATH_MISSING")
9008        );
9009    }
9010
9011    #[test]
9012    fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
9013        let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
9014        assert!(normalized.missing_terminal);
9015        assert_eq!(
9016            normalized.missing_terminal_reason.as_deref(),
9017            Some("FILE_PATH_MISSING")
9018        );
9019    }
9020
9021    #[test]
9022    fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
9023        let normalized = normalize_tool_args(
9024            "read",
9025            json!({"path":"tool/policy"}),
9026            "Read `T1011U kitöltési útmutató.pdf` and summarize.",
9027            "",
9028        );
9029        assert!(!normalized.missing_terminal);
9030        assert_eq!(
9031            normalized.args.get("path").and_then(|v| v.as_str()),
9032            Some("T1011U kitöltési útmutató.pdf")
9033        );
9034        assert_eq!(normalized.args_source, "inferred_from_user");
9035        assert_eq!(normalized.args_integrity, "recovered");
9036    }
9037
9038    #[test]
9039    fn normalize_tool_name_strips_default_api_namespace() {
9040        assert_eq!(normalize_tool_name("default_api:read"), "read");
9041        assert_eq!(normalize_tool_name("functions.shell"), "bash");
9042    }
9043
9044    #[test]
9045    fn mcp_server_from_tool_name_parses_server_segment() {
9046        assert_eq!(
9047            mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
9048            Some("arcade")
9049        );
9050        assert_eq!(mcp_server_from_tool_name("read"), None);
9051        assert_eq!(mcp_server_from_tool_name("mcp"), None);
9052    }
9053
9054    #[test]
9055    fn mcp_tools_are_exempt_from_workspace_sandbox_path_checks() {
9056        assert!(is_mcp_tool_name("mcp_list"));
9057        assert!(is_mcp_tool_name("mcp.tandem_mcp.get_doc"));
9058        assert!(is_mcp_tool_name("MCP.TANDEM_MCP.GET_DOC"));
9059        assert!(!is_mcp_tool_name("read"));
9060        assert!(!is_mcp_tool_name("glob"));
9061    }
9062
9063    #[test]
9064    fn batch_helpers_use_name_when_tool_is_wrapper() {
9065        let args = json!({
9066            "tool_calls":[
9067                {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
9068                {"tool":"default_api:glob","args":{"pattern":"*.md"}}
9069            ]
9070        });
9071        let calls = extract_batch_calls(&args);
9072        assert_eq!(calls.len(), 2);
9073        assert_eq!(calls[0].0, "read");
9074        assert_eq!(calls[1].0, "glob");
9075        assert!(is_read_only_batch_call(&args));
9076        let sig = batch_tool_signature(&args).unwrap_or_default();
9077        assert!(sig.contains("read:"));
9078        assert!(sig.contains("glob:"));
9079    }
9080
9081    #[test]
9082    fn batch_helpers_resolve_nested_function_name() {
9083        let args = json!({
9084            "tool_calls":[
9085                {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
9086            ]
9087        });
9088        let calls = extract_batch_calls(&args);
9089        assert_eq!(calls.len(), 1);
9090        assert_eq!(calls[0].0, "read");
9091        assert!(is_read_only_batch_call(&args));
9092    }
9093
9094    #[test]
9095    fn batch_output_classifier_detects_non_productive_unknown_results() {
9096        let output = r#"
9097[
9098  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
9099  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
9100]
9101"#;
9102        assert!(is_non_productive_batch_output(output));
9103    }
9104
9105    #[test]
9106    fn runtime_prompt_includes_execution_environment_block() {
9107        let prompt = tandem_runtime_system_prompt(
9108            &HostRuntimeContext {
9109                os: HostOs::Windows,
9110                arch: "x86_64".to_string(),
9111                shell_family: ShellFamily::Powershell,
9112                path_style: PathStyle::Windows,
9113            },
9114            &[],
9115        );
9116        assert!(prompt.contains("[Execution Environment]"));
9117        assert!(prompt.contains("Host OS: windows"));
9118        assert!(prompt.contains("Shell: powershell"));
9119        assert!(prompt.contains("Path style: windows"));
9120    }
9121
9122    #[test]
9123    fn runtime_prompt_includes_connected_integrations_block() {
9124        let prompt = tandem_runtime_system_prompt(
9125            &HostRuntimeContext {
9126                os: HostOs::Linux,
9127                arch: "x86_64".to_string(),
9128                shell_family: ShellFamily::Posix,
9129                path_style: PathStyle::Posix,
9130            },
9131            &["notion".to_string(), "github".to_string()],
9132        );
9133        assert!(prompt.contains("[Connected Integrations]"));
9134        assert!(prompt.contains("- notion"));
9135        assert!(prompt.contains("- github"));
9136    }
9137
9138    #[test]
9139    fn detects_web_research_prompt_keywords() {
9140        assert!(requires_web_research_prompt(
9141            "research todays top news stories and include links"
9142        ));
9143        assert!(!requires_web_research_prompt(
9144            "say hello and summarize this text"
9145        ));
9146    }
9147
9148    #[test]
9149    fn detects_email_delivery_prompt_keywords() {
9150        assert!(requires_email_delivery_prompt(
9151            "send a full report with links to user123@example.com"
9152        ));
9153        assert!(!requires_email_delivery_prompt("draft a summary for later"));
9154    }
9155
9156    #[test]
9157    fn completion_claim_detector_flags_sent_language() {
9158        assert!(completion_claims_email_sent(
9159            "Email Status: Sent to user123@example.com."
9160        ));
9161        assert!(!completion_claims_email_sent(
9162            "I could not send email in this run."
9163        ));
9164    }
9165
9166    #[test]
9167    fn email_tool_detector_finds_mcp_gmail_tools() {
9168        let schemas = vec![
9169            ToolSchema::new("read", "", json!({})),
9170            ToolSchema::new("mcp.composio.gmail_send_email", "", json!({})),
9171        ];
9172        assert!(has_email_action_tools(&schemas));
9173    }
9174
9175    #[test]
9176    fn extract_mcp_auth_required_metadata_parses_expected_shape() {
9177        let metadata = json!({
9178            "server": "arcade",
9179            "mcpAuth": {
9180                "required": true,
9181                "challengeId": "abc123",
9182                "authorizationUrl": "https://example.com/oauth",
9183                "message": "Authorize first",
9184                "pending": true,
9185                "blocked": true,
9186                "retryAfterMs": 8000
9187            }
9188        });
9189        let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
9190        assert_eq!(parsed.challenge_id, "abc123");
9191        assert_eq!(parsed.authorization_url, "https://example.com/oauth");
9192        assert_eq!(parsed.message, "Authorize first");
9193        assert_eq!(parsed.server.as_deref(), Some("arcade"));
9194        assert!(parsed.pending);
9195        assert!(parsed.blocked);
9196        assert_eq!(parsed.retry_after_ms, Some(8000));
9197    }
9198
9199    #[test]
9200    fn auth_required_output_detector_matches_auth_text() {
9201        assert!(is_auth_required_tool_output(
9202            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
9203        ));
9204        assert!(is_auth_required_tool_output(
9205            "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
9206        ));
9207        assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
9208    }
9209
9210    #[test]
9211    fn productive_tool_output_detector_rejects_missing_terminal_write_errors() {
9212        assert!(!is_productive_tool_output("write", "WRITE_CONTENT_MISSING"));
9213        assert!(!is_productive_tool_output("write", "FILE_PATH_MISSING"));
9214        assert!(!is_productive_tool_output(
9215            "write",
9216            "Tool `write` result:\nWRITE_CONTENT_MISSING"
9217        ));
9218        assert!(!is_productive_tool_output(
9219            "edit",
9220            "Tool `edit` result:\nFILE_PATH_MISSING"
9221        ));
9222        assert!(!is_productive_tool_output(
9223            "write",
9224            "Tool `write` result:\ninvalid_function_parameters"
9225        ));
9226    }
9227
9228    #[test]
9229    fn productive_tool_output_detector_accepts_real_tool_results() {
9230        assert!(is_productive_tool_output(
9231            "write",
9232            "Tool `write` result:\nWrote /tmp/probe.html"
9233        ));
9234        assert!(!is_productive_tool_output(
9235            "write",
9236            "Authorization required for `write`.\nAuthorize here: https://example.com"
9237        ));
9238    }
9239
9240    #[test]
9241    fn glob_empty_result_is_productive() {
9242        assert!(is_productive_tool_output("glob", "Tool `glob` result:\n"));
9243        assert!(is_productive_tool_output("glob", ""));
9244    }
9245
9246    #[test]
9247    fn write_required_node_retries_after_empty_glob() {
9248        assert!(should_retry_nonproductive_required_tool_cycle(
9249            true, false, true, 0
9250        ));
9251        assert!(should_retry_nonproductive_required_tool_cycle(
9252            true, false, true, 1
9253        ));
9254        assert!(!should_retry_nonproductive_required_tool_cycle(
9255            true, false, true, 2
9256        ));
9257    }
9258
9259    #[test]
9260    fn write_required_node_does_not_take_preparatory_retry_after_write_attempt() {
9261        assert!(!should_retry_nonproductive_required_tool_cycle(
9262            true, true, true, 0
9263        ));
9264        assert!(should_retry_nonproductive_required_tool_cycle(
9265            false, true, false, 0
9266        ));
9267    }
9268
9269    #[test]
9270    fn guard_budget_output_detector_matches_expected_text() {
9271        assert!(is_guard_budget_tool_output(
9272            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9273        ));
9274        assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
9275    }
9276
9277    #[test]
9278    fn summarize_guard_budget_outputs_returns_run_scoped_message() {
9279        let outputs = vec![
9280            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
9281                .to_string(),
9282            "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
9283                .to_string(),
9284        ];
9285        let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
9286        assert!(summary.contains("per-run tool guard budget"));
9287        assert!(summary.contains("fresh run"));
9288    }
9289
9290    #[test]
9291    fn duplicate_signature_output_detector_matches_expected_text() {
9292        assert!(is_duplicate_signature_limit_output(
9293            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9294        ));
9295        assert!(!is_duplicate_signature_limit_output(
9296            "Tool `read` result: ok"
9297        ));
9298    }
9299
9300    #[test]
9301    fn summarize_duplicate_signature_outputs_returns_run_scoped_message() {
9302        let outputs = vec![
9303            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9304                .to_string(),
9305            "Tool `bash` call skipped: duplicate call signature retry limit reached (2)."
9306                .to_string(),
9307        ];
9308        let summary =
9309            summarize_duplicate_signature_outputs(&outputs).expect("expected duplicate summary");
9310        assert!(summary.contains("same tool call kept repeating"));
9311        assert!(summary.contains("clearer command target"));
9312    }
9313
9314    #[test]
9315    fn required_tool_mode_unsatisfied_completion_includes_marker() {
9316        let message =
9317            required_tool_mode_unsatisfied_completion(RequiredToolFailureKind::NoToolCallEmitted);
9318        assert!(message.contains(REQUIRED_TOOL_MODE_UNSATISFIED_REASON));
9319        assert!(message.contains("NO_TOOL_CALL_EMITTED"));
9320        assert!(message.contains("tool_mode=required"));
9321    }
9322
9323    #[test]
9324    fn post_tool_final_narrative_generation_is_allowed_after_required_tools_succeed() {
9325        assert!(should_generate_post_tool_final_narrative(
9326            ToolMode::Required,
9327            1
9328        ));
9329        assert!(!should_generate_post_tool_final_narrative(
9330            ToolMode::Required,
9331            0
9332        ));
9333        assert!(should_generate_post_tool_final_narrative(ToolMode::Auto, 0));
9334    }
9335
9336    #[test]
9337    fn post_tool_final_narrative_prompt_preserves_structured_response_requirements() {
9338        let prompt = build_post_tool_final_narrative_prompt(&[String::from(
9339            "Tool `glob` result:\n/home/user123/marketing-tandem/tandem-reference/SOURCES.md",
9340        )]);
9341        assert!(prompt.contains("Preserve any requested output contract"));
9342        assert!(prompt.contains("required JSON structure"));
9343        assert!(prompt.contains("required handoff fields"));
9344        assert!(prompt.contains("required final status object"));
9345        assert!(prompt.contains("Do not stop at a tool summary"));
9346    }
9347
9348    #[test]
9349    fn required_tool_retry_context_mentions_offered_tools() {
9350        let prompt = build_required_tool_retry_context(
9351            "read, write, apply_patch",
9352            RequiredToolFailureKind::ToolCallInvalidArgs,
9353        );
9354        assert!(prompt.contains("Tool access is mandatory"));
9355        assert!(prompt.contains("TOOL_CALL_INVALID_ARGS"));
9356        assert!(prompt.contains("full `content`"));
9357        assert!(prompt.contains("write, edit, or apply_patch"));
9358    }
9359
9360    #[test]
9361    fn required_tool_retry_context_requires_write_after_read_only_pass() {
9362        let prompt = build_required_tool_retry_context(
9363            "glob, read, write, edit, apply_patch",
9364            RequiredToolFailureKind::WriteRequiredNotSatisfied,
9365        );
9366        assert!(prompt.contains("WRITE_REQUIRED_NOT_SATISFIED"));
9367        assert!(prompt.contains("Inspection is complete"));
9368        assert!(prompt.contains("write, edit, or apply_patch"));
9369    }
9370
9371    #[test]
9372    fn classify_required_tool_failure_detects_invalid_args() {
9373        let reason = classify_required_tool_failure(
9374            &[String::from("WRITE_CONTENT_MISSING")],
9375            true,
9376            1,
9377            false,
9378            false,
9379        );
9380        assert_eq!(reason, RequiredToolFailureKind::ToolCallInvalidArgs);
9381    }
9382
9383    #[test]
9384    fn looks_like_unparsed_tool_payload_detects_tool_call_json() {
9385        assert!(looks_like_unparsed_tool_payload(
9386            r#"{"content":[{"type":"tool_call","name":"write"}]}"#
9387        ));
9388        assert!(!looks_like_unparsed_tool_payload("Updated README.md"));
9389    }
9390
9391    #[test]
9392    fn workspace_write_tool_detection_is_limited_to_mutations() {
9393        assert!(is_workspace_write_tool("write"));
9394        assert!(is_workspace_write_tool("edit"));
9395        assert!(is_workspace_write_tool("apply_patch"));
9396        assert!(!is_workspace_write_tool("read"));
9397        assert!(!is_workspace_write_tool("glob"));
9398    }
9399
9400    #[test]
9401    fn proactive_write_gate_applies_only_before_prewrite_is_satisfied() {
9402        let decision = evaluate_prewrite_gate(
9403            true,
9404            &PrewriteRequirements {
9405                workspace_inspection_required: true,
9406                web_research_required: false,
9407                concrete_read_required: true,
9408                successful_web_research_required: false,
9409                repair_on_unmet_requirements: true,
9410                coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9411            },
9412            PrewriteProgress {
9413                productive_write_tool_calls_total: 0,
9414                productive_workspace_inspection_total: 0,
9415                productive_concrete_read_total: 0,
9416                productive_web_research_total: 0,
9417                successful_web_research_total: 0,
9418                required_write_retry_count: 0,
9419                unmet_prewrite_repair_retry_count: 0,
9420                prewrite_gate_waived: false,
9421            },
9422        );
9423        assert!(decision.gate_write);
9424    }
9425
9426    #[test]
9427    fn prewrite_repair_can_start_before_any_write_attempt() {
9428        assert!(should_start_prewrite_repair_before_first_write(
9429            true, 0, false, false
9430        ));
9431        assert!(!should_start_prewrite_repair_before_first_write(
9432            true, 0, true, false
9433        ));
9434        assert!(!should_start_prewrite_repair_before_first_write(
9435            false, 0, false, false
9436        ));
9437        assert!(should_start_prewrite_repair_before_first_write(
9438            false, 0, false, true
9439        ));
9440    }
9441
9442    #[test]
9443    fn prewrite_repair_does_not_fire_after_first_write() {
9444        assert!(!should_start_prewrite_repair_before_first_write(
9445            true, 1, false, false
9446        ));
9447        assert!(!should_start_prewrite_repair_before_first_write(
9448            true, 2, false, true
9449        ));
9450    }
9451
9452    #[test]
9453    fn infer_code_workflow_from_text_detects_code_agent_contract() {
9454        let prompt = "Code Agent Contract:\n- Follow the deterministic loop: inspect -> patch -> apply -> test -> repair -> finalize.\n- Verification expectation: cargo test";
9455        assert!(infer_code_workflow_from_text(prompt));
9456    }
9457
9458    #[test]
9459    fn infer_code_workflow_from_text_detects_source_target_path() {
9460        let prompt = "Required Workspace Output:\n- Create or update `src/lib.rs` relative to the workspace root.";
9461        assert!(infer_code_workflow_from_text(prompt));
9462    }
9463
9464    #[test]
9465    fn required_tool_retry_context_for_task_adds_code_loop_guidance() {
9466        let prompt = build_required_tool_retry_context_for_task(
9467            "read, edit, apply_patch, bash",
9468            RequiredToolFailureKind::WriteRequiredNotSatisfied,
9469            "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.",
9470        );
9471        assert!(prompt.contains("inspect -> patch -> apply -> test -> repair"));
9472        assert!(prompt.contains("apply_patch"));
9473        assert!(prompt.contains("cargo test"));
9474        assert!(prompt.contains("src/lib.rs"));
9475    }
9476
9477    #[test]
9478    fn write_tool_removed_after_first_productive_write() {
9479        let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9480        let repair_on_unmet_requirements = true;
9481        let productive_write_tool_calls_total = 1usize;
9482        if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9483            offered.retain(|tool| !is_workspace_write_tool(tool));
9484        }
9485        assert_eq!(offered, vec!["glob", "read", "websearch", "write", "edit"]);
9486    }
9487
9488    #[test]
9489    fn write_tool_removed_after_third_productive_write() {
9490        let mut offered = vec!["glob", "read", "websearch", "write", "edit"];
9491        let repair_on_unmet_requirements = true;
9492        let productive_write_tool_calls_total = 3usize;
9493        if repair_on_unmet_requirements && productive_write_tool_calls_total >= 3 {
9494            offered.retain(|tool| !is_workspace_write_tool(tool));
9495        }
9496        assert_eq!(offered, vec!["glob", "read", "websearch"]);
9497    }
9498
9499    #[test]
9500    fn force_write_only_retry_disabled_for_prewrite_repair_nodes() {
9501        let requested_write_required = true;
9502        let required_write_retry_count = 1usize;
9503        let productive_write_tool_calls_total = 0usize;
9504        let prewrite_satisfied = true;
9505        let prewrite_gate_write = false;
9506        let repair_on_unmet_requirements = true;
9507
9508        let force_write_only_retry = requested_write_required
9509            && required_write_retry_count > 0
9510            && (productive_write_tool_calls_total == 0 || prewrite_satisfied)
9511            && !prewrite_gate_write
9512            && !repair_on_unmet_requirements;
9513
9514        assert!(!force_write_only_retry);
9515    }
9516
9517    #[test]
9518    fn infer_required_output_target_path_reads_prompt_json_block() {
9519        let prompt = r#"Execute task.
9520
9521Required output target:
9522{
9523  "path": "src/game.html",
9524  "kind": "source",
9525  "operation": "create"
9526}
9527"#;
9528        assert_eq!(
9529            infer_required_output_target_path_from_text(prompt).as_deref(),
9530            Some("src/game.html")
9531        );
9532    }
9533
9534    #[test]
9535    fn infer_required_output_target_path_accepts_extensionless_target() {
9536        let prompt = r#"Execute task.
9537
9538Required output target:
9539{
9540  "path": "Dockerfile",
9541  "kind": "source",
9542  "operation": "create"
9543}
9544"#;
9545        assert_eq!(
9546            infer_required_output_target_path_from_text(prompt).as_deref(),
9547            Some("Dockerfile")
9548        );
9549    }
9550
9551    #[test]
9552    fn infer_write_file_path_from_text_rejects_workspace_root() {
9553        let prompt = "Workspace: /home/user123/game\nCreate the scaffold in the workspace now.";
9554        assert_eq!(infer_write_file_path_from_text(prompt), None);
9555    }
9556
9557    #[test]
9558    fn duplicate_signature_limit_defaults_to_200_for_general_tools_and_1_for_email_delivery() {
9559        let _guard = env_test_lock();
9560        unsafe {
9561            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9562            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9563        }
9564        assert_eq!(duplicate_signature_limit_for("pack_builder"), 200);
9565        assert_eq!(duplicate_signature_limit_for("bash"), 200);
9566        assert_eq!(duplicate_signature_limit_for("write"), 200);
9567        assert_eq!(
9568            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9569            1
9570        );
9571        assert_eq!(
9572            duplicate_signature_limit_for("mcp.composio_1.gmail_create_email_draft"),
9573            1
9574        );
9575    }
9576
9577    #[test]
9578    fn parse_streamed_tool_args_preserves_unparseable_write_payload() {
9579        let parsed = parse_streamed_tool_args("write", "path=game.html content");
9580        assert_ne!(parsed, json!({}));
9581    }
9582
9583    #[test]
9584    fn parse_streamed_tool_args_rejects_malformed_json_fragment_as_function_style() {
9585        let parsed = parse_streamed_tool_args("write", r#"{"allow_empty": null"#);
9586        assert_eq!(parsed, json!(r#"{"allow_empty": null"#));
9587    }
9588
9589    #[test]
9590    fn parse_streamed_tool_args_preserves_large_write_payload() {
9591        let content = "x".repeat(4096);
9592        let raw_args = format!(r#"{{"path":"game.html","content":"{}"}}"#, content);
9593        let parsed = parse_streamed_tool_args("write", &raw_args);
9594        assert_eq!(
9595            parsed.get("path").and_then(|value| value.as_str()),
9596            Some("game.html")
9597        );
9598        assert_eq!(
9599            parsed.get("content").and_then(|value| value.as_str()),
9600            Some(content.as_str())
9601        );
9602    }
9603
9604    #[test]
9605    fn parse_streamed_tool_args_recovers_truncated_write_json() {
9606        let raw_args = concat!(
9607            r#"{"path":"game.html","allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9608            r#"<html lang=\"en\"><body>Neon Drift"#
9609        );
9610        let parsed = parse_streamed_tool_args("write", raw_args);
9611        assert_eq!(
9612            parsed,
9613            json!({
9614                "path": "game.html",
9615                "content": "<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift"
9616            })
9617        );
9618    }
9619
9620    #[test]
9621    fn parse_streamed_tool_args_recovers_truncated_write_json_without_path() {
9622        let raw_args = concat!(
9623            r#"{"allow_empty":false,"content":"<!DOCTYPE html>\n"#,
9624            r#"<html lang=\"en\"><body>Neon Drift"#
9625        );
9626        let parsed = parse_streamed_tool_args("write", raw_args);
9627        assert_eq!(parsed.get("path"), None);
9628        assert_eq!(
9629            parsed.get("content").and_then(|value| value.as_str()),
9630            Some("<!DOCTYPE html>\n<html lang=\"en\"><body>Neon Drift")
9631        );
9632    }
9633
9634    #[test]
9635    fn duplicate_signature_limit_env_override_respects_minimum_floor() {
9636        let _guard = env_test_lock();
9637        unsafe {
9638            std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "9");
9639            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9640        }
9641        assert_eq!(duplicate_signature_limit_for("write"), 200);
9642        assert_eq!(duplicate_signature_limit_for("bash"), 200);
9643        unsafe {
9644            std::env::set_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT", "250");
9645        }
9646        assert_eq!(duplicate_signature_limit_for("bash"), 250);
9647        unsafe {
9648            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT");
9649        }
9650    }
9651
9652    #[test]
9653    fn email_delivery_duplicate_signature_limit_env_override_respects_floor_of_one() {
9654        let _guard = env_test_lock();
9655        unsafe {
9656            std::env::set_var(
9657                "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9658                "1",
9659            );
9660        }
9661        assert_eq!(
9662            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9663            1
9664        );
9665        unsafe {
9666            std::env::set_var(
9667                "TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY",
9668                "3",
9669            );
9670        }
9671        assert_eq!(
9672            duplicate_signature_limit_for("mcp.composio_1.gmail_send_email"),
9673            3
9674        );
9675        unsafe {
9676            std::env::remove_var("TANDEM_TOOL_LOOP_DUPLICATE_SIGNATURE_LIMIT_EMAIL_DELIVERY");
9677        }
9678    }
9679
9680    #[test]
9681    fn email_delivery_detection_is_provider_agnostic() {
9682        assert!(is_email_delivery_tool_name(
9683            "mcp.composio_1.gmail_send_email"
9684        ));
9685        assert!(is_email_delivery_tool_name("mcp.sendgrid.send_email"));
9686        assert!(is_email_delivery_tool_name("mcp.resend.create_email_draft"));
9687        assert!(is_email_delivery_tool_name("mcp.outlook.reply_email"));
9688        assert!(!is_email_delivery_tool_name("mcp.reddit.send_message"));
9689        assert!(!is_email_delivery_tool_name("mcp.github.create_issue"));
9690    }
9691
9692    #[test]
9693    fn websearch_duplicate_signature_limit_is_unset_by_default() {
9694        let _guard = env_test_lock();
9695        unsafe {
9696            std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9697        }
9698        assert_eq!(websearch_duplicate_signature_limit(), None);
9699    }
9700
9701    #[test]
9702    fn websearch_duplicate_signature_limit_reads_env() {
9703        let _guard = env_test_lock();
9704        unsafe {
9705            std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "5");
9706        }
9707        assert_eq!(websearch_duplicate_signature_limit(), Some(200));
9708        unsafe {
9709            std::env::set_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT", "300");
9710        }
9711        assert_eq!(websearch_duplicate_signature_limit(), Some(300));
9712        unsafe {
9713            std::env::remove_var("TANDEM_WEBSEARCH_DUPLICATE_SIGNATURE_LIMIT");
9714        }
9715    }
9716
9717    #[test]
9718    fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
9719        let outputs = vec![
9720            "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
9721            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
9722        ];
9723        let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
9724        assert!(summary.contains("Authorization is required before I can continue"));
9725        assert!(summary.contains("gmail_sendemail"));
9726        assert!(summary.contains("gmail_whoami"));
9727    }
9728
9729    #[test]
9730    fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
9731        let outputs = vec![
9732            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
9733            "Tool `read` result:\nok".to_string(),
9734        ];
9735        assert!(summarize_auth_pending_outputs(&outputs).is_none());
9736    }
9737
9738    #[test]
9739    fn invalid_tool_args_retry_context_handles_missing_bash_command() {
9740        let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9741        let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 0)
9742            .expect("retry expected");
9743        assert!(message.contains("required `command` field"));
9744        assert!(message.contains("Prefer `ls`, `glob`, `search`, and `read`"));
9745    }
9746
9747    #[test]
9748    fn invalid_tool_args_retry_context_escalates_on_repeat_bash_failure() {
9749        let outputs = vec!["Tool `bash` result:\nBASH_COMMAND_MISSING".to_string()];
9750        let message = build_invalid_tool_args_retry_context_from_outputs(&outputs, 1)
9751            .expect("retry expected");
9752        assert!(message.contains("Do not repeat an empty bash call"));
9753    }
9754
9755    #[test]
9756    fn invalid_tool_args_retry_context_ignores_unrelated_outputs() {
9757        let outputs = vec!["Tool `read` result:\nok".to_string()];
9758        assert!(build_invalid_tool_args_retry_context_from_outputs(&outputs, 0).is_none());
9759    }
9760
9761    #[test]
9762    fn prewrite_repair_retry_context_prioritizes_research_tools_before_write() {
9763        let requirements = PrewriteRequirements {
9764            workspace_inspection_required: true,
9765            web_research_required: true,
9766            concrete_read_required: true,
9767            successful_web_research_required: true,
9768            repair_on_unmet_requirements: true,
9769            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9770        };
9771        let prompt = build_prewrite_repair_retry_context(
9772            "glob, read, websearch, write",
9773            RequiredToolFailureKind::WriteRequiredNotSatisfied,
9774            r#"Required output target:
9775{
9776  "path": "marketing-brief.md",
9777  "kind": "artifact"
9778}"#,
9779            &requirements,
9780            true,
9781            false,
9782            false,
9783            false,
9784        );
9785        assert!(prompt.contains("requires concrete `read` calls"));
9786        assert!(prompt.contains("call `websearch` with a concrete query now"));
9787        assert!(prompt.contains("Use `read` and `websearch` now to gather evidence"));
9788        assert!(prompt.contains("Do not declare the output blocked"));
9789        assert!(!prompt.contains("blocked-but-substantive artifact"));
9790        assert!(!prompt.contains("Your next response must be a `write` tool call"));
9791        assert!(!prompt.contains("Do not call `glob`, `read`, or `websearch` again"));
9792    }
9793
9794    #[test]
9795    fn empty_completion_retry_context_requires_write_when_prewrite_is_satisfied() {
9796        let requirements = PrewriteRequirements {
9797            workspace_inspection_required: true,
9798            web_research_required: false,
9799            concrete_read_required: true,
9800            successful_web_research_required: false,
9801            repair_on_unmet_requirements: true,
9802            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9803        };
9804        let prompt = build_empty_completion_retry_context(
9805            "glob, read, write",
9806            "Create or update `marketing-brief.md` relative to the workspace root.",
9807            &requirements,
9808            true,
9809            true,
9810            false,
9811            false,
9812        );
9813        assert!(prompt.contains("returned no final output"));
9814        assert!(prompt.contains("marketing-brief.md"));
9815        assert!(prompt.contains("must be a `write` tool call"));
9816    }
9817
9818    #[test]
9819    fn empty_completion_retry_context_mentions_missing_prewrite_work() {
9820        let requirements = PrewriteRequirements {
9821            workspace_inspection_required: true,
9822            web_research_required: true,
9823            concrete_read_required: true,
9824            successful_web_research_required: true,
9825            repair_on_unmet_requirements: true,
9826            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9827        };
9828        let prompt = build_empty_completion_retry_context(
9829            "glob, read, websearch, write",
9830            "Create or update `marketing-brief.md` relative to the workspace root.",
9831            &requirements,
9832            true,
9833            false,
9834            false,
9835            false,
9836        );
9837        assert!(prompt.contains("still need to use `read`"));
9838        assert!(prompt.contains("use `websearch`"));
9839        assert!(prompt.contains("After completing the missing requirement"));
9840    }
9841
9842    #[test]
9843    fn synthesize_artifact_write_completion_from_tool_state_marks_completed() {
9844        let completion = synthesize_artifact_write_completion_from_tool_state(
9845            "Create or update `marketing-brief.md` relative to the workspace root.",
9846            true,
9847            false,
9848        );
9849        assert!(completion.contains("wrote `marketing-brief.md`"));
9850        assert!(completion.contains("\"status\":\"completed\""));
9851        assert!(completion.contains("Runtime validation will verify"));
9852    }
9853
9854    #[test]
9855    fn synthesize_artifact_write_completion_from_tool_state_mentions_waived_evidence() {
9856        let completion = synthesize_artifact_write_completion_from_tool_state(
9857            "Create or update `marketing-brief.md` relative to the workspace root.",
9858            false,
9859            true,
9860        );
9861        assert!(completion.contains("waived in-run"));
9862        assert!(completion.contains("\"status\":\"completed\""));
9863    }
9864
9865    #[test]
9866    fn prewrite_repair_retry_budget_allows_five_repair_attempts() {
9867        assert_eq!(prewrite_repair_retry_max_attempts(), 5);
9868    }
9869
9870    #[test]
9871    fn prewrite_repair_tool_filter_removes_write_until_evidence_is_satisfied() {
9872        let offered = ["glob", "read", "websearch", "write", "edit"];
9873        let filtered = offered
9874            .iter()
9875            .copied()
9876            .filter(|tool| {
9877                tool_matches_unmet_prewrite_repair_requirement(
9878                    tool,
9879                    &[
9880                        "workspace_inspection_required",
9881                        "concrete_read_required",
9882                        "successful_web_research_required",
9883                    ],
9884                )
9885            })
9886            .collect::<Vec<_>>();
9887        assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9888    }
9889
9890    #[test]
9891    fn prewrite_repair_tool_filter_restricts_to_glob_and_read_for_concrete_reads() {
9892        let offered = ["glob", "read", "search", "write"];
9893        let filtered = offered
9894            .iter()
9895            .copied()
9896            .filter(|tool| {
9897                tool_matches_unmet_prewrite_repair_requirement(tool, &["concrete_read_required"])
9898            })
9899            .collect::<Vec<_>>();
9900        assert_eq!(filtered, vec!["glob", "read"]);
9901    }
9902
9903    #[test]
9904    fn prewrite_repair_tool_filter_allows_glob_only_for_workspace_inspection() {
9905        let offered = ["glob", "read", "websearch", "write"];
9906        let with_inspection_unmet = offered
9907            .iter()
9908            .copied()
9909            .filter(|tool| {
9910                tool_matches_unmet_prewrite_repair_requirement(
9911                    tool,
9912                    &["workspace_inspection_required", "concrete_read_required"],
9913                )
9914            })
9915            .collect::<Vec<_>>();
9916        assert_eq!(with_inspection_unmet, vec!["glob", "read"]);
9917
9918        let without_inspection_unmet = offered
9919            .iter()
9920            .copied()
9921            .filter(|tool| {
9922                tool_matches_unmet_prewrite_repair_requirement(
9923                    tool,
9924                    &["concrete_read_required", "web_research_required"],
9925                )
9926            })
9927            .collect::<Vec<_>>();
9928        assert_eq!(without_inspection_unmet, vec!["glob", "read", "websearch"]);
9929    }
9930
9931    #[test]
9932    fn prewrite_repair_after_glob_restricts_to_glob_read_and_websearch() {
9933        let offered = ["glob", "read", "websearch", "write", "edit"];
9934        let filtered = offered
9935            .iter()
9936            .copied()
9937            .filter(|tool| {
9938                tool_matches_unmet_prewrite_repair_requirement(
9939                    tool,
9940                    &[
9941                        "concrete_read_required",
9942                        "successful_web_research_required",
9943                        "coverage_mode",
9944                    ],
9945                )
9946            })
9947            .collect::<Vec<_>>();
9948        assert_eq!(filtered, vec!["glob", "read", "websearch"]);
9949    }
9950
9951    #[test]
9952    fn prewrite_requirements_exhausted_completion_reports_structured_repair_state() {
9953        let message = prewrite_requirements_exhausted_completion(
9954            &["concrete_read_required", "successful_web_research_required"],
9955            2,
9956            0,
9957        );
9958        assert!(message.contains("PREWRITE_REQUIREMENTS_EXHAUSTED"));
9959        assert!(message.contains("\"status\":\"blocked\""));
9960        assert!(message.contains("\"repairAttempt\":2"));
9961        assert!(message.contains("\"repairAttemptsRemaining\":0"));
9962        assert!(message.contains("\"repairExhausted\":true"));
9963        assert!(message.contains("\"unmetRequirements\":[\"concrete_read_required\", \"successful_web_research_required\"]"));
9964    }
9965
9966    #[test]
9967    fn prewrite_waived_write_context_includes_unmet_codes() {
9968        let user_text = "Some task text without output target marker.";
9969        let unmet = vec!["concrete_read_required", "coverage_mode"];
9970        let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9971        assert!(ctx.contains("could not be fully satisfied"));
9972        assert!(ctx.contains("concrete_read_required"));
9973        assert!(ctx.contains("coverage_mode"));
9974        assert!(ctx.contains("write"));
9975        assert!(ctx.contains("Do not write a blocked or placeholder file"));
9976    }
9977
9978    #[test]
9979    fn prewrite_waived_write_context_includes_output_path_when_present() {
9980        let user_text = "Required output target: {\"path\": \"marketing-brief.md\"}";
9981        let unmet = vec!["concrete_read_required"];
9982        let ctx = build_prewrite_waived_write_context(user_text, &unmet);
9983        assert!(ctx.contains("marketing-brief.md"));
9984        assert!(ctx.contains("`write`"));
9985    }
9986
9987    #[test]
9988    fn prewrite_gate_waived_disables_prewrite_gate_write() {
9989        let requirements = PrewriteRequirements {
9990            workspace_inspection_required: true,
9991            web_research_required: false,
9992            concrete_read_required: true,
9993            successful_web_research_required: false,
9994            repair_on_unmet_requirements: true,
9995            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
9996        };
9997        let before = evaluate_prewrite_gate(
9998            true,
9999            &requirements,
10000            PrewriteProgress {
10001                productive_write_tool_calls_total: 0,
10002                productive_workspace_inspection_total: 0,
10003                productive_concrete_read_total: 0,
10004                productive_web_research_total: 0,
10005                successful_web_research_total: 0,
10006                required_write_retry_count: 0,
10007                unmet_prewrite_repair_retry_count: 0,
10008                prewrite_gate_waived: false,
10009            },
10010        );
10011        assert!(before.gate_write, "gate should be active before waiver");
10012        let after = evaluate_prewrite_gate(
10013            true,
10014            &requirements,
10015            PrewriteProgress {
10016                productive_write_tool_calls_total: 0,
10017                productive_workspace_inspection_total: 0,
10018                productive_concrete_read_total: 0,
10019                productive_web_research_total: 0,
10020                successful_web_research_total: 0,
10021                required_write_retry_count: 0,
10022                unmet_prewrite_repair_retry_count: 0,
10023                prewrite_gate_waived: true,
10024            },
10025        );
10026        assert!(!after.gate_write, "gate should be off after waiver");
10027    }
10028
10029    #[test]
10030    fn prewrite_gate_waived_disables_allow_repair_tools() {
10031        let requirements = PrewriteRequirements {
10032            workspace_inspection_required: true,
10033            web_research_required: true,
10034            concrete_read_required: true,
10035            successful_web_research_required: true,
10036            repair_on_unmet_requirements: true,
10037            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10038        };
10039        let before = evaluate_prewrite_gate(
10040            true,
10041            &requirements,
10042            PrewriteProgress {
10043                productive_write_tool_calls_total: 0,
10044                productive_workspace_inspection_total: 0,
10045                productive_concrete_read_total: 0,
10046                productive_web_research_total: 0,
10047                successful_web_research_total: 0,
10048                required_write_retry_count: 0,
10049                unmet_prewrite_repair_retry_count: 1,
10050                prewrite_gate_waived: false,
10051            },
10052        );
10053        assert!(
10054            before.allow_repair_tools,
10055            "repair tools should be active before waiver"
10056        );
10057        let after = evaluate_prewrite_gate(
10058            true,
10059            &requirements,
10060            PrewriteProgress {
10061                productive_write_tool_calls_total: 0,
10062                productive_workspace_inspection_total: 0,
10063                productive_concrete_read_total: 0,
10064                productive_web_research_total: 0,
10065                successful_web_research_total: 0,
10066                required_write_retry_count: 0,
10067                unmet_prewrite_repair_retry_count: 1,
10068                prewrite_gate_waived: true,
10069            },
10070        );
10071        assert!(
10072            !after.allow_repair_tools,
10073            "repair tools should be disabled after waiver"
10074        );
10075    }
10076
10077    #[test]
10078    fn force_write_only_enabled_after_prewrite_waiver() {
10079        let requirements = PrewriteRequirements {
10080            workspace_inspection_required: true,
10081            web_research_required: true,
10082            concrete_read_required: true,
10083            successful_web_research_required: true,
10084            repair_on_unmet_requirements: true,
10085            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10086        };
10087        let decision = evaluate_prewrite_gate(
10088            true,
10089            &requirements,
10090            PrewriteProgress {
10091                productive_write_tool_calls_total: 0,
10092                productive_workspace_inspection_total: 0,
10093                productive_concrete_read_total: 0,
10094                productive_web_research_total: 0,
10095                successful_web_research_total: 0,
10096                required_write_retry_count: 1,
10097                unmet_prewrite_repair_retry_count: 1,
10098                prewrite_gate_waived: true,
10099            },
10100        );
10101        assert!(
10102            decision.force_write_only_retry,
10103            "force_write_only should be active after prewrite waiver + write retry"
10104        );
10105    }
10106
10107    #[test]
10108    fn force_write_only_disabled_before_prewrite_waiver() {
10109        let requirements = PrewriteRequirements {
10110            workspace_inspection_required: true,
10111            web_research_required: true,
10112            concrete_read_required: true,
10113            successful_web_research_required: true,
10114            repair_on_unmet_requirements: true,
10115            coverage_mode: PrewriteCoverageMode::ResearchCorpus,
10116        };
10117        let decision = evaluate_prewrite_gate(
10118            true,
10119            &requirements,
10120            PrewriteProgress {
10121                productive_write_tool_calls_total: 0,
10122                productive_workspace_inspection_total: 0,
10123                productive_concrete_read_total: 0,
10124                productive_web_research_total: 0,
10125                successful_web_research_total: 0,
10126                required_write_retry_count: 1,
10127                unmet_prewrite_repair_retry_count: 1,
10128                prewrite_gate_waived: false,
10129            },
10130        );
10131        assert!(
10132            !decision.force_write_only_retry,
10133            "force_write_only should be disabled before waiver for prewrite nodes"
10134        );
10135    }
10136
10137    #[test]
10138    fn parse_budget_override_zero_disables_budget() {
10139        unsafe {
10140            std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "0");
10141        }
10142        assert_eq!(
10143            parse_budget_override("TANDEM_TOOL_BUDGET_DEFAULT"),
10144            Some(usize::MAX)
10145        );
10146        unsafe {
10147            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10148        }
10149    }
10150
10151    #[test]
10152    fn disable_tool_guard_budgets_env_overrides_all_budgets() {
10153        unsafe {
10154            std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10155            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10156        }
10157        assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), 1);
10158        // M-2: disabling guards now returns HARD_TOOL_CALL_CEILING, not usize::MAX,
10159        // because the hard ceiling cannot be bypassed by any env setting.
10160        assert_eq!(tool_budget_for("websearch"), HARD_TOOL_CALL_CEILING);
10161        unsafe {
10162            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10163        }
10164    }
10165
10166    #[test]
10167    fn email_delivery_budget_can_still_be_explicitly_overridden_when_global_budgets_are_disabled() {
10168        let _guard = env_test_lock();
10169        unsafe {
10170            std::env::set_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS", "1");
10171            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "0");
10172        }
10173        assert_eq!(tool_budget_for("mcp.arcade.gmail_sendemail"), usize::MAX);
10174        unsafe {
10175            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10176            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10177        }
10178    }
10179
10180    #[test]
10181    fn tool_budget_defaults_to_200_calls_and_1_for_email_delivery() {
10182        let _guard = env_test_lock();
10183        unsafe {
10184            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10185            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10186            std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10187            std::env::remove_var("TANDEM_TOOL_BUDGET_READ");
10188            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10189        }
10190        assert_eq!(tool_budget_for("bash"), 200);
10191        assert_eq!(tool_budget_for("websearch"), 200);
10192        assert_eq!(tool_budget_for("read"), 200);
10193        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10194        assert_eq!(
10195            tool_budget_for("mcp.composio_1.gmail_create_email_draft"),
10196            1
10197        );
10198    }
10199
10200    #[test]
10201    fn tool_budget_env_override_respects_minimum_floor() {
10202        let _guard = env_test_lock();
10203        unsafe {
10204            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10205            std::env::set_var("TANDEM_TOOL_BUDGET_DEFAULT", "17");
10206            std::env::set_var("TANDEM_TOOL_BUDGET_WEBSEARCH", "250");
10207            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10208        }
10209        assert_eq!(tool_budget_for("bash"), 200);
10210        assert_eq!(tool_budget_for("websearch"), 250);
10211        unsafe {
10212            std::env::remove_var("TANDEM_TOOL_BUDGET_DEFAULT");
10213            std::env::remove_var("TANDEM_TOOL_BUDGET_WEBSEARCH");
10214        }
10215    }
10216
10217    #[test]
10218    fn email_delivery_tool_budget_env_override_respects_floor_of_one() {
10219        let _guard = env_test_lock();
10220        unsafe {
10221            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10222            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "1");
10223        }
10224        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 1);
10225        unsafe {
10226            std::env::set_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY", "5");
10227        }
10228        assert_eq!(tool_budget_for("mcp.composio_1.gmail_send_email"), 5);
10229        unsafe {
10230            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10231        }
10232    }
10233
10234    #[test]
10235    fn provider_agnostic_email_tools_share_single_send_budget() {
10236        let _guard = env_test_lock();
10237        unsafe {
10238            std::env::remove_var("TANDEM_DISABLE_TOOL_GUARD_BUDGETS");
10239            std::env::remove_var("TANDEM_TOOL_BUDGET_EMAIL_DELIVERY");
10240        }
10241        assert_eq!(tool_budget_for("mcp.sendgrid.send_email"), 1);
10242        assert_eq!(tool_budget_for("mcp.resend.create_email_draft"), 1);
10243        assert_eq!(duplicate_signature_limit_for("mcp.outlook.reply_email"), 1);
10244    }
10245}