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