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