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