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 tandem_observability::{emit_event, ObservabilityEvent, ProcessKind};
9use tandem_providers::{ChatMessage, ProviderRegistry, StreamChunk, TokenUsage};
10use tandem_tools::{validate_tool_schemas, ToolRegistry};
11use tandem_types::{
12    EngineEvent, HostOs, HostRuntimeContext, Message, MessagePart, MessagePartInput, MessageRole,
13    ModelSpec, PathStyle, SendMessageRequest, ShellFamily,
14};
15use tandem_wire::WireMessagePart;
16use tokio_util::sync::CancellationToken;
17use tracing::Level;
18
19use crate::{
20    derive_session_title_from_prompt, title_needs_repair, AgentDefinition, AgentRegistry,
21    CancellationRegistry, EventBus, PermissionAction, PermissionManager, PluginRegistry, Storage,
22};
23use tokio::sync::RwLock;
24
25#[derive(Default)]
26struct StreamedToolCall {
27    name: String,
28    args: String,
29}
30
31#[derive(Debug, Clone)]
32pub struct SpawnAgentToolContext {
33    pub session_id: String,
34    pub message_id: String,
35    pub tool_call_id: Option<String>,
36    pub args: Value,
37}
38
39#[derive(Debug, Clone)]
40pub struct SpawnAgentToolResult {
41    pub output: String,
42    pub metadata: Value,
43}
44
45#[derive(Debug, Clone)]
46pub struct ToolPolicyContext {
47    pub session_id: String,
48    pub message_id: String,
49    pub tool: String,
50    pub args: Value,
51}
52
53#[derive(Debug, Clone)]
54pub struct ToolPolicyDecision {
55    pub allowed: bool,
56    pub reason: Option<String>,
57}
58
59pub trait SpawnAgentHook: Send + Sync {
60    fn spawn_agent(
61        &self,
62        ctx: SpawnAgentToolContext,
63    ) -> BoxFuture<'static, anyhow::Result<SpawnAgentToolResult>>;
64}
65
66pub trait ToolPolicyHook: Send + Sync {
67    fn evaluate_tool(
68        &self,
69        ctx: ToolPolicyContext,
70    ) -> BoxFuture<'static, anyhow::Result<ToolPolicyDecision>>;
71}
72
73#[derive(Debug, Clone)]
74pub struct PromptContextHookContext {
75    pub session_id: String,
76    pub message_id: String,
77    pub provider_id: String,
78    pub model_id: String,
79    pub iteration: usize,
80}
81
82pub trait PromptContextHook: Send + Sync {
83    fn augment_provider_messages(
84        &self,
85        ctx: PromptContextHookContext,
86        messages: Vec<ChatMessage>,
87    ) -> BoxFuture<'static, anyhow::Result<Vec<ChatMessage>>>;
88}
89
90#[derive(Clone)]
91pub struct EngineLoop {
92    storage: std::sync::Arc<Storage>,
93    event_bus: EventBus,
94    providers: ProviderRegistry,
95    plugins: PluginRegistry,
96    agents: AgentRegistry,
97    permissions: PermissionManager,
98    tools: ToolRegistry,
99    cancellations: CancellationRegistry,
100    host_runtime_context: HostRuntimeContext,
101    workspace_overrides: std::sync::Arc<RwLock<HashMap<String, u64>>>,
102    session_allowed_tools: std::sync::Arc<RwLock<HashMap<String, Vec<String>>>>,
103    spawn_agent_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn SpawnAgentHook>>>>,
104    tool_policy_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn ToolPolicyHook>>>>,
105    prompt_context_hook: std::sync::Arc<RwLock<Option<std::sync::Arc<dyn PromptContextHook>>>>,
106}
107
108impl EngineLoop {
109    #[allow(clippy::too_many_arguments)]
110    pub fn new(
111        storage: std::sync::Arc<Storage>,
112        event_bus: EventBus,
113        providers: ProviderRegistry,
114        plugins: PluginRegistry,
115        agents: AgentRegistry,
116        permissions: PermissionManager,
117        tools: ToolRegistry,
118        cancellations: CancellationRegistry,
119        host_runtime_context: HostRuntimeContext,
120    ) -> Self {
121        Self {
122            storage,
123            event_bus,
124            providers,
125            plugins,
126            agents,
127            permissions,
128            tools,
129            cancellations,
130            host_runtime_context,
131            workspace_overrides: std::sync::Arc::new(RwLock::new(HashMap::new())),
132            session_allowed_tools: std::sync::Arc::new(RwLock::new(HashMap::new())),
133            spawn_agent_hook: std::sync::Arc::new(RwLock::new(None)),
134            tool_policy_hook: std::sync::Arc::new(RwLock::new(None)),
135            prompt_context_hook: std::sync::Arc::new(RwLock::new(None)),
136        }
137    }
138
139    pub async fn set_spawn_agent_hook(&self, hook: std::sync::Arc<dyn SpawnAgentHook>) {
140        *self.spawn_agent_hook.write().await = Some(hook);
141    }
142
143    pub async fn set_tool_policy_hook(&self, hook: std::sync::Arc<dyn ToolPolicyHook>) {
144        *self.tool_policy_hook.write().await = Some(hook);
145    }
146
147    pub async fn set_prompt_context_hook(&self, hook: std::sync::Arc<dyn PromptContextHook>) {
148        *self.prompt_context_hook.write().await = Some(hook);
149    }
150
151    pub async fn set_session_allowed_tools(&self, session_id: &str, allowed_tools: Vec<String>) {
152        let normalized = allowed_tools
153            .into_iter()
154            .map(|tool| normalize_tool_name(&tool))
155            .filter(|tool| !tool.trim().is_empty())
156            .collect::<Vec<_>>();
157        self.session_allowed_tools
158            .write()
159            .await
160            .insert(session_id.to_string(), normalized);
161    }
162
163    pub async fn clear_session_allowed_tools(&self, session_id: &str) {
164        self.session_allowed_tools.write().await.remove(session_id);
165    }
166
167    pub async fn grant_workspace_override_for_session(
168        &self,
169        session_id: &str,
170        ttl_seconds: u64,
171    ) -> u64 {
172        let expires_at = chrono::Utc::now()
173            .timestamp_millis()
174            .max(0)
175            .saturating_add((ttl_seconds as i64).saturating_mul(1000))
176            as u64;
177        self.workspace_overrides
178            .write()
179            .await
180            .insert(session_id.to_string(), expires_at);
181        expires_at
182    }
183
184    pub async fn run_prompt_async(
185        &self,
186        session_id: String,
187        req: SendMessageRequest,
188    ) -> anyhow::Result<()> {
189        self.run_prompt_async_with_context(session_id, req, None)
190            .await
191    }
192
193    pub async fn run_prompt_async_with_context(
194        &self,
195        session_id: String,
196        req: SendMessageRequest,
197        correlation_id: Option<String>,
198    ) -> anyhow::Result<()> {
199        let session_model = self
200            .storage
201            .get_session(&session_id)
202            .await
203            .and_then(|s| s.model);
204        let (provider_id, model_id_value) =
205            resolve_model_route(req.model.as_ref(), session_model.as_ref()).ok_or_else(|| {
206                anyhow::anyhow!(
207                "MODEL_SELECTION_REQUIRED: explicit provider/model is required for this request."
208            )
209            })?;
210        let correlation_ref = correlation_id.as_deref();
211        let model_id = Some(model_id_value.as_str());
212        let cancel = self.cancellations.create(&session_id).await;
213        emit_event(
214            Level::INFO,
215            ProcessKind::Engine,
216            ObservabilityEvent {
217                event: "provider.call.start",
218                component: "engine.loop",
219                correlation_id: correlation_ref,
220                session_id: Some(&session_id),
221                run_id: None,
222                message_id: None,
223                provider_id: Some(provider_id.as_str()),
224                model_id,
225                status: Some("start"),
226                error_code: None,
227                detail: Some("run_prompt_async dispatch"),
228            },
229        );
230        self.event_bus.publish(EngineEvent::new(
231            "session.status",
232            json!({"sessionID": session_id, "status":"running"}),
233        ));
234        let text = req
235            .parts
236            .iter()
237            .map(|p| match p {
238                MessagePartInput::Text { text } => text.clone(),
239                MessagePartInput::File {
240                    mime,
241                    filename,
242                    url,
243                } => format!(
244                    "[file mime={} name={} url={}]",
245                    mime,
246                    filename.clone().unwrap_or_else(|| "unknown".to_string()),
247                    url
248                ),
249            })
250            .collect::<Vec<_>>()
251            .join("\n");
252        self.auto_rename_session_from_user_text(&session_id, &text)
253            .await;
254        let active_agent = self.agents.get(req.agent.as_deref()).await;
255        let mut user_message_id = self
256            .find_recent_matching_user_message_id(&session_id, &text)
257            .await;
258        if user_message_id.is_none() {
259            let user_message = Message::new(
260                MessageRole::User,
261                vec![MessagePart::Text { text: text.clone() }],
262            );
263            let created_message_id = user_message.id.clone();
264            self.storage
265                .append_message(&session_id, user_message)
266                .await?;
267
268            let user_part = WireMessagePart::text(&session_id, &created_message_id, text.clone());
269            self.event_bus.publish(EngineEvent::new(
270                "message.part.updated",
271                json!({
272                    "part": user_part,
273                    "delta": text,
274                    "agent": active_agent.name
275                }),
276            ));
277            user_message_id = Some(created_message_id);
278        }
279        let user_message_id = user_message_id.unwrap_or_else(|| "unknown".to_string());
280
281        if cancel.is_cancelled() {
282            self.event_bus.publish(EngineEvent::new(
283                "session.status",
284                json!({"sessionID": session_id, "status":"cancelled"}),
285            ));
286            self.cancellations.remove(&session_id).await;
287            return Ok(());
288        }
289
290        let mut question_tool_used = false;
291        let completion = if let Some((tool, args)) = parse_tool_invocation(&text) {
292            if normalize_tool_name(&tool) == "question" {
293                question_tool_used = true;
294            }
295            if !agent_can_use_tool(&active_agent, &tool) {
296                format!(
297                    "Tool `{tool}` is not enabled for agent `{}`.",
298                    active_agent.name
299                )
300            } else {
301                self.execute_tool_with_permission(
302                    &session_id,
303                    &user_message_id,
304                    tool.clone(),
305                    args,
306                    active_agent.skills.as_deref(),
307                    &text,
308                    None,
309                    cancel.clone(),
310                )
311                .await?
312                .unwrap_or_default()
313            }
314        } else {
315            let mut completion = String::new();
316            let mut max_iterations = 25usize;
317            let mut followup_context: Option<String> = None;
318            let mut last_tool_outputs: Vec<String> = Vec::new();
319            let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
320            let mut readonly_tool_cache: HashMap<String, String> = HashMap::new();
321            let mut readonly_signature_counts: HashMap<String, usize> = HashMap::new();
322            let mut shell_mismatch_signatures: HashSet<String> = HashSet::new();
323            let mut blocked_mcp_servers: HashSet<String> = HashSet::new();
324            let mut websearch_query_blocked = false;
325            let mut auto_workspace_probe_attempted = false;
326
327            while max_iterations > 0 && !cancel.is_cancelled() {
328                let iteration = 26usize.saturating_sub(max_iterations);
329                max_iterations -= 1;
330                let mut messages = load_chat_history(self.storage.clone(), &session_id).await;
331                let mut system_parts =
332                    vec![tandem_runtime_system_prompt(&self.host_runtime_context)];
333                if let Some(system) = active_agent.system_prompt.as_ref() {
334                    system_parts.push(system.clone());
335                }
336                messages.insert(
337                    0,
338                    ChatMessage {
339                        role: "system".to_string(),
340                        content: system_parts.join("\n\n"),
341                    },
342                );
343                if let Some(extra) = followup_context.take() {
344                    messages.push(ChatMessage {
345                        role: "user".to_string(),
346                        content: extra,
347                    });
348                }
349                if let Some(hook) = self.prompt_context_hook.read().await.clone() {
350                    let ctx = PromptContextHookContext {
351                        session_id: session_id.clone(),
352                        message_id: user_message_id.clone(),
353                        provider_id: provider_id.clone(),
354                        model_id: model_id_value.clone(),
355                        iteration,
356                    };
357                    if let Ok(augmented) =
358                        hook.augment_provider_messages(ctx, messages.clone()).await
359                    {
360                        messages = augmented;
361                    }
362                }
363                let mut tool_schemas = self.tools.list().await;
364                if active_agent.tools.is_some() {
365                    tool_schemas.retain(|schema| agent_can_use_tool(&active_agent, &schema.name));
366                }
367                tool_schemas.retain(|schema| {
368                    let normalized = normalize_tool_name(&schema.name);
369                    if let Some(server) = mcp_server_from_tool_name(&normalized) {
370                        !blocked_mcp_servers.contains(server)
371                    } else {
372                        true
373                    }
374                });
375                if let Some(allowed_tools) = self
376                    .session_allowed_tools
377                    .read()
378                    .await
379                    .get(&session_id)
380                    .cloned()
381                {
382                    if !allowed_tools.is_empty() {
383                        tool_schemas.retain(|schema| {
384                            let normalized = normalize_tool_name(&schema.name);
385                            allowed_tools.iter().any(|tool| tool == &normalized)
386                        });
387                    }
388                }
389                if let Err(validation_err) = validate_tool_schemas(&tool_schemas) {
390                    let detail = validation_err.to_string();
391                    emit_event(
392                        Level::ERROR,
393                        ProcessKind::Engine,
394                        ObservabilityEvent {
395                            event: "provider.call.error",
396                            component: "engine.loop",
397                            correlation_id: correlation_ref,
398                            session_id: Some(&session_id),
399                            run_id: None,
400                            message_id: Some(&user_message_id),
401                            provider_id: Some(provider_id.as_str()),
402                            model_id,
403                            status: Some("failed"),
404                            error_code: Some("TOOL_SCHEMA_INVALID"),
405                            detail: Some(&detail),
406                        },
407                    );
408                    anyhow::bail!("{detail}");
409                }
410                let stream = self
411                    .providers
412                    .stream_for_provider(
413                        Some(provider_id.as_str()),
414                        Some(model_id_value.as_str()),
415                        messages,
416                        Some(tool_schemas),
417                        cancel.clone(),
418                    )
419                    .await
420                    .inspect_err(|err| {
421                        let error_text = err.to_string();
422                        let error_code = provider_error_code(&error_text);
423                        let detail = truncate_text(&error_text, 500);
424                        emit_event(
425                            Level::ERROR,
426                            ProcessKind::Engine,
427                            ObservabilityEvent {
428                                event: "provider.call.error",
429                                component: "engine.loop",
430                                correlation_id: correlation_ref,
431                                session_id: Some(&session_id),
432                                run_id: None,
433                                message_id: Some(&user_message_id),
434                                provider_id: Some(provider_id.as_str()),
435                                model_id,
436                                status: Some("failed"),
437                                error_code: Some(error_code),
438                                detail: Some(&detail),
439                            },
440                        );
441                    })?;
442                tokio::pin!(stream);
443                completion.clear();
444                let mut streamed_tool_calls: HashMap<String, StreamedToolCall> = HashMap::new();
445                let mut provider_usage: Option<TokenUsage> = None;
446                while let Some(chunk) = stream.next().await {
447                    let chunk = match chunk {
448                        Ok(chunk) => chunk,
449                        Err(err) => {
450                            let error_text = err.to_string();
451                            let error_code = provider_error_code(&error_text);
452                            let detail = truncate_text(&error_text, 500);
453                            emit_event(
454                                Level::ERROR,
455                                ProcessKind::Engine,
456                                ObservabilityEvent {
457                                    event: "provider.call.error",
458                                    component: "engine.loop",
459                                    correlation_id: correlation_ref,
460                                    session_id: Some(&session_id),
461                                    run_id: None,
462                                    message_id: Some(&user_message_id),
463                                    provider_id: Some(provider_id.as_str()),
464                                    model_id,
465                                    status: Some("failed"),
466                                    error_code: Some(error_code),
467                                    detail: Some(&detail),
468                                },
469                            );
470                            return Err(anyhow::anyhow!(
471                                "provider stream chunk error: {error_text}"
472                            ));
473                        }
474                    };
475                    match chunk {
476                        StreamChunk::TextDelta(delta) => {
477                            let delta = strip_model_control_markers(&delta);
478                            if delta.trim().is_empty() {
479                                continue;
480                            }
481                            if completion.is_empty() {
482                                emit_event(
483                                    Level::INFO,
484                                    ProcessKind::Engine,
485                                    ObservabilityEvent {
486                                        event: "provider.call.first_byte",
487                                        component: "engine.loop",
488                                        correlation_id: correlation_ref,
489                                        session_id: Some(&session_id),
490                                        run_id: None,
491                                        message_id: Some(&user_message_id),
492                                        provider_id: Some(provider_id.as_str()),
493                                        model_id,
494                                        status: Some("streaming"),
495                                        error_code: None,
496                                        detail: Some("first text delta"),
497                                    },
498                                );
499                            }
500                            completion.push_str(&delta);
501                            let delta = truncate_text(&delta, 4_000);
502                            let delta_part =
503                                WireMessagePart::text(&session_id, &user_message_id, delta.clone());
504                            self.event_bus.publish(EngineEvent::new(
505                                "message.part.updated",
506                                json!({"part": delta_part, "delta": delta}),
507                            ));
508                        }
509                        StreamChunk::ReasoningDelta(_reasoning) => {}
510                        StreamChunk::Done {
511                            finish_reason: _,
512                            usage,
513                        } => {
514                            if usage.is_some() {
515                                provider_usage = usage;
516                            }
517                            break;
518                        }
519                        StreamChunk::ToolCallStart { id, name } => {
520                            let entry = streamed_tool_calls.entry(id).or_default();
521                            if entry.name.is_empty() {
522                                entry.name = name;
523                            }
524                        }
525                        StreamChunk::ToolCallDelta { id, args_delta } => {
526                            let entry = streamed_tool_calls.entry(id.clone()).or_default();
527                            entry.args.push_str(&args_delta);
528                            let tool_name = if entry.name.trim().is_empty() {
529                                "tool".to_string()
530                            } else {
531                                normalize_tool_name(&entry.name)
532                            };
533                            let parsed_preview = if entry.name.trim().is_empty() {
534                                Value::String(truncate_text(&entry.args, 1_000))
535                            } else {
536                                parse_streamed_tool_args(&tool_name, &entry.args)
537                            };
538                            let mut tool_part = WireMessagePart::tool_invocation(
539                                &session_id,
540                                &user_message_id,
541                                tool_name.clone(),
542                                json!({}),
543                            );
544                            tool_part.id = Some(id.clone());
545                            self.event_bus.publish(EngineEvent::new(
546                                "message.part.updated",
547                                json!({
548                                    "part": tool_part,
549                                    "toolCallDelta": {
550                                        "id": id,
551                                        "tool": tool_name,
552                                        "argsDelta": truncate_text(&args_delta, 1_000),
553                                        "parsedArgsPreview": parsed_preview
554                                    }
555                                }),
556                            ));
557                        }
558                        StreamChunk::ToolCallEnd { id: _ } => {}
559                    }
560                    if cancel.is_cancelled() {
561                        break;
562                    }
563                }
564
565                let mut tool_calls = streamed_tool_calls
566                    .into_values()
567                    .filter_map(|call| {
568                        if call.name.trim().is_empty() {
569                            return None;
570                        }
571                        let tool_name = normalize_tool_name(&call.name);
572                        let parsed_args = parse_streamed_tool_args(&tool_name, &call.args);
573                        Some((tool_name, parsed_args))
574                    })
575                    .collect::<Vec<_>>();
576                if tool_calls.is_empty() {
577                    tool_calls = parse_tool_invocations_from_response(&completion);
578                }
579                if tool_calls.is_empty()
580                    && !auto_workspace_probe_attempted
581                    && should_force_workspace_probe(&text, &completion)
582                {
583                    auto_workspace_probe_attempted = true;
584                    tool_calls = vec![("glob".to_string(), json!({ "pattern": "*" }))];
585                }
586                if !tool_calls.is_empty() {
587                    let mut outputs = Vec::new();
588                    let mut executed_productive_tool = false;
589                    let mut auth_required_hit_in_cycle = false;
590                    let mut guard_budget_hit_in_cycle = false;
591                    for (tool, args) in tool_calls {
592                        if !agent_can_use_tool(&active_agent, &tool) {
593                            continue;
594                        }
595                        let tool_key = normalize_tool_name(&tool);
596                        if let Some(server) = mcp_server_from_tool_name(&tool_key) {
597                            if blocked_mcp_servers.contains(server) {
598                                outputs.push(format!(
599                                    "Tool `{}` call skipped: authorization is still pending for MCP server `{}`.",
600                                    tool_key, server
601                                ));
602                                continue;
603                            }
604                        }
605                        if tool_key == "question" {
606                            question_tool_used = true;
607                        }
608                        if websearch_query_blocked && tool_key == "websearch" {
609                            outputs.push(
610                                "Tool `websearch` call skipped: WEBSEARCH_QUERY_MISSING"
611                                    .to_string(),
612                            );
613                            continue;
614                        }
615                        let entry = tool_call_counts.entry(tool_key.clone()).or_insert(0);
616                        *entry += 1;
617                        let budget = tool_budget_for(&tool_key);
618                        if *entry > budget {
619                            outputs.push(format!(
620                                "Tool `{}` call skipped: per-run guard budget exceeded ({}).",
621                                tool_key, budget
622                            ));
623                            guard_budget_hit_in_cycle = true;
624                            continue;
625                        }
626                        let mut effective_args = args.clone();
627                        if tool_key == "todo_write" {
628                            effective_args = normalize_todo_write_args(effective_args, &completion);
629                            if is_empty_todo_write_args(&effective_args) {
630                                outputs.push(
631                                    "Tool `todo_write` call skipped: empty todo payload."
632                                        .to_string(),
633                                );
634                                continue;
635                            }
636                        }
637                        let signature = if tool_key == "batch" {
638                            batch_tool_signature(&args)
639                                .unwrap_or_else(|| tool_signature(&tool_key, &args))
640                        } else {
641                            tool_signature(&tool_key, &args)
642                        };
643                        if is_shell_tool_name(&tool_key)
644                            && shell_mismatch_signatures.contains(&signature)
645                        {
646                            outputs.push(
647                                "Tool `bash` call skipped: previous invocation hit an OS/path mismatch. Use `read`, `glob`, or `grep`."
648                                    .to_string(),
649                            );
650                            continue;
651                        }
652                        let mut signature_count = 1usize;
653                        if is_read_only_tool(&tool_key)
654                            || (tool_key == "batch" && is_read_only_batch_call(&args))
655                        {
656                            let count = readonly_signature_counts
657                                .entry(signature.clone())
658                                .and_modify(|v| *v = v.saturating_add(1))
659                                .or_insert(1);
660                            signature_count = *count;
661                            if tool_key == "websearch" && *count > 2 {
662                                self.event_bus.publish(EngineEvent::new(
663                                    "tool.loop_guard.triggered",
664                                    json!({
665                                        "sessionID": session_id,
666                                        "messageID": user_message_id,
667                                        "tool": tool_key,
668                                        "reason": "duplicate_signature_retry_exhausted",
669                                        "queryHash": extract_websearch_query(&args).map(|q| stable_hash(&q)),
670                                        "loop_guard_triggered": true
671                                    }),
672                                ));
673                                outputs.push(
674                                    "Tool `websearch` call skipped: WEBSEARCH_LOOP_GUARD"
675                                        .to_string(),
676                                );
677                                continue;
678                            }
679                            if tool_key != "websearch" && *count > 1 {
680                                if let Some(cached) = readonly_tool_cache.get(&signature) {
681                                    outputs.push(cached.clone());
682                                } else {
683                                    outputs.push(format!(
684                                        "Tool `{}` call skipped: duplicate call signature detected.",
685                                        tool_key
686                                    ));
687                                }
688                                continue;
689                            }
690                        }
691                        if let Some(output) = self
692                            .execute_tool_with_permission(
693                                &session_id,
694                                &user_message_id,
695                                tool,
696                                effective_args,
697                                active_agent.skills.as_deref(),
698                                &text,
699                                Some(&completion),
700                                cancel.clone(),
701                            )
702                            .await?
703                        {
704                            let productive = !(tool_key == "batch"
705                                && is_non_productive_batch_output(&output))
706                                && !is_auth_required_tool_output(&output);
707                            if output.contains("WEBSEARCH_QUERY_MISSING") {
708                                websearch_query_blocked = true;
709                            }
710                            if is_shell_tool_name(&tool_key) && is_os_mismatch_tool_output(&output)
711                            {
712                                shell_mismatch_signatures.insert(signature.clone());
713                            }
714                            if is_read_only_tool(&tool_key)
715                                && tool_key != "websearch"
716                                && signature_count == 1
717                            {
718                                readonly_tool_cache.insert(signature, output.clone());
719                            }
720                            if productive {
721                                executed_productive_tool = true;
722                            }
723                            if is_auth_required_tool_output(&output) {
724                                if let Some(server) = mcp_server_from_tool_name(&tool_key) {
725                                    blocked_mcp_servers.insert(server.to_string());
726                                }
727                                auth_required_hit_in_cycle = true;
728                            }
729                            outputs.push(output);
730                            if auth_required_hit_in_cycle {
731                                break;
732                            }
733                            if guard_budget_hit_in_cycle {
734                                break;
735                            }
736                        }
737                    }
738                    if !outputs.is_empty() {
739                        last_tool_outputs = outputs.clone();
740                        let guard_budget_hit =
741                            outputs.iter().any(|o| is_guard_budget_tool_output(o));
742                        if executed_productive_tool {
743                            followup_context = Some(format!(
744                                "{}\nContinue with a concise final response and avoid repeating identical tool calls.",
745                                summarize_tool_outputs(&outputs)
746                            ));
747                            continue;
748                        }
749                        if guard_budget_hit {
750                            completion = summarize_guard_budget_outputs(&outputs)
751                                .unwrap_or_else(|| {
752                                    "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()
753                                });
754                        } else if let Some(summary) = summarize_auth_pending_outputs(&outputs) {
755                            completion = summary;
756                        } else {
757                            completion.clear();
758                        }
759                        break;
760                    }
761                }
762
763                if let Some(usage) = provider_usage {
764                    self.event_bus.publish(EngineEvent::new(
765                        "provider.usage",
766                        json!({
767                            "sessionID": session_id,
768                            "messageID": user_message_id,
769                            "promptTokens": usage.prompt_tokens,
770                            "completionTokens": usage.completion_tokens,
771                            "totalTokens": usage.total_tokens,
772                        }),
773                    ));
774                }
775
776                break;
777            }
778            if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
779                if let Some(narrative) = self
780                    .generate_final_narrative_without_tools(
781                        &session_id,
782                        &active_agent,
783                        Some(provider_id.as_str()),
784                        Some(model_id_value.as_str()),
785                        cancel.clone(),
786                        &last_tool_outputs,
787                    )
788                    .await
789                {
790                    completion = narrative;
791                }
792            }
793            if completion.trim().is_empty() && !last_tool_outputs.is_empty() {
794                let preview = last_tool_outputs
795                    .iter()
796                    .take(3)
797                    .map(|o| truncate_text(o, 240))
798                    .collect::<Vec<_>>()
799                    .join("\n");
800                completion = format!(
801                    "I completed project analysis steps using tools, but the model returned no final narrative text.\n\nTool result summary:\n{}",
802                    preview
803                );
804            }
805            completion = strip_model_control_markers(&completion);
806            truncate_text(&completion, 16_000)
807        };
808        emit_event(
809            Level::INFO,
810            ProcessKind::Engine,
811            ObservabilityEvent {
812                event: "provider.call.finish",
813                component: "engine.loop",
814                correlation_id: correlation_ref,
815                session_id: Some(&session_id),
816                run_id: None,
817                message_id: Some(&user_message_id),
818                provider_id: Some(provider_id.as_str()),
819                model_id,
820                status: Some("ok"),
821                error_code: None,
822                detail: Some("provider stream complete"),
823            },
824        );
825        if active_agent.name.eq_ignore_ascii_case("plan") {
826            emit_plan_todo_fallback(
827                self.storage.clone(),
828                &self.event_bus,
829                &session_id,
830                &user_message_id,
831                &completion,
832            )
833            .await;
834            let todos_after_fallback = self.storage.get_todos(&session_id).await;
835            if todos_after_fallback.is_empty() && !question_tool_used {
836                emit_plan_question_fallback(
837                    self.storage.clone(),
838                    &self.event_bus,
839                    &session_id,
840                    &user_message_id,
841                    &completion,
842                )
843                .await;
844            }
845        }
846        if cancel.is_cancelled() {
847            self.event_bus.publish(EngineEvent::new(
848                "session.status",
849                json!({"sessionID": session_id, "status":"cancelled"}),
850            ));
851            self.cancellations.remove(&session_id).await;
852            return Ok(());
853        }
854        let assistant = Message::new(
855            MessageRole::Assistant,
856            vec![MessagePart::Text {
857                text: completion.clone(),
858            }],
859        );
860        let assistant_message_id = assistant.id.clone();
861        self.storage.append_message(&session_id, assistant).await?;
862        let final_part = WireMessagePart::text(
863            &session_id,
864            &assistant_message_id,
865            truncate_text(&completion, 16_000),
866        );
867        self.event_bus.publish(EngineEvent::new(
868            "message.part.updated",
869            json!({"part": final_part}),
870        ));
871        self.event_bus.publish(EngineEvent::new(
872            "session.updated",
873            json!({"sessionID": session_id, "status":"idle"}),
874        ));
875        self.event_bus.publish(EngineEvent::new(
876            "session.status",
877            json!({"sessionID": session_id, "status":"idle"}),
878        ));
879        self.cancellations.remove(&session_id).await;
880        Ok(())
881    }
882
883    pub async fn run_oneshot(&self, prompt: String) -> anyhow::Result<String> {
884        self.providers.default_complete(&prompt).await
885    }
886
887    pub async fn run_oneshot_for_provider(
888        &self,
889        prompt: String,
890        provider_id: Option<&str>,
891    ) -> anyhow::Result<String> {
892        self.providers
893            .complete_for_provider(provider_id, &prompt, None)
894            .await
895    }
896
897    #[allow(clippy::too_many_arguments)]
898    async fn execute_tool_with_permission(
899        &self,
900        session_id: &str,
901        message_id: &str,
902        tool: String,
903        args: Value,
904        equipped_skills: Option<&[String]>,
905        latest_user_text: &str,
906        latest_assistant_context: Option<&str>,
907        cancel: CancellationToken,
908    ) -> anyhow::Result<Option<String>> {
909        let tool = normalize_tool_name(&tool);
910        let normalized = normalize_tool_args(
911            &tool,
912            args,
913            latest_user_text,
914            latest_assistant_context.unwrap_or_default(),
915        );
916        self.event_bus.publish(EngineEvent::new(
917            "tool.args.normalized",
918            json!({
919                "sessionID": session_id,
920                "messageID": message_id,
921                "tool": tool,
922                "argsSource": normalized.args_source,
923                "argsIntegrity": normalized.args_integrity,
924                "query": normalized.query,
925                "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
926                "requestID": Value::Null
927            }),
928        ));
929        if normalized.args_integrity == "recovered" {
930            self.event_bus.publish(EngineEvent::new(
931                "tool.args.recovered",
932                json!({
933                    "sessionID": session_id,
934                    "messageID": message_id,
935                    "tool": tool,
936                    "argsSource": normalized.args_source,
937                    "query": normalized.query,
938                    "queryHash": normalized.query.as_ref().map(|q| stable_hash(q)),
939                    "requestID": Value::Null
940                }),
941            ));
942        }
943        if normalized.missing_terminal {
944            let missing_reason = normalized
945                .missing_terminal_reason
946                .clone()
947                .unwrap_or_else(|| "TOOL_ARGUMENTS_MISSING".to_string());
948            self.event_bus.publish(EngineEvent::new(
949                "tool.args.missing_terminal",
950                json!({
951                    "sessionID": session_id,
952                    "messageID": message_id,
953                    "tool": tool,
954                    "argsSource": normalized.args_source,
955                    "argsIntegrity": normalized.args_integrity,
956                    "requestID": Value::Null,
957                    "error": missing_reason
958                }),
959            ));
960            let mut failed_part =
961                WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
962            failed_part.state = Some("failed".to_string());
963            failed_part.error = Some(missing_reason.clone());
964            self.event_bus.publish(EngineEvent::new(
965                "message.part.updated",
966                json!({"part": failed_part}),
967            ));
968            return Ok(Some(missing_reason));
969        }
970
971        let args = match enforce_skill_scope(&tool, normalized.args, equipped_skills) {
972            Ok(args) => args,
973            Err(message) => return Ok(Some(message)),
974        };
975        if let Some(allowed_tools) = self
976            .session_allowed_tools
977            .read()
978            .await
979            .get(session_id)
980            .cloned()
981        {
982            if !allowed_tools.is_empty() && !allowed_tools.iter().any(|name| name == &tool) {
983                return Ok(Some(format!("Tool `{tool}` is not allowed for this run.")));
984            }
985        }
986        if let Some(hook) = self.tool_policy_hook.read().await.clone() {
987            let decision = hook
988                .evaluate_tool(ToolPolicyContext {
989                    session_id: session_id.to_string(),
990                    message_id: message_id.to_string(),
991                    tool: tool.clone(),
992                    args: args.clone(),
993                })
994                .await?;
995            if !decision.allowed {
996                let reason = decision
997                    .reason
998                    .unwrap_or_else(|| "Tool denied by runtime policy".to_string());
999                let mut blocked_part =
1000                    WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1001                blocked_part.state = Some("failed".to_string());
1002                blocked_part.error = Some(reason.clone());
1003                self.event_bus.publish(EngineEvent::new(
1004                    "message.part.updated",
1005                    json!({"part": blocked_part}),
1006                ));
1007                return Ok(Some(reason));
1008            }
1009        }
1010        let mut tool_call_id: Option<String> = None;
1011        if let Some(violation) = self
1012            .workspace_sandbox_violation(session_id, &tool, &args)
1013            .await
1014        {
1015            let mut blocked_part =
1016                WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1017            blocked_part.state = Some("failed".to_string());
1018            blocked_part.error = Some(violation.clone());
1019            self.event_bus.publish(EngineEvent::new(
1020                "message.part.updated",
1021                json!({"part": blocked_part}),
1022            ));
1023            return Ok(Some(violation));
1024        }
1025        let rule = self
1026            .plugins
1027            .permission_override(&tool)
1028            .await
1029            .unwrap_or(self.permissions.evaluate(&tool, &tool).await);
1030        if matches!(rule, PermissionAction::Deny) {
1031            return Ok(Some(format!(
1032                "Permission denied for tool `{tool}` by policy."
1033            )));
1034        }
1035
1036        let mut effective_args = args.clone();
1037        if matches!(rule, PermissionAction::Ask) {
1038            let pending = self
1039                .permissions
1040                .ask_for_session_with_context(
1041                    Some(session_id),
1042                    &tool,
1043                    args.clone(),
1044                    Some(crate::PermissionArgsContext {
1045                        args_source: normalized.args_source.clone(),
1046                        args_integrity: normalized.args_integrity.clone(),
1047                        query: normalized.query.clone(),
1048                    }),
1049                )
1050                .await;
1051            let mut pending_part = WireMessagePart::tool_invocation(
1052                session_id,
1053                message_id,
1054                tool.clone(),
1055                args.clone(),
1056            );
1057            pending_part.id = Some(pending.id.clone());
1058            tool_call_id = Some(pending.id.clone());
1059            pending_part.state = Some("pending".to_string());
1060            self.event_bus.publish(EngineEvent::new(
1061                "message.part.updated",
1062                json!({"part": pending_part}),
1063            ));
1064            let reply = self
1065                .permissions
1066                .wait_for_reply(&pending.id, cancel.clone())
1067                .await;
1068            if cancel.is_cancelled() {
1069                return Ok(None);
1070            }
1071            let approved = matches!(reply.as_deref(), Some("once" | "always" | "allow"));
1072            if !approved {
1073                let mut denied_part =
1074                    WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1075                denied_part.id = Some(pending.id);
1076                denied_part.state = Some("denied".to_string());
1077                denied_part.error = Some("Permission denied by user".to_string());
1078                self.event_bus.publish(EngineEvent::new(
1079                    "message.part.updated",
1080                    json!({"part": denied_part}),
1081                ));
1082                return Ok(Some(format!(
1083                    "Permission denied for tool `{tool}` by user."
1084                )));
1085            }
1086            effective_args = args;
1087        }
1088
1089        let mut args = self.plugins.inject_tool_args(&tool, effective_args).await;
1090        let tool_context = self.resolve_tool_execution_context(session_id).await;
1091        if let Some((workspace_root, effective_cwd)) = tool_context.as_ref() {
1092            if let Some(obj) = args.as_object_mut() {
1093                obj.insert(
1094                    "__workspace_root".to_string(),
1095                    Value::String(workspace_root.clone()),
1096                );
1097                obj.insert(
1098                    "__effective_cwd".to_string(),
1099                    Value::String(effective_cwd.clone()),
1100                );
1101                obj.insert(
1102                    "__session_id".to_string(),
1103                    Value::String(session_id.to_string()),
1104                );
1105            }
1106            tracing::info!(
1107                "tool execution context session_id={} tool={} workspace_root={} effective_cwd={}",
1108                session_id,
1109                tool,
1110                workspace_root,
1111                effective_cwd
1112            );
1113        }
1114        let mut invoke_part =
1115            WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
1116        if let Some(call_id) = tool_call_id.clone() {
1117            invoke_part.id = Some(call_id);
1118        }
1119        let invoke_part_id = invoke_part.id.clone();
1120        self.event_bus.publish(EngineEvent::new(
1121            "message.part.updated",
1122            json!({"part": invoke_part}),
1123        ));
1124        let args_for_side_events = args.clone();
1125        if tool == "spawn_agent" {
1126            let hook = self.spawn_agent_hook.read().await.clone();
1127            if let Some(hook) = hook {
1128                let spawned = hook
1129                    .spawn_agent(SpawnAgentToolContext {
1130                        session_id: session_id.to_string(),
1131                        message_id: message_id.to_string(),
1132                        tool_call_id: invoke_part_id.clone(),
1133                        args: args_for_side_events.clone(),
1134                    })
1135                    .await?;
1136                let output = self.plugins.transform_tool_output(spawned.output).await;
1137                let output = truncate_text(&output, 16_000);
1138                emit_tool_side_events(
1139                    self.storage.clone(),
1140                    &self.event_bus,
1141                    ToolSideEventContext {
1142                        session_id,
1143                        message_id,
1144                        tool: &tool,
1145                        args: &args_for_side_events,
1146                        metadata: &spawned.metadata,
1147                        workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1148                        effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1149                    },
1150                )
1151                .await;
1152                let mut result_part = WireMessagePart::tool_result(
1153                    session_id,
1154                    message_id,
1155                    tool.clone(),
1156                    json!(output.clone()),
1157                );
1158                result_part.id = invoke_part_id;
1159                self.event_bus.publish(EngineEvent::new(
1160                    "message.part.updated",
1161                    json!({"part": result_part}),
1162                ));
1163                return Ok(Some(truncate_text(
1164                    &format!("Tool `{tool}` result:\n{output}"),
1165                    16_000,
1166                )));
1167            }
1168            let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
1169            let mut failed_part =
1170                WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1171            failed_part.id = invoke_part_id.clone();
1172            failed_part.state = Some("failed".to_string());
1173            failed_part.error = Some(output.to_string());
1174            self.event_bus.publish(EngineEvent::new(
1175                "message.part.updated",
1176                json!({"part": failed_part}),
1177            ));
1178            return Ok(Some(output.to_string()));
1179        }
1180        let result = match self
1181            .tools
1182            .execute_with_cancel(&tool, args, cancel.clone())
1183            .await
1184        {
1185            Ok(result) => result,
1186            Err(err) => {
1187                let err_text = err.to_string();
1188                if let Some(auth) = extract_mcp_auth_required_from_error_text(&tool, &err_text) {
1189                    self.event_bus.publish(EngineEvent::new(
1190                        "mcp.auth.required",
1191                        json!({
1192                            "sessionID": session_id,
1193                            "messageID": message_id,
1194                            "tool": tool.clone(),
1195                            "server": auth.server,
1196                            "authorizationUrl": auth.authorization_url,
1197                            "message": auth.message,
1198                            "challengeId": auth.challenge_id
1199                        }),
1200                    ));
1201                    let auth_output = format!(
1202                        "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
1203                        tool, auth.message, auth.authorization_url
1204                    );
1205                    let mut result_part = WireMessagePart::tool_result(
1206                        session_id,
1207                        message_id,
1208                        tool.clone(),
1209                        json!(auth_output.clone()),
1210                    );
1211                    result_part.id = invoke_part_id.clone();
1212                    self.event_bus.publish(EngineEvent::new(
1213                        "message.part.updated",
1214                        json!({"part": result_part}),
1215                    ));
1216                    return Ok(Some(truncate_text(
1217                        &format!("Tool `{tool}` result:\n{auth_output}"),
1218                        16_000,
1219                    )));
1220                }
1221                let mut failed_part =
1222                    WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1223                failed_part.id = invoke_part_id.clone();
1224                failed_part.state = Some("failed".to_string());
1225                failed_part.error = Some(err_text.clone());
1226                self.event_bus.publish(EngineEvent::new(
1227                    "message.part.updated",
1228                    json!({"part": failed_part}),
1229                ));
1230                return Err(err);
1231            }
1232        };
1233        if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
1234            let event_name = if auth.pending && auth.blocked {
1235                "mcp.auth.pending"
1236            } else {
1237                "mcp.auth.required"
1238            };
1239            self.event_bus.publish(EngineEvent::new(
1240                event_name,
1241                json!({
1242                    "sessionID": session_id,
1243                    "messageID": message_id,
1244                    "tool": tool.clone(),
1245                    "server": auth.server,
1246                    "authorizationUrl": auth.authorization_url,
1247                    "message": auth.message,
1248                    "challengeId": auth.challenge_id,
1249                    "pending": auth.pending,
1250                    "blocked": auth.blocked,
1251                    "retryAfterMs": auth.retry_after_ms
1252                }),
1253            ));
1254        }
1255        emit_tool_side_events(
1256            self.storage.clone(),
1257            &self.event_bus,
1258            ToolSideEventContext {
1259                session_id,
1260                message_id,
1261                tool: &tool,
1262                args: &args_for_side_events,
1263                metadata: &result.metadata,
1264                workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1265                effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1266            },
1267        )
1268        .await;
1269        let output = if let Some(auth) = extract_mcp_auth_required_metadata(&result.metadata) {
1270            if auth.pending && auth.blocked {
1271                let retry_after_secs = auth.retry_after_ms.unwrap_or(0).div_ceil(1000);
1272                format!(
1273                    "Authorization pending for `{}`.\n{}\n\nAuthorize here: {}\nRetry after {}s.",
1274                    tool, auth.message, auth.authorization_url, retry_after_secs
1275                )
1276            } else {
1277                format!(
1278                    "Authorization required for `{}`.\n{}\n\nAuthorize here: {}",
1279                    tool, auth.message, auth.authorization_url
1280                )
1281            }
1282        } else {
1283            self.plugins.transform_tool_output(result.output).await
1284        };
1285        let output = truncate_text(&output, 16_000);
1286        let mut result_part = WireMessagePart::tool_result(
1287            session_id,
1288            message_id,
1289            tool.clone(),
1290            json!(output.clone()),
1291        );
1292        result_part.id = invoke_part_id;
1293        self.event_bus.publish(EngineEvent::new(
1294            "message.part.updated",
1295            json!({"part": result_part}),
1296        ));
1297        Ok(Some(truncate_text(
1298            &format!("Tool `{tool}` result:\n{output}"),
1299            16_000,
1300        )))
1301    }
1302
1303    async fn find_recent_matching_user_message_id(
1304        &self,
1305        session_id: &str,
1306        text: &str,
1307    ) -> Option<String> {
1308        let session = self.storage.get_session(session_id).await?;
1309        let last = session.messages.last()?;
1310        if !matches!(last.role, MessageRole::User) {
1311            return None;
1312        }
1313        let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
1314        if age_ms > 10_000 {
1315            return None;
1316        }
1317        let last_text = last
1318            .parts
1319            .iter()
1320            .filter_map(|part| match part {
1321                MessagePart::Text { text } => Some(text.clone()),
1322                _ => None,
1323            })
1324            .collect::<Vec<_>>()
1325            .join("\n");
1326        if last_text == text {
1327            return Some(last.id.clone());
1328        }
1329        None
1330    }
1331
1332    async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
1333        let Some(mut session) = self.storage.get_session(session_id).await else {
1334            return;
1335        };
1336        if !title_needs_repair(&session.title) {
1337            return;
1338        }
1339
1340        let first_user_text = session.messages.iter().find_map(|message| {
1341            if !matches!(message.role, MessageRole::User) {
1342                return None;
1343            }
1344            message.parts.iter().find_map(|part| match part {
1345                MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
1346                _ => None,
1347            })
1348        });
1349
1350        let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
1351        let Some(title) = derive_session_title_from_prompt(&source, 60) else {
1352            return;
1353        };
1354
1355        session.title = title;
1356        session.time.updated = Utc::now();
1357        let _ = self.storage.save_session(session).await;
1358    }
1359
1360    async fn workspace_sandbox_violation(
1361        &self,
1362        session_id: &str,
1363        tool: &str,
1364        args: &Value,
1365    ) -> Option<String> {
1366        if self.workspace_override_active(session_id).await {
1367            return None;
1368        }
1369        let session = self.storage.get_session(session_id).await?;
1370        let workspace = session
1371            .workspace_root
1372            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1373        let workspace_path = PathBuf::from(&workspace);
1374        let candidate_paths = extract_tool_candidate_paths(tool, args);
1375        if candidate_paths.is_empty() {
1376            if is_shell_tool_name(tool) {
1377                if let Some(command) = extract_shell_command(args) {
1378                    if shell_command_targets_sensitive_path(&command) {
1379                        return Some(format!(
1380                            "Sandbox blocked `{tool}` command targeting sensitive paths."
1381                        ));
1382                    }
1383                }
1384            }
1385            return None;
1386        }
1387        if let Some(sensitive) = candidate_paths.iter().find(|path| {
1388            let raw = Path::new(path);
1389            let resolved = if raw.is_absolute() {
1390                raw.to_path_buf()
1391            } else {
1392                workspace_path.join(raw)
1393            };
1394            is_sensitive_path_candidate(&resolved)
1395        }) {
1396            return Some(format!(
1397                "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
1398            ));
1399        }
1400
1401        let outside = candidate_paths.iter().find(|path| {
1402            let raw = Path::new(path);
1403            let resolved = if raw.is_absolute() {
1404                raw.to_path_buf()
1405            } else {
1406                workspace_path.join(raw)
1407            };
1408            !crate::is_within_workspace_root(&resolved, &workspace_path)
1409        })?;
1410        Some(format!(
1411            "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
1412        ))
1413    }
1414
1415    async fn resolve_tool_execution_context(&self, session_id: &str) -> Option<(String, String)> {
1416        let session = self.storage.get_session(session_id).await?;
1417        let workspace_root = session
1418            .workspace_root
1419            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1420        let effective_cwd = if session.directory.trim().is_empty()
1421            || session.directory.trim() == "."
1422        {
1423            workspace_root.clone()
1424        } else {
1425            crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
1426        };
1427        Some((workspace_root, effective_cwd))
1428    }
1429
1430    async fn workspace_override_active(&self, session_id: &str) -> bool {
1431        let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
1432        let mut overrides = self.workspace_overrides.write().await;
1433        overrides.retain(|_, expires_at| *expires_at > now);
1434        overrides
1435            .get(session_id)
1436            .map(|expires_at| *expires_at > now)
1437            .unwrap_or(false)
1438    }
1439
1440    async fn generate_final_narrative_without_tools(
1441        &self,
1442        session_id: &str,
1443        active_agent: &AgentDefinition,
1444        provider_hint: Option<&str>,
1445        model_id: Option<&str>,
1446        cancel: CancellationToken,
1447        tool_outputs: &[String],
1448    ) -> Option<String> {
1449        if cancel.is_cancelled() {
1450            return None;
1451        }
1452        let mut messages = load_chat_history(self.storage.clone(), session_id).await;
1453        let mut system_parts = vec![tandem_runtime_system_prompt(&self.host_runtime_context)];
1454        if let Some(system) = active_agent.system_prompt.as_ref() {
1455            system_parts.push(system.clone());
1456        }
1457        messages.insert(
1458            0,
1459            ChatMessage {
1460                role: "system".to_string(),
1461                content: system_parts.join("\n\n"),
1462            },
1463        );
1464        messages.push(ChatMessage {
1465            role: "user".to_string(),
1466            content: format!(
1467                "Tool observations:\n{}\n\nProvide a direct final answer now. Do not call tools.",
1468                summarize_tool_outputs(tool_outputs)
1469            ),
1470        });
1471        let stream = self
1472            .providers
1473            .stream_for_provider(provider_hint, model_id, messages, None, cancel.clone())
1474            .await
1475            .ok()?;
1476        tokio::pin!(stream);
1477        let mut completion = String::new();
1478        while let Some(chunk) = stream.next().await {
1479            if cancel.is_cancelled() {
1480                return None;
1481            }
1482            match chunk {
1483                Ok(StreamChunk::TextDelta(delta)) => {
1484                    let delta = strip_model_control_markers(&delta);
1485                    if !delta.trim().is_empty() {
1486                        completion.push_str(&delta);
1487                    }
1488                }
1489                Ok(StreamChunk::Done { .. }) => break,
1490                Ok(_) => {}
1491                Err(_) => return None,
1492            }
1493        }
1494        let completion = truncate_text(&strip_model_control_markers(&completion), 16_000);
1495        if completion.trim().is_empty() {
1496            None
1497        } else {
1498            Some(completion)
1499        }
1500    }
1501}
1502
1503fn resolve_model_route(
1504    request_model: Option<&ModelSpec>,
1505    session_model: Option<&ModelSpec>,
1506) -> Option<(String, String)> {
1507    fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
1508        let provider_id = spec.provider_id.trim();
1509        let model_id = spec.model_id.trim();
1510        if provider_id.is_empty() || model_id.is_empty() {
1511            return None;
1512        }
1513        Some((provider_id.to_string(), model_id.to_string()))
1514    }
1515
1516    request_model
1517        .and_then(normalize)
1518        .or_else(|| session_model.and_then(normalize))
1519}
1520
1521fn strip_model_control_markers(input: &str) -> String {
1522    let mut cleaned = input.to_string();
1523    for marker in ["<|eom|>", "<|eot_id|>", "<|im_end|>", "<|end|>"] {
1524        if cleaned.contains(marker) {
1525            cleaned = cleaned.replace(marker, "");
1526        }
1527    }
1528    cleaned
1529}
1530
1531fn truncate_text(input: &str, max_len: usize) -> String {
1532    if input.len() <= max_len {
1533        return input.to_string();
1534    }
1535    let mut out = input[..max_len].to_string();
1536    out.push_str("...<truncated>");
1537    out
1538}
1539
1540fn provider_error_code(error_text: &str) -> &'static str {
1541    let lower = error_text.to_lowercase();
1542    if lower.contains("invalid_function_parameters")
1543        || lower.contains("array schema missing items")
1544        || lower.contains("tool schema")
1545    {
1546        return "TOOL_SCHEMA_INVALID";
1547    }
1548    if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
1549    {
1550        return "RATE_LIMIT_EXCEEDED";
1551    }
1552    if lower.contains("context length")
1553        || lower.contains("max tokens")
1554        || lower.contains("token limit")
1555    {
1556        return "CONTEXT_LENGTH_EXCEEDED";
1557    }
1558    if lower.contains("unauthorized")
1559        || lower.contains("authentication")
1560        || lower.contains("401")
1561        || lower.contains("403")
1562    {
1563        return "AUTHENTICATION_ERROR";
1564    }
1565    if lower.contains("timeout") || lower.contains("timed out") {
1566        return "TIMEOUT";
1567    }
1568    if lower.contains("server error")
1569        || lower.contains("500")
1570        || lower.contains("502")
1571        || lower.contains("503")
1572        || lower.contains("504")
1573    {
1574        return "PROVIDER_SERVER_ERROR";
1575    }
1576    "PROVIDER_REQUEST_FAILED"
1577}
1578
1579fn normalize_tool_name(name: &str) -> String {
1580    let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
1581    for prefix in [
1582        "default_api:",
1583        "default_api.",
1584        "functions.",
1585        "function.",
1586        "tools.",
1587        "tool.",
1588        "builtin:",
1589        "builtin.",
1590    ] {
1591        if let Some(rest) = normalized.strip_prefix(prefix) {
1592            let trimmed = rest.trim();
1593            if !trimmed.is_empty() {
1594                normalized = trimmed.to_string();
1595                break;
1596            }
1597        }
1598    }
1599    match normalized.as_str() {
1600        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
1601        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
1602        other => other.to_string(),
1603    }
1604}
1605
1606fn mcp_server_from_tool_name(tool_name: &str) -> Option<&str> {
1607    let mut parts = tool_name.split('.');
1608    let prefix = parts.next()?;
1609    if prefix != "mcp" {
1610        return None;
1611    }
1612    parts.next().filter(|server| !server.is_empty())
1613}
1614
1615fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
1616    let Some(obj) = args.as_object() else {
1617        return Vec::new();
1618    };
1619    let keys: &[&str] = match tool {
1620        "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
1621        "glob" => &["pattern"],
1622        "lsp" => &["filePath", "path"],
1623        "bash" => &["cwd"],
1624        "apply_patch" => &[],
1625        _ => &["path", "cwd"],
1626    };
1627    keys.iter()
1628        .filter_map(|key| obj.get(*key))
1629        .filter_map(|value| value.as_str())
1630        .filter(|s| !s.trim().is_empty())
1631        .map(ToString::to_string)
1632        .collect()
1633}
1634
1635fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
1636    let target = normalize_tool_name(tool_name);
1637    match agent.tools.as_ref() {
1638        None => true,
1639        Some(list) => list.iter().any(|t| normalize_tool_name(t) == target),
1640    }
1641}
1642
1643fn enforce_skill_scope(
1644    tool_name: &str,
1645    args: Value,
1646    equipped_skills: Option<&[String]>,
1647) -> Result<Value, String> {
1648    if normalize_tool_name(tool_name) != "skill" {
1649        return Ok(args);
1650    }
1651    let Some(configured) = equipped_skills else {
1652        return Ok(args);
1653    };
1654
1655    let mut allowed = configured
1656        .iter()
1657        .map(|s| s.trim().to_string())
1658        .filter(|s| !s.is_empty())
1659        .collect::<Vec<_>>();
1660    if allowed
1661        .iter()
1662        .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
1663    {
1664        return Ok(args);
1665    }
1666    allowed.sort();
1667    allowed.dedup();
1668    if allowed.is_empty() {
1669        return Err("No skills are equipped for this agent.".to_string());
1670    }
1671
1672    let requested = args
1673        .get("name")
1674        .and_then(|v| v.as_str())
1675        .map(|v| v.trim().to_string())
1676        .unwrap_or_default();
1677    if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
1678        return Err(format!(
1679            "Skill '{}' is not equipped for this agent. Equipped skills: {}",
1680            requested,
1681            allowed.join(", ")
1682        ));
1683    }
1684
1685    let mut out = if let Some(obj) = args.as_object() {
1686        Value::Object(obj.clone())
1687    } else {
1688        json!({})
1689    };
1690    if let Some(obj) = out.as_object_mut() {
1691        obj.insert("allowed_skills".to_string(), json!(allowed));
1692    }
1693    Ok(out)
1694}
1695
1696fn is_read_only_tool(tool_name: &str) -> bool {
1697    matches!(
1698        normalize_tool_name(tool_name).as_str(),
1699        "glob"
1700            | "read"
1701            | "grep"
1702            | "search"
1703            | "codesearch"
1704            | "list"
1705            | "ls"
1706            | "lsp"
1707            | "websearch"
1708            | "webfetch"
1709            | "webfetch_html"
1710    )
1711}
1712
1713fn is_batch_wrapper_tool_name(name: &str) -> bool {
1714    matches!(
1715        normalize_tool_name(name).as_str(),
1716        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1717    )
1718}
1719
1720fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1721    obj.get(key)
1722        .and_then(|v| v.as_str())
1723        .map(str::trim)
1724        .filter(|s| !s.is_empty())
1725}
1726
1727fn nested_non_empty_string_at<'a>(
1728    obj: &'a Map<String, Value>,
1729    parent: &str,
1730    key: &str,
1731) -> Option<&'a str> {
1732    obj.get(parent)
1733        .and_then(|v| v.as_object())
1734        .and_then(|nested| nested.get(key))
1735        .and_then(|v| v.as_str())
1736        .map(str::trim)
1737        .filter(|s| !s.is_empty())
1738}
1739
1740fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1741    let calls = args
1742        .get("tool_calls")
1743        .and_then(|v| v.as_array())
1744        .cloned()
1745        .unwrap_or_default();
1746    calls
1747        .into_iter()
1748        .filter_map(|call| {
1749            let obj = call.as_object()?;
1750            let tool_raw = non_empty_string_at(obj, "tool")
1751                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
1752                .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
1753                .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
1754                .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
1755            let name_raw = non_empty_string_at(obj, "name")
1756                .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
1757                .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
1758                .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
1759                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
1760            let effective = match (tool_raw, name_raw) {
1761                (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1762                (Some(t), _) => t,
1763                (None, Some(n)) => n,
1764                (None, None) => return None,
1765            };
1766            let normalized = normalize_tool_name(effective);
1767            let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1768            Some((normalized, call_args))
1769        })
1770        .collect()
1771}
1772
1773fn is_read_only_batch_call(args: &Value) -> bool {
1774    let calls = extract_batch_calls(args);
1775    !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1776}
1777
1778fn batch_tool_signature(args: &Value) -> Option<String> {
1779    let calls = extract_batch_calls(args);
1780    if calls.is_empty() {
1781        return None;
1782    }
1783    let parts = calls
1784        .into_iter()
1785        .map(|(tool, call_args)| tool_signature(&tool, &call_args))
1786        .collect::<Vec<_>>();
1787    Some(format!("batch:{}", parts.join("|")))
1788}
1789
1790fn is_non_productive_batch_output(output: &str) -> bool {
1791    let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
1792        return false;
1793    };
1794    let Some(items) = value.as_array() else {
1795        return false;
1796    };
1797    if items.is_empty() {
1798        return true;
1799    }
1800    items.iter().all(|item| {
1801        let text = item
1802            .get("output")
1803            .and_then(|v| v.as_str())
1804            .map(str::trim)
1805            .unwrap_or_default()
1806            .to_ascii_lowercase();
1807        text.is_empty()
1808            || text.starts_with("unknown tool:")
1809            || text.contains("call skipped")
1810            || text.contains("guard budget exceeded")
1811    })
1812}
1813
1814fn is_auth_required_tool_output(output: &str) -> bool {
1815    let lower = output.to_ascii_lowercase();
1816    (lower.contains("authorization required")
1817        || lower.contains("requires authorization")
1818        || lower.contains("authorization pending"))
1819        && (lower.contains("authorize here") || lower.contains("http"))
1820}
1821
1822#[derive(Debug, Clone)]
1823struct McpAuthRequiredMetadata {
1824    challenge_id: String,
1825    authorization_url: String,
1826    message: String,
1827    server: Option<String>,
1828    pending: bool,
1829    blocked: bool,
1830    retry_after_ms: Option<u64>,
1831}
1832
1833fn extract_mcp_auth_required_metadata(metadata: &Value) -> Option<McpAuthRequiredMetadata> {
1834    let auth = metadata.get("mcpAuth")?;
1835    if !auth
1836        .get("required")
1837        .and_then(|v| v.as_bool())
1838        .unwrap_or(false)
1839    {
1840        return None;
1841    }
1842    let authorization_url = auth
1843        .get("authorizationUrl")
1844        .and_then(|v| v.as_str())
1845        .map(str::trim)
1846        .filter(|v| !v.is_empty())?
1847        .to_string();
1848    let message = auth
1849        .get("message")
1850        .and_then(|v| v.as_str())
1851        .map(str::trim)
1852        .filter(|v| !v.is_empty())
1853        .unwrap_or("This tool requires authorization before it can run.")
1854        .to_string();
1855    let challenge_id = auth
1856        .get("challengeId")
1857        .and_then(|v| v.as_str())
1858        .map(str::trim)
1859        .filter(|v| !v.is_empty())
1860        .unwrap_or("unknown")
1861        .to_string();
1862    let server = metadata
1863        .get("server")
1864        .and_then(|v| v.as_str())
1865        .map(str::trim)
1866        .filter(|v| !v.is_empty())
1867        .map(ToString::to_string);
1868    let pending = auth
1869        .get("pending")
1870        .and_then(|v| v.as_bool())
1871        .unwrap_or(false);
1872    let blocked = auth
1873        .get("blocked")
1874        .and_then(|v| v.as_bool())
1875        .unwrap_or(false);
1876    let retry_after_ms = auth.get("retryAfterMs").and_then(|v| v.as_u64());
1877    Some(McpAuthRequiredMetadata {
1878        challenge_id,
1879        authorization_url,
1880        message,
1881        server,
1882        pending,
1883        blocked,
1884        retry_after_ms,
1885    })
1886}
1887
1888fn extract_mcp_auth_required_from_error_text(
1889    tool_name: &str,
1890    error_text: &str,
1891) -> Option<McpAuthRequiredMetadata> {
1892    let lower = error_text.to_ascii_lowercase();
1893    let auth_hint = lower.contains("authorization")
1894        || lower.contains("oauth")
1895        || lower.contains("invalid oauth token")
1896        || lower.contains("requires authorization");
1897    if !auth_hint {
1898        return None;
1899    }
1900    let authorization_url = find_first_url(error_text)?;
1901    let challenge_id = stable_hash(&format!("{tool_name}:{authorization_url}"));
1902    let server = tool_name
1903        .strip_prefix("mcp.")
1904        .and_then(|rest| rest.split('.').next())
1905        .filter(|s| !s.is_empty())
1906        .map(ToString::to_string);
1907    Some(McpAuthRequiredMetadata {
1908        challenge_id,
1909        authorization_url,
1910        message: "This integration requires authorization before this action can run.".to_string(),
1911        server,
1912        pending: false,
1913        blocked: false,
1914        retry_after_ms: None,
1915    })
1916}
1917
1918fn summarize_auth_pending_outputs(outputs: &[String]) -> Option<String> {
1919    if outputs.is_empty()
1920        || !outputs
1921            .iter()
1922            .all(|output| is_auth_required_tool_output(output))
1923    {
1924        return None;
1925    }
1926    let mut auth_lines = outputs
1927        .iter()
1928        .filter_map(|output| {
1929            let trimmed = output.trim();
1930            if trimmed.is_empty() {
1931                None
1932            } else {
1933                Some(trimmed.to_string())
1934            }
1935        })
1936        .collect::<Vec<_>>();
1937    auth_lines.sort();
1938    auth_lines.dedup();
1939    if auth_lines.is_empty() {
1940        return None;
1941    }
1942    Some(format!(
1943        "Authorization is required before I can continue with this action.\n\n{}",
1944        auth_lines.join("\n\n")
1945    ))
1946}
1947
1948fn summarize_guard_budget_outputs(outputs: &[String]) -> Option<String> {
1949    if outputs.is_empty()
1950        || !outputs
1951            .iter()
1952            .all(|output| is_guard_budget_tool_output(output))
1953    {
1954        return None;
1955    }
1956    let mut lines = outputs
1957        .iter()
1958        .filter_map(|output| {
1959            let trimmed = output.trim();
1960            if trimmed.is_empty() {
1961                None
1962            } else {
1963                Some(trimmed.to_string())
1964            }
1965        })
1966        .collect::<Vec<_>>();
1967    lines.sort();
1968    lines.dedup();
1969    if lines.is_empty() {
1970        return None;
1971    }
1972    Some(format!(
1973        "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.",
1974        lines.join("\n")
1975    ))
1976}
1977
1978fn find_first_url(text: &str) -> Option<String> {
1979    text.split_whitespace().find_map(|token| {
1980        if token.starts_with("https://") || token.starts_with("http://") {
1981            let cleaned = token.trim_end_matches(&[')', ']', '}', '"', '\'', ',', '.'][..]);
1982            if cleaned.len() > "https://".len() {
1983                return Some(cleaned.to_string());
1984            }
1985        }
1986        None
1987    })
1988}
1989
1990fn tool_budget_for(tool_name: &str) -> usize {
1991    match normalize_tool_name(tool_name).as_str() {
1992        "glob" => 4,
1993        "read" => 8,
1994        "websearch" => 3,
1995        "batch" => 4,
1996        "grep" | "search" | "codesearch" => 6,
1997        _ => 10,
1998    }
1999}
2000
2001fn is_guard_budget_tool_output(output: &str) -> bool {
2002    output
2003        .to_ascii_lowercase()
2004        .contains("per-run guard budget exceeded")
2005}
2006
2007fn is_sensitive_path_candidate(path: &Path) -> bool {
2008    let lowered = path.to_string_lossy().to_ascii_lowercase();
2009    if lowered.contains("/.ssh/")
2010        || lowered.ends_with("/.ssh")
2011        || lowered.contains("/.gnupg/")
2012        || lowered.ends_with("/.gnupg")
2013    {
2014        return true;
2015    }
2016    if lowered.contains("/.aws/credentials")
2017        || lowered.ends_with("/.npmrc")
2018        || lowered.ends_with("/.netrc")
2019        || lowered.ends_with("/.pypirc")
2020    {
2021        return true;
2022    }
2023    if lowered.contains("id_rsa")
2024        || lowered.contains("id_ed25519")
2025        || lowered.contains("id_ecdsa")
2026        || lowered.contains(".pem")
2027        || lowered.contains(".p12")
2028        || lowered.contains(".pfx")
2029        || lowered.contains(".key")
2030    {
2031        return true;
2032    }
2033    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
2034        let n = name.to_ascii_lowercase();
2035        if n == ".env" || n.starts_with(".env.") {
2036            return true;
2037        }
2038    }
2039    false
2040}
2041
2042fn shell_command_targets_sensitive_path(command: &str) -> bool {
2043    let lower = command.to_ascii_lowercase();
2044    let patterns = [
2045        ".env",
2046        ".ssh",
2047        ".gnupg",
2048        ".aws/credentials",
2049        "id_rsa",
2050        "id_ed25519",
2051        ".pem",
2052        ".p12",
2053        ".pfx",
2054        ".key",
2055    ];
2056    patterns.iter().any(|p| lower.contains(p))
2057}
2058
2059#[derive(Debug, Clone)]
2060struct NormalizedToolArgs {
2061    args: Value,
2062    args_source: String,
2063    args_integrity: String,
2064    query: Option<String>,
2065    missing_terminal: bool,
2066    missing_terminal_reason: Option<String>,
2067}
2068
2069fn normalize_tool_args(
2070    tool_name: &str,
2071    raw_args: Value,
2072    latest_user_text: &str,
2073    latest_assistant_context: &str,
2074) -> NormalizedToolArgs {
2075    let normalized_tool = normalize_tool_name(tool_name);
2076    let mut args = raw_args;
2077    let mut args_source = if args.is_string() {
2078        "provider_string".to_string()
2079    } else {
2080        "provider_json".to_string()
2081    };
2082    let mut args_integrity = "ok".to_string();
2083    let mut query = None;
2084    let mut missing_terminal = false;
2085    let mut missing_terminal_reason = None;
2086
2087    if normalized_tool == "websearch" {
2088        if let Some(found) = extract_websearch_query(&args) {
2089            query = Some(found);
2090            args = set_websearch_query_and_source(args, query.clone(), "tool_args");
2091        } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
2092            args_source = "inferred_from_user".to_string();
2093            args_integrity = "recovered".to_string();
2094            query = Some(inferred);
2095            args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
2096        } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
2097            args_source = "recovered_from_context".to_string();
2098            args_integrity = "recovered".to_string();
2099            query = Some(recovered);
2100            args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
2101        } else {
2102            args_source = "missing".to_string();
2103            args_integrity = "empty".to_string();
2104            missing_terminal = true;
2105            missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
2106        }
2107    } else if is_shell_tool_name(&normalized_tool) {
2108        if let Some(command) = extract_shell_command(&args) {
2109            args = set_shell_command(args, command);
2110        } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
2111            args_source = "inferred_from_context".to_string();
2112            args_integrity = "recovered".to_string();
2113            args = set_shell_command(args, inferred);
2114        } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
2115            args_source = "inferred_from_user".to_string();
2116            args_integrity = "recovered".to_string();
2117            args = set_shell_command(args, inferred);
2118        } else {
2119            args_source = "missing".to_string();
2120            args_integrity = "empty".to_string();
2121            missing_terminal = true;
2122            missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
2123        }
2124    } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
2125        if let Some(path) = extract_file_path_arg(&args) {
2126            args = set_file_path_arg(args, path);
2127        } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
2128            args_source = "inferred_from_user".to_string();
2129            args_integrity = "recovered".to_string();
2130            args = set_file_path_arg(args, inferred);
2131        } else {
2132            args_source = "missing".to_string();
2133            args_integrity = "empty".to_string();
2134            missing_terminal = true;
2135            missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
2136        }
2137
2138        if !missing_terminal && normalized_tool == "write" {
2139            if let Some(content) = extract_write_content_arg(&args) {
2140                args = set_write_content_arg(args, content);
2141            } else {
2142                args_source = "missing".to_string();
2143                args_integrity = "empty".to_string();
2144                missing_terminal = true;
2145                missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
2146            }
2147        }
2148    } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
2149        if let Some(url) = extract_webfetch_url_arg(&args) {
2150            args = set_webfetch_url_arg(args, url);
2151        } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
2152            args_source = "inferred_from_context".to_string();
2153            args_integrity = "recovered".to_string();
2154            args = set_webfetch_url_arg(args, inferred);
2155        } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
2156            args_source = "inferred_from_user".to_string();
2157            args_integrity = "recovered".to_string();
2158            args = set_webfetch_url_arg(args, inferred);
2159        } else {
2160            args_source = "missing".to_string();
2161            args_integrity = "empty".to_string();
2162            missing_terminal = true;
2163            missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
2164        }
2165    }
2166
2167    NormalizedToolArgs {
2168        args,
2169        args_source,
2170        args_integrity,
2171        query,
2172        missing_terminal,
2173        missing_terminal_reason,
2174    }
2175}
2176
2177fn is_shell_tool_name(tool_name: &str) -> bool {
2178    matches!(
2179        tool_name.trim().to_ascii_lowercase().as_str(),
2180        "bash" | "shell" | "powershell" | "cmd"
2181    )
2182}
2183
2184fn set_file_path_arg(args: Value, path: String) -> Value {
2185    let mut obj = args.as_object().cloned().unwrap_or_default();
2186    obj.insert("path".to_string(), Value::String(path));
2187    Value::Object(obj)
2188}
2189
2190fn set_write_content_arg(args: Value, content: String) -> Value {
2191    let mut obj = args.as_object().cloned().unwrap_or_default();
2192    obj.insert("content".to_string(), Value::String(content));
2193    Value::Object(obj)
2194}
2195
2196fn extract_file_path_arg(args: &Value) -> Option<String> {
2197    extract_file_path_arg_internal(args, 0)
2198}
2199
2200fn extract_write_content_arg(args: &Value) -> Option<String> {
2201    extract_write_content_arg_internal(args, 0)
2202}
2203
2204fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
2205    if depth > 5 {
2206        return None;
2207    }
2208
2209    match args {
2210        Value::String(raw) => {
2211            let trimmed = raw.trim();
2212            if trimmed.is_empty() {
2213                return None;
2214            }
2215            // If the provider sent plain string args, treat it as a path directly.
2216            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
2217                return sanitize_path_candidate(trimmed);
2218            }
2219            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2220                return extract_file_path_arg_internal(&parsed, depth + 1);
2221            }
2222            sanitize_path_candidate(trimmed)
2223        }
2224        Value::Array(items) => items
2225            .iter()
2226            .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
2227        Value::Object(obj) => {
2228            for key in FILE_PATH_KEYS {
2229                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
2230                    if let Some(path) = sanitize_path_candidate(raw) {
2231                        return Some(path);
2232                    }
2233                }
2234            }
2235            for container in NESTED_ARGS_KEYS {
2236                if let Some(nested) = obj.get(container) {
2237                    if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
2238                        return Some(path);
2239                    }
2240                }
2241            }
2242            None
2243        }
2244        _ => None,
2245    }
2246}
2247
2248fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
2249    if depth > 5 {
2250        return None;
2251    }
2252
2253    match args {
2254        Value::String(raw) => {
2255            let trimmed = raw.trim();
2256            if trimmed.is_empty() {
2257                return None;
2258            }
2259            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2260                return extract_write_content_arg_internal(&parsed, depth + 1);
2261            }
2262            // Some providers collapse args to a plain string. Recover as content only when
2263            // it does not look like a standalone file path token.
2264            if sanitize_path_candidate(trimmed).is_some()
2265                && !trimmed.contains('\n')
2266                && trimmed.split_whitespace().count() <= 3
2267            {
2268                return None;
2269            }
2270            Some(trimmed.to_string())
2271        }
2272        Value::Array(items) => items
2273            .iter()
2274            .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
2275        Value::Object(obj) => {
2276            for key in WRITE_CONTENT_KEYS {
2277                if let Some(value) = obj.get(key) {
2278                    if let Some(raw) = value.as_str() {
2279                        if !raw.is_empty() {
2280                            return Some(raw.to_string());
2281                        }
2282                    } else if let Some(recovered) =
2283                        extract_write_content_arg_internal(value, depth + 1)
2284                    {
2285                        return Some(recovered);
2286                    }
2287                }
2288            }
2289            for container in NESTED_ARGS_KEYS {
2290                if let Some(nested) = obj.get(container) {
2291                    if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
2292                        return Some(content);
2293                    }
2294                }
2295            }
2296            None
2297        }
2298        _ => None,
2299    }
2300}
2301
2302fn set_shell_command(args: Value, command: String) -> Value {
2303    let mut obj = args.as_object().cloned().unwrap_or_default();
2304    obj.insert("command".to_string(), Value::String(command));
2305    Value::Object(obj)
2306}
2307
2308fn extract_shell_command(args: &Value) -> Option<String> {
2309    extract_shell_command_internal(args, 0)
2310}
2311
2312fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
2313    if depth > 5 {
2314        return None;
2315    }
2316
2317    match args {
2318        Value::String(raw) => {
2319            let trimmed = raw.trim();
2320            if trimmed.is_empty() {
2321                return None;
2322            }
2323            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
2324                return sanitize_shell_command_candidate(trimmed);
2325            }
2326            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2327                return extract_shell_command_internal(&parsed, depth + 1);
2328            }
2329            sanitize_shell_command_candidate(trimmed)
2330        }
2331        Value::Array(items) => items
2332            .iter()
2333            .find_map(|item| extract_shell_command_internal(item, depth + 1)),
2334        Value::Object(obj) => {
2335            for key in SHELL_COMMAND_KEYS {
2336                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
2337                    if let Some(command) = sanitize_shell_command_candidate(raw) {
2338                        return Some(command);
2339                    }
2340                }
2341            }
2342            for container in NESTED_ARGS_KEYS {
2343                if let Some(nested) = obj.get(container) {
2344                    if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
2345                        return Some(command);
2346                    }
2347                }
2348            }
2349            None
2350        }
2351        _ => None,
2352    }
2353}
2354
2355fn infer_shell_command_from_text(text: &str) -> Option<String> {
2356    let trimmed = text.trim();
2357    if trimmed.is_empty() {
2358        return None;
2359    }
2360
2361    // Prefer explicit backtick commands first.
2362    let mut in_tick = false;
2363    let mut tick_buf = String::new();
2364    for ch in trimmed.chars() {
2365        if ch == '`' {
2366            if in_tick {
2367                if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
2368                    if looks_like_shell_command(&candidate) {
2369                        return Some(candidate);
2370                    }
2371                }
2372                tick_buf.clear();
2373            }
2374            in_tick = !in_tick;
2375            continue;
2376        }
2377        if in_tick {
2378            tick_buf.push(ch);
2379        }
2380    }
2381
2382    for line in trimmed.lines() {
2383        let line = line.trim();
2384        if line.is_empty() {
2385            continue;
2386        }
2387        let lower = line.to_ascii_lowercase();
2388        for prefix in [
2389            "run ",
2390            "execute ",
2391            "call ",
2392            "use bash ",
2393            "use shell ",
2394            "bash ",
2395            "shell ",
2396            "powershell ",
2397            "pwsh ",
2398        ] {
2399            if lower.starts_with(prefix) {
2400                let candidate = line[prefix.len()..].trim();
2401                if let Some(command) = sanitize_shell_command_candidate(candidate) {
2402                    if looks_like_shell_command(&command) {
2403                        return Some(command);
2404                    }
2405                }
2406            }
2407        }
2408    }
2409
2410    None
2411}
2412
2413fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
2414    let mut obj = args.as_object().cloned().unwrap_or_default();
2415    if let Some(q) = query {
2416        obj.insert("query".to_string(), Value::String(q));
2417    }
2418    obj.insert(
2419        "__query_source".to_string(),
2420        Value::String(query_source.to_string()),
2421    );
2422    Value::Object(obj)
2423}
2424
2425fn set_webfetch_url_arg(args: Value, url: String) -> Value {
2426    let mut obj = args.as_object().cloned().unwrap_or_default();
2427    obj.insert("url".to_string(), Value::String(url));
2428    Value::Object(obj)
2429}
2430
2431fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
2432    const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
2433    for key in URL_KEYS {
2434        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2435            if let Some(url) = sanitize_url_candidate(value) {
2436                return Some(url);
2437            }
2438        }
2439    }
2440    for container in ["arguments", "args", "input", "params"] {
2441        if let Some(obj) = args.get(container) {
2442            for key in URL_KEYS {
2443                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2444                    if let Some(url) = sanitize_url_candidate(value) {
2445                        return Some(url);
2446                    }
2447                }
2448            }
2449        }
2450    }
2451    args.as_str().and_then(sanitize_url_candidate)
2452}
2453
2454fn extract_websearch_query(args: &Value) -> Option<String> {
2455    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2456    for key in QUERY_KEYS {
2457        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2458            if let Some(query) = sanitize_websearch_query_candidate(value) {
2459                return Some(query);
2460            }
2461        }
2462    }
2463    for container in ["arguments", "args", "input", "params"] {
2464        if let Some(obj) = args.get(container) {
2465            for key in QUERY_KEYS {
2466                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2467                    if let Some(query) = sanitize_websearch_query_candidate(value) {
2468                        return Some(query);
2469                    }
2470                }
2471            }
2472        }
2473    }
2474    args.as_str().and_then(sanitize_websearch_query_candidate)
2475}
2476
2477fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
2478    let trimmed = raw.trim();
2479    if trimmed.is_empty() {
2480        return None;
2481    }
2482
2483    let lower = trimmed.to_ascii_lowercase();
2484    if let Some(start) = lower.find("<arg_value>") {
2485        let value_start = start + "<arg_value>".len();
2486        let tail = &trimmed[value_start..];
2487        let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
2488            &tail[..end]
2489        } else {
2490            tail
2491        };
2492        let cleaned = value.trim();
2493        if !cleaned.is_empty() {
2494            return Some(cleaned.to_string());
2495        }
2496    }
2497
2498    let without_wrappers = trimmed
2499        .replace("<arg_key>", " ")
2500        .replace("</arg_key>", " ")
2501        .replace("<arg_value>", " ")
2502        .replace("</arg_value>", " ");
2503    let collapsed = without_wrappers
2504        .split_whitespace()
2505        .collect::<Vec<_>>()
2506        .join(" ");
2507    if collapsed.is_empty() {
2508        return None;
2509    }
2510
2511    let collapsed_lower = collapsed.to_ascii_lowercase();
2512    if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
2513        let offset = collapsed.len() - rest.len();
2514        let q = collapsed[offset..].trim();
2515        if !q.is_empty() {
2516            return Some(q.to_string());
2517        }
2518    }
2519    if let Some(rest) = collapsed_lower.strip_prefix("query ") {
2520        let offset = collapsed.len() - rest.len();
2521        let q = collapsed[offset..].trim();
2522        if !q.is_empty() {
2523            return Some(q.to_string());
2524        }
2525    }
2526
2527    Some(collapsed)
2528}
2529
2530fn infer_websearch_query_from_text(text: &str) -> Option<String> {
2531    let trimmed = text.trim();
2532    if trimmed.is_empty() {
2533        return None;
2534    }
2535
2536    let lower = trimmed.to_lowercase();
2537    const PREFIXES: [&str; 11] = [
2538        "web search",
2539        "websearch",
2540        "search web for",
2541        "search web",
2542        "search for",
2543        "search",
2544        "look up",
2545        "lookup",
2546        "find",
2547        "web lookup",
2548        "query",
2549    ];
2550
2551    let mut candidate = trimmed;
2552    for prefix in PREFIXES {
2553        if lower.starts_with(prefix) && lower.len() >= prefix.len() {
2554            let remainder = trimmed[prefix.len()..]
2555                .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
2556            candidate = remainder;
2557            break;
2558        }
2559    }
2560
2561    let normalized = candidate
2562        .trim()
2563        .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
2564        .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
2565        .trim()
2566        .to_string();
2567
2568    if normalized.split_whitespace().count() < 2 {
2569        return None;
2570    }
2571    Some(normalized)
2572}
2573
2574fn infer_file_path_from_text(text: &str) -> Option<String> {
2575    let trimmed = text.trim();
2576    if trimmed.is_empty() {
2577        return None;
2578    }
2579
2580    let mut candidates: Vec<String> = Vec::new();
2581
2582    // Prefer backtick-delimited paths when available.
2583    let mut in_tick = false;
2584    let mut tick_buf = String::new();
2585    for ch in trimmed.chars() {
2586        if ch == '`' {
2587            if in_tick {
2588                let cand = sanitize_path_candidate(&tick_buf);
2589                if let Some(path) = cand {
2590                    candidates.push(path);
2591                }
2592                tick_buf.clear();
2593            }
2594            in_tick = !in_tick;
2595            continue;
2596        }
2597        if in_tick {
2598            tick_buf.push(ch);
2599        }
2600    }
2601
2602    // Fallback: scan whitespace tokens.
2603    for raw in trimmed.split_whitespace() {
2604        if let Some(path) = sanitize_path_candidate(raw) {
2605            candidates.push(path);
2606        }
2607    }
2608
2609    let mut deduped = Vec::new();
2610    let mut seen = HashSet::new();
2611    for candidate in candidates {
2612        if seen.insert(candidate.clone()) {
2613            deduped.push(candidate);
2614        }
2615    }
2616
2617    deduped.into_iter().next()
2618}
2619
2620fn infer_url_from_text(text: &str) -> Option<String> {
2621    let trimmed = text.trim();
2622    if trimmed.is_empty() {
2623        return None;
2624    }
2625
2626    let mut candidates: Vec<String> = Vec::new();
2627
2628    // Prefer backtick-delimited URLs when available.
2629    let mut in_tick = false;
2630    let mut tick_buf = String::new();
2631    for ch in trimmed.chars() {
2632        if ch == '`' {
2633            if in_tick {
2634                if let Some(url) = sanitize_url_candidate(&tick_buf) {
2635                    candidates.push(url);
2636                }
2637                tick_buf.clear();
2638            }
2639            in_tick = !in_tick;
2640            continue;
2641        }
2642        if in_tick {
2643            tick_buf.push(ch);
2644        }
2645    }
2646
2647    // Fallback: scan whitespace tokens.
2648    for raw in trimmed.split_whitespace() {
2649        if let Some(url) = sanitize_url_candidate(raw) {
2650            candidates.push(url);
2651        }
2652    }
2653
2654    let mut seen = HashSet::new();
2655    candidates
2656        .into_iter()
2657        .find(|candidate| seen.insert(candidate.clone()))
2658}
2659
2660fn sanitize_url_candidate(raw: &str) -> Option<String> {
2661    let token = raw
2662        .trim()
2663        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2664        .trim_start_matches(['(', '[', '{', '<'])
2665        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2666        .trim_end_matches('.')
2667        .trim();
2668
2669    if token.is_empty() {
2670        return None;
2671    }
2672    let lower = token.to_ascii_lowercase();
2673    if !(lower.starts_with("http://") || lower.starts_with("https://")) {
2674        return None;
2675    }
2676    Some(token.to_string())
2677}
2678
2679fn sanitize_path_candidate(raw: &str) -> Option<String> {
2680    let token = raw
2681        .trim()
2682        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2683        .trim_start_matches(['(', '[', '{', '<'])
2684        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2685        .trim_end_matches('.')
2686        .trim();
2687
2688    if token.is_empty() {
2689        return None;
2690    }
2691    let lower = token.to_ascii_lowercase();
2692    if lower.starts_with("http://") || lower.starts_with("https://") {
2693        return None;
2694    }
2695    if is_malformed_tool_path_token(token) {
2696        return None;
2697    }
2698    if is_root_only_path_token(token) {
2699        return None;
2700    }
2701    if is_placeholder_path_token(token) {
2702        return None;
2703    }
2704
2705    let looks_like_path = token.contains('/') || token.contains('\\');
2706    let has_file_ext = [
2707        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
2708        ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
2709    ]
2710    .iter()
2711    .any(|ext| lower.ends_with(ext));
2712
2713    if !looks_like_path && !has_file_ext {
2714        return None;
2715    }
2716
2717    Some(token.to_string())
2718}
2719
2720fn is_placeholder_path_token(token: &str) -> bool {
2721    let lowered = token.trim().to_ascii_lowercase();
2722    if lowered.is_empty() {
2723        return true;
2724    }
2725    matches!(
2726        lowered.as_str(),
2727        "files/directories"
2728            | "file/directory"
2729            | "relative/or/absolute/path"
2730            | "path/to/file"
2731            | "path/to/your/file"
2732            | "tool/policy"
2733            | "tools/policy"
2734            | "the expected artifact file"
2735            | "workspace/file"
2736    )
2737}
2738
2739fn is_malformed_tool_path_token(token: &str) -> bool {
2740    let lower = token.to_ascii_lowercase();
2741    // XML-ish tool-call wrappers emitted by some model responses.
2742    if lower.contains("<tool_call")
2743        || lower.contains("</tool_call")
2744        || lower.contains("<function=")
2745        || lower.contains("<parameter=")
2746        || lower.contains("</function>")
2747        || lower.contains("</parameter>")
2748    {
2749        return true;
2750    }
2751    // Multiline payloads are not valid single file paths.
2752    if token.contains('\n') || token.contains('\r') {
2753        return true;
2754    }
2755    // Glob patterns are not concrete file paths for read/write/edit.
2756    if token.contains('*') || token.contains('?') {
2757        return true;
2758    }
2759    false
2760}
2761
2762fn is_root_only_path_token(token: &str) -> bool {
2763    let trimmed = token.trim();
2764    if trimmed.is_empty() {
2765        return true;
2766    }
2767    if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
2768        return true;
2769    }
2770    // Windows drive root placeholders, e.g. `C:` or `C:\`.
2771    let bytes = trimmed.as_bytes();
2772    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
2773        return true;
2774    }
2775    if bytes.len() == 3
2776        && bytes[1] == b':'
2777        && (bytes[0] as char).is_ascii_alphabetic()
2778        && (bytes[2] == b'\\' || bytes[2] == b'/')
2779    {
2780        return true;
2781    }
2782    false
2783}
2784
2785fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
2786    let token = raw
2787        .trim()
2788        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
2789        .trim();
2790    if token.is_empty() {
2791        return None;
2792    }
2793    Some(token.to_string())
2794}
2795
2796fn looks_like_shell_command(candidate: &str) -> bool {
2797    let lower = candidate.to_ascii_lowercase();
2798    if lower.is_empty() {
2799        return false;
2800    }
2801    let first = lower.split_whitespace().next().unwrap_or_default();
2802    let common = [
2803        "rg",
2804        "git",
2805        "cargo",
2806        "pnpm",
2807        "npm",
2808        "node",
2809        "python",
2810        "pytest",
2811        "pwsh",
2812        "powershell",
2813        "cmd",
2814        "dir",
2815        "ls",
2816        "cat",
2817        "type",
2818        "echo",
2819        "cd",
2820        "mkdir",
2821        "cp",
2822        "copy",
2823        "move",
2824        "del",
2825        "rm",
2826    ];
2827    common.contains(&first)
2828        || first.starts_with("get-")
2829        || first.starts_with("./")
2830        || first.starts_with(".\\")
2831        || lower.contains(" | ")
2832        || lower.contains(" && ")
2833        || lower.contains(" ; ")
2834}
2835
2836const FILE_PATH_KEYS: [&str; 10] = [
2837    "path",
2838    "file_path",
2839    "filePath",
2840    "filepath",
2841    "filename",
2842    "file",
2843    "target",
2844    "targetFile",
2845    "absolutePath",
2846    "uri",
2847];
2848
2849const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
2850
2851const WRITE_CONTENT_KEYS: [&str; 8] = [
2852    "content",
2853    "text",
2854    "body",
2855    "value",
2856    "markdown",
2857    "document",
2858    "output",
2859    "file_content",
2860];
2861
2862const NESTED_ARGS_KEYS: [&str; 10] = [
2863    "arguments",
2864    "args",
2865    "input",
2866    "params",
2867    "payload",
2868    "data",
2869    "tool_input",
2870    "toolInput",
2871    "tool_args",
2872    "toolArgs",
2873];
2874
2875fn tool_signature(tool_name: &str, args: &Value) -> String {
2876    let normalized = normalize_tool_name(tool_name);
2877    if normalized == "websearch" {
2878        let query = extract_websearch_query(args)
2879            .unwrap_or_default()
2880            .to_lowercase();
2881        let limit = args
2882            .get("limit")
2883            .or_else(|| args.get("numResults"))
2884            .or_else(|| args.get("num_results"))
2885            .and_then(|v| v.as_u64())
2886            .unwrap_or(8);
2887        let domains = args
2888            .get("domains")
2889            .or_else(|| args.get("domain"))
2890            .map(|v| v.to_string())
2891            .unwrap_or_default();
2892        let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
2893        return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
2894    }
2895    format!("{}:{}", normalized, args)
2896}
2897
2898fn stable_hash(input: &str) -> String {
2899    let mut hasher = DefaultHasher::new();
2900    input.hash(&mut hasher);
2901    format!("{:016x}", hasher.finish())
2902}
2903
2904fn summarize_tool_outputs(outputs: &[String]) -> String {
2905    outputs
2906        .iter()
2907        .take(6)
2908        .map(|output| truncate_text(output, 600))
2909        .collect::<Vec<_>>()
2910        .join("\n\n")
2911}
2912
2913fn is_os_mismatch_tool_output(output: &str) -> bool {
2914    let lower = output.to_ascii_lowercase();
2915    lower.contains("os error 3")
2916        || lower.contains("system cannot find the path specified")
2917        || lower.contains("command not found")
2918        || lower.contains("is not recognized as an internal or external command")
2919        || lower.contains("shell command blocked on windows")
2920}
2921
2922fn tandem_runtime_system_prompt(host: &HostRuntimeContext) -> String {
2923    let mut sections = Vec::new();
2924    if os_aware_prompts_enabled() {
2925        sections.push(format!(
2926            "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
2927            host_os_label(host.os),
2928            shell_family_label(host.shell_family),
2929            path_style_label(host.path_style),
2930            host.arch
2931        ));
2932    }
2933    sections.push(
2934        "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
2935Use tool calls to inspect and modify the workspace when needed instead of asking the user
2936to manually run basic discovery steps. Permission prompts may occur for some tools; if
2937a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
2938            .to_string(),
2939    );
2940    if host.os == HostOs::Windows {
2941        sections.push(
2942            "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
2943Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
2944If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
2945                .to_string(),
2946        );
2947    } else {
2948        sections.push(
2949            "POSIX guidance: standard shell commands are available.
2950Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
2951                .to_string(),
2952        );
2953    }
2954    sections.join("\n\n")
2955}
2956
2957fn os_aware_prompts_enabled() -> bool {
2958    std::env::var("TANDEM_OS_AWARE_PROMPTS")
2959        .ok()
2960        .map(|v| {
2961            let normalized = v.trim().to_ascii_lowercase();
2962            !(normalized == "0" || normalized == "false" || normalized == "off")
2963        })
2964        .unwrap_or(true)
2965}
2966
2967fn host_os_label(os: HostOs) -> &'static str {
2968    match os {
2969        HostOs::Windows => "windows",
2970        HostOs::Linux => "linux",
2971        HostOs::Macos => "macos",
2972    }
2973}
2974
2975fn shell_family_label(shell: ShellFamily) -> &'static str {
2976    match shell {
2977        ShellFamily::Powershell => "powershell",
2978        ShellFamily::Posix => "posix",
2979    }
2980}
2981
2982fn path_style_label(path_style: PathStyle) -> &'static str {
2983    match path_style {
2984        PathStyle::Windows => "windows",
2985        PathStyle::Posix => "posix",
2986    }
2987}
2988
2989fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
2990    let user = user_text.to_lowercase();
2991    let reply = completion.to_lowercase();
2992
2993    let asked_for_project_context = [
2994        "what is this project",
2995        "what's this project",
2996        "what project is this",
2997        "explain this project",
2998        "analyze this project",
2999        "inspect this project",
3000        "look at the project",
3001        "summarize this project",
3002        "show me this project",
3003        "what files are in",
3004        "show files",
3005        "list files",
3006        "read files",
3007        "browse files",
3008        "use glob",
3009        "run glob",
3010    ]
3011    .iter()
3012    .any(|needle| user.contains(needle));
3013
3014    if !asked_for_project_context {
3015        return false;
3016    }
3017
3018    let assistant_claimed_no_access = [
3019        "can't inspect",
3020        "cannot inspect",
3021        "unable to inspect",
3022        "unable to directly inspect",
3023        "can't access",
3024        "cannot access",
3025        "unable to access",
3026        "can't read files",
3027        "cannot read files",
3028        "unable to read files",
3029        "tool restriction",
3030        "tool restrictions",
3031        "don't have visibility",
3032        "no visibility",
3033        "haven't been able to inspect",
3034        "i don't know what this project is",
3035        "need your help to",
3036        "sandbox",
3037        "restriction",
3038        "system restriction",
3039        "permissions restrictions",
3040    ]
3041    .iter()
3042    .any(|needle| reply.contains(needle));
3043
3044    // If the user is explicitly asking for project inspection and the model replies with
3045    // a no-access narrative instead of making a tool call, force a minimal read-only probe.
3046    asked_for_project_context && assistant_claimed_no_access
3047}
3048
3049fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
3050    let raw = input.trim();
3051    if !raw.starts_with("/tool ") {
3052        return None;
3053    }
3054    let rest = raw.trim_start_matches("/tool ").trim();
3055    let mut split = rest.splitn(2, ' ');
3056    let tool = normalize_tool_name(split.next()?.trim());
3057    let args = split
3058        .next()
3059        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
3060        .unwrap_or_else(|| json!({}));
3061    Some((tool, args))
3062}
3063
3064fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
3065    let trimmed = input.trim();
3066    if trimmed.is_empty() {
3067        return Vec::new();
3068    }
3069
3070    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
3071        if let Some(found) = extract_tool_call_from_value(&parsed) {
3072            return vec![found];
3073        }
3074    }
3075
3076    if let Some(block) = extract_first_json_object(trimmed) {
3077        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
3078            if let Some(found) = extract_tool_call_from_value(&parsed) {
3079                return vec![found];
3080            }
3081        }
3082    }
3083
3084    parse_function_style_tool_calls(trimmed)
3085}
3086
3087#[cfg(test)]
3088fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
3089    parse_tool_invocations_from_response(input)
3090        .into_iter()
3091        .next()
3092}
3093
3094fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
3095    let mut calls = Vec::new();
3096    let lower = input.to_lowercase();
3097    let names = [
3098        "todo_write",
3099        "todowrite",
3100        "update_todo_list",
3101        "update_todos",
3102    ];
3103    let mut cursor = 0usize;
3104
3105    while cursor < lower.len() {
3106        let mut best: Option<(usize, &str)> = None;
3107        for name in names {
3108            let needle = format!("{name}(");
3109            if let Some(rel_idx) = lower[cursor..].find(&needle) {
3110                let idx = cursor + rel_idx;
3111                if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
3112                    best = Some((idx, name));
3113                }
3114            }
3115        }
3116
3117        let Some((tool_start, tool_name)) = best else {
3118            break;
3119        };
3120
3121        let open_paren = tool_start + tool_name.len();
3122        if let Some(close_paren) = find_matching_paren(input, open_paren) {
3123            if let Some(args_text) = input.get(open_paren + 1..close_paren) {
3124                let args = parse_function_style_args(args_text.trim());
3125                calls.push((normalize_tool_name(tool_name), Value::Object(args)));
3126            }
3127            cursor = close_paren.saturating_add(1);
3128        } else {
3129            cursor = tool_start.saturating_add(tool_name.len());
3130        }
3131    }
3132
3133    calls
3134}
3135
3136fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
3137    if input.as_bytes().get(open_paren).copied()? != b'(' {
3138        return None;
3139    }
3140
3141    let mut depth = 0usize;
3142    let mut in_single = false;
3143    let mut in_double = false;
3144    let mut escaped = false;
3145
3146    for (offset, ch) in input.get(open_paren..)?.char_indices() {
3147        if escaped {
3148            escaped = false;
3149            continue;
3150        }
3151        if ch == '\\' && (in_single || in_double) {
3152            escaped = true;
3153            continue;
3154        }
3155        if ch == '\'' && !in_double {
3156            in_single = !in_single;
3157            continue;
3158        }
3159        if ch == '"' && !in_single {
3160            in_double = !in_double;
3161            continue;
3162        }
3163        if in_single || in_double {
3164            continue;
3165        }
3166
3167        match ch {
3168            '(' => depth += 1,
3169            ')' => {
3170                depth = depth.saturating_sub(1);
3171                if depth == 0 {
3172                    return Some(open_paren + offset);
3173                }
3174            }
3175            _ => {}
3176        }
3177    }
3178
3179    None
3180}
3181
3182fn parse_function_style_args(input: &str) -> Map<String, Value> {
3183    let mut args = Map::new();
3184    if input.trim().is_empty() {
3185        return args;
3186    }
3187
3188    let mut parts = Vec::<String>::new();
3189    let mut current = String::new();
3190    let mut in_single = false;
3191    let mut in_double = false;
3192    let mut escaped = false;
3193    let mut depth_paren = 0usize;
3194    let mut depth_bracket = 0usize;
3195    let mut depth_brace = 0usize;
3196
3197    for ch in input.chars() {
3198        if escaped {
3199            current.push(ch);
3200            escaped = false;
3201            continue;
3202        }
3203        if ch == '\\' && (in_single || in_double) {
3204            current.push(ch);
3205            escaped = true;
3206            continue;
3207        }
3208        if ch == '\'' && !in_double {
3209            in_single = !in_single;
3210            current.push(ch);
3211            continue;
3212        }
3213        if ch == '"' && !in_single {
3214            in_double = !in_double;
3215            current.push(ch);
3216            continue;
3217        }
3218        if in_single || in_double {
3219            current.push(ch);
3220            continue;
3221        }
3222
3223        match ch {
3224            '(' => depth_paren += 1,
3225            ')' => depth_paren = depth_paren.saturating_sub(1),
3226            '[' => depth_bracket += 1,
3227            ']' => depth_bracket = depth_bracket.saturating_sub(1),
3228            '{' => depth_brace += 1,
3229            '}' => depth_brace = depth_brace.saturating_sub(1),
3230            ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
3231                let part = current.trim();
3232                if !part.is_empty() {
3233                    parts.push(part.to_string());
3234                }
3235                current.clear();
3236                continue;
3237            }
3238            _ => {}
3239        }
3240        current.push(ch);
3241    }
3242    let tail = current.trim();
3243    if !tail.is_empty() {
3244        parts.push(tail.to_string());
3245    }
3246
3247    for part in parts {
3248        let Some((raw_key, raw_value)) = part
3249            .split_once('=')
3250            .or_else(|| part.split_once(':'))
3251            .map(|(k, v)| (k.trim(), v.trim()))
3252        else {
3253            continue;
3254        };
3255        let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
3256        if key.is_empty() {
3257            continue;
3258        }
3259        let value = parse_scalar_like_value(raw_value);
3260        args.insert(key.to_string(), value);
3261    }
3262
3263    args
3264}
3265
3266fn parse_scalar_like_value(raw: &str) -> Value {
3267    let trimmed = raw.trim();
3268    if trimmed.is_empty() {
3269        return Value::Null;
3270    }
3271
3272    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
3273        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
3274    {
3275        return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
3276    }
3277
3278    if trimmed.eq_ignore_ascii_case("true") {
3279        return Value::Bool(true);
3280    }
3281    if trimmed.eq_ignore_ascii_case("false") {
3282        return Value::Bool(false);
3283    }
3284    if trimmed.eq_ignore_ascii_case("null") {
3285        return Value::Null;
3286    }
3287
3288    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
3289        return v;
3290    }
3291    if let Ok(v) = trimmed.parse::<i64>() {
3292        return Value::Number(Number::from(v));
3293    }
3294    if let Ok(v) = trimmed.parse::<f64>() {
3295        if let Some(n) = Number::from_f64(v) {
3296            return Value::Number(n);
3297        }
3298    }
3299
3300    Value::String(trimmed.to_string())
3301}
3302
3303fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
3304    if is_todo_status_update_args(&args) {
3305        return args;
3306    }
3307
3308    let mut obj = match args {
3309        Value::Object(map) => map,
3310        Value::Array(items) => {
3311            return json!({ "todos": normalize_todo_arg_items(items) });
3312        }
3313        Value::String(text) => {
3314            let derived = extract_todo_candidates_from_text(&text);
3315            if !derived.is_empty() {
3316                return json!({ "todos": derived });
3317            }
3318            return json!({});
3319        }
3320        _ => return json!({}),
3321    };
3322
3323    if obj
3324        .get("todos")
3325        .and_then(|v| v.as_array())
3326        .map(|arr| !arr.is_empty())
3327        .unwrap_or(false)
3328    {
3329        return Value::Object(obj);
3330    }
3331
3332    for alias in ["tasks", "items", "list", "checklist"] {
3333        if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
3334            let normalized = normalize_todo_arg_items(items.clone());
3335            if !normalized.is_empty() {
3336                obj.insert("todos".to_string(), Value::Array(normalized));
3337                return Value::Object(obj);
3338            }
3339        }
3340    }
3341
3342    let derived = extract_todo_candidates_from_text(completion);
3343    if !derived.is_empty() {
3344        obj.insert("todos".to_string(), Value::Array(derived));
3345    }
3346    Value::Object(obj)
3347}
3348
3349fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
3350    items
3351        .into_iter()
3352        .filter_map(|item| match item {
3353            Value::String(text) => {
3354                let content = text.trim();
3355                if content.is_empty() {
3356                    None
3357                } else {
3358                    Some(json!({"content": content}))
3359                }
3360            }
3361            Value::Object(mut obj) => {
3362                if !obj.contains_key("content") {
3363                    if let Some(text) = obj.get("text").cloned() {
3364                        obj.insert("content".to_string(), text);
3365                    } else if let Some(title) = obj.get("title").cloned() {
3366                        obj.insert("content".to_string(), title);
3367                    } else if let Some(name) = obj.get("name").cloned() {
3368                        obj.insert("content".to_string(), name);
3369                    }
3370                }
3371                let content = obj
3372                    .get("content")
3373                    .and_then(|v| v.as_str())
3374                    .map(str::trim)
3375                    .unwrap_or("");
3376                if content.is_empty() {
3377                    None
3378                } else {
3379                    Some(Value::Object(obj))
3380                }
3381            }
3382            _ => None,
3383        })
3384        .collect()
3385}
3386
3387fn is_todo_status_update_args(args: &Value) -> bool {
3388    let Some(obj) = args.as_object() else {
3389        return false;
3390    };
3391    let has_status = obj
3392        .get("status")
3393        .and_then(|v| v.as_str())
3394        .map(|s| !s.trim().is_empty())
3395        .unwrap_or(false);
3396    let has_target =
3397        obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
3398    has_status && has_target
3399}
3400
3401fn is_empty_todo_write_args(args: &Value) -> bool {
3402    if is_todo_status_update_args(args) {
3403        return false;
3404    }
3405    let Some(obj) = args.as_object() else {
3406        return true;
3407    };
3408    !obj.get("todos")
3409        .and_then(|v| v.as_array())
3410        .map(|arr| !arr.is_empty())
3411        .unwrap_or(false)
3412}
3413
3414fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
3415    let trimmed = raw_args.trim();
3416    if trimmed.is_empty() {
3417        return json!({});
3418    }
3419
3420    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3421        return normalize_streamed_tool_args(tool_name, parsed, trimmed);
3422    }
3423
3424    // Some providers emit non-JSON argument text (for example: raw query strings
3425    // or key=value fragments). Recover the common forms instead of dropping to {}.
3426    let kv_args = parse_function_style_args(trimmed);
3427    if !kv_args.is_empty() {
3428        return normalize_streamed_tool_args(tool_name, Value::Object(kv_args), trimmed);
3429    }
3430
3431    if normalize_tool_name(tool_name) == "websearch" {
3432        if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
3433            return json!({ "query": query });
3434        }
3435        return json!({});
3436    }
3437
3438    json!({})
3439}
3440
3441fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
3442    let normalized_tool = normalize_tool_name(tool_name);
3443    if normalized_tool != "websearch" {
3444        return parsed;
3445    }
3446
3447    match parsed {
3448        Value::Object(mut obj) => {
3449            if !has_websearch_query(&obj) && !raw.trim().is_empty() {
3450                if let Some(query) = sanitize_websearch_query_candidate(raw) {
3451                    obj.insert("query".to_string(), Value::String(query));
3452                }
3453            }
3454            Value::Object(obj)
3455        }
3456        Value::String(s) => match sanitize_websearch_query_candidate(&s) {
3457            Some(query) => json!({ "query": query }),
3458            None => json!({}),
3459        },
3460        other => other,
3461    }
3462}
3463
3464fn has_websearch_query(obj: &Map<String, Value>) -> bool {
3465    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
3466    QUERY_KEYS.iter().any(|key| {
3467        obj.get(*key)
3468            .and_then(|v| v.as_str())
3469            .map(|s| !s.trim().is_empty())
3470            .unwrap_or(false)
3471    })
3472}
3473
3474fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
3475    if let Some(obj) = value.as_object() {
3476        if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
3477            return Some((
3478                normalize_tool_name(tool),
3479                obj.get("args").cloned().unwrap_or_else(|| json!({})),
3480            ));
3481        }
3482
3483        if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
3484            let args = obj
3485                .get("args")
3486                .cloned()
3487                .or_else(|| obj.get("arguments").cloned())
3488                .unwrap_or_else(|| json!({}));
3489            let normalized_tool = normalize_tool_name(tool);
3490            let args = if let Some(raw) = args.as_str() {
3491                parse_streamed_tool_args(&normalized_tool, raw)
3492            } else {
3493                args
3494            };
3495            return Some((normalized_tool, args));
3496        }
3497
3498        for key in [
3499            "tool_call",
3500            "toolCall",
3501            "call",
3502            "function_call",
3503            "functionCall",
3504        ] {
3505            if let Some(nested) = obj.get(key) {
3506                if let Some(found) = extract_tool_call_from_value(nested) {
3507                    return Some(found);
3508                }
3509            }
3510        }
3511    }
3512
3513    if let Some(items) = value.as_array() {
3514        for item in items {
3515            if let Some(found) = extract_tool_call_from_value(item) {
3516                return Some(found);
3517            }
3518        }
3519    }
3520
3521    None
3522}
3523
3524fn extract_first_json_object(input: &str) -> Option<String> {
3525    let mut start = None;
3526    let mut depth = 0usize;
3527    for (idx, ch) in input.char_indices() {
3528        if ch == '{' {
3529            if start.is_none() {
3530                start = Some(idx);
3531            }
3532            depth += 1;
3533        } else if ch == '}' {
3534            if depth == 0 {
3535                continue;
3536            }
3537            depth -= 1;
3538            if depth == 0 {
3539                let begin = start?;
3540                let block = input.get(begin..=idx)?;
3541                return Some(block.to_string());
3542            }
3543        }
3544    }
3545    None
3546}
3547
3548fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
3549    let mut seen = HashSet::<String>::new();
3550    let mut todos = Vec::new();
3551
3552    for raw_line in input.lines() {
3553        let mut line = raw_line.trim();
3554        let mut structured_line = false;
3555        if line.is_empty() {
3556            continue;
3557        }
3558        if line.starts_with("```") {
3559            continue;
3560        }
3561        if line.ends_with(':') {
3562            continue;
3563        }
3564        if let Some(rest) = line
3565            .strip_prefix("- [ ]")
3566            .or_else(|| line.strip_prefix("* [ ]"))
3567            .or_else(|| line.strip_prefix("- [x]"))
3568            .or_else(|| line.strip_prefix("* [x]"))
3569        {
3570            line = rest.trim();
3571            structured_line = true;
3572        } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
3573            line = rest.trim();
3574            structured_line = true;
3575        } else {
3576            let bytes = line.as_bytes();
3577            let mut i = 0usize;
3578            while i < bytes.len() && bytes[i].is_ascii_digit() {
3579                i += 1;
3580            }
3581            if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
3582                line = line[i + 1..].trim();
3583                structured_line = true;
3584            }
3585        }
3586        if !structured_line {
3587            continue;
3588        }
3589
3590        let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
3591        if content.len() < 5 || content.len() > 180 {
3592            continue;
3593        }
3594        let key = content.to_lowercase();
3595        if seen.contains(&key) {
3596            continue;
3597        }
3598        seen.insert(key);
3599        todos.push(json!({ "content": content }));
3600        if todos.len() >= 25 {
3601            break;
3602        }
3603    }
3604
3605    todos
3606}
3607
3608async fn emit_plan_todo_fallback(
3609    storage: std::sync::Arc<Storage>,
3610    bus: &EventBus,
3611    session_id: &str,
3612    message_id: &str,
3613    completion: &str,
3614) {
3615    let todos = extract_todo_candidates_from_text(completion);
3616    if todos.is_empty() {
3617        return;
3618    }
3619
3620    let invoke_part = WireMessagePart::tool_invocation(
3621        session_id,
3622        message_id,
3623        "todo_write",
3624        json!({"todos": todos.clone()}),
3625    );
3626    let call_id = invoke_part.id.clone();
3627    bus.publish(EngineEvent::new(
3628        "message.part.updated",
3629        json!({"part": invoke_part}),
3630    ));
3631
3632    if storage.set_todos(session_id, todos).await.is_err() {
3633        let mut failed_part =
3634            WireMessagePart::tool_result(session_id, message_id, "todo_write", json!(null));
3635        failed_part.id = call_id;
3636        failed_part.state = Some("failed".to_string());
3637        failed_part.error = Some("failed to persist plan todos".to_string());
3638        bus.publish(EngineEvent::new(
3639            "message.part.updated",
3640            json!({"part": failed_part}),
3641        ));
3642        return;
3643    }
3644
3645    let normalized = storage.get_todos(session_id).await;
3646    let mut result_part = WireMessagePart::tool_result(
3647        session_id,
3648        message_id,
3649        "todo_write",
3650        json!({ "todos": normalized }),
3651    );
3652    result_part.id = call_id;
3653    bus.publish(EngineEvent::new(
3654        "message.part.updated",
3655        json!({"part": result_part}),
3656    ));
3657    bus.publish(EngineEvent::new(
3658        "todo.updated",
3659        json!({
3660            "sessionID": session_id,
3661            "todos": normalized
3662        }),
3663    ));
3664}
3665
3666async fn emit_plan_question_fallback(
3667    storage: std::sync::Arc<Storage>,
3668    bus: &EventBus,
3669    session_id: &str,
3670    message_id: &str,
3671    completion: &str,
3672) {
3673    let trimmed = completion.trim();
3674    if trimmed.is_empty() {
3675        return;
3676    }
3677
3678    let hints = extract_todo_candidates_from_text(trimmed)
3679        .into_iter()
3680        .take(6)
3681        .filter_map(|v| {
3682            v.get("content")
3683                .and_then(|c| c.as_str())
3684                .map(ToString::to_string)
3685        })
3686        .collect::<Vec<_>>();
3687
3688    let mut options = hints
3689        .iter()
3690        .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
3691        .collect::<Vec<_>>();
3692    if options.is_empty() {
3693        options = vec![
3694            json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
3695            json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
3696            json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
3697        ];
3698    }
3699
3700    let question_payload = vec![json!({
3701        "header":"Planning Input",
3702        "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
3703        "options": options,
3704        "multiple": true,
3705        "custom": true
3706    })];
3707
3708    let request = storage
3709        .add_question_request(session_id, message_id, question_payload.clone())
3710        .await
3711        .ok();
3712    bus.publish(EngineEvent::new(
3713        "question.asked",
3714        json!({
3715            "id": request
3716                .as_ref()
3717                .map(|req| req.id.clone())
3718                .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3719            "sessionID": session_id,
3720            "messageID": message_id,
3721            "questions": question_payload,
3722            "tool": request.and_then(|req| {
3723                req.tool.map(|tool| {
3724                    json!({
3725                        "callID": tool.call_id,
3726                        "messageID": tool.message_id
3727                    })
3728                })
3729            })
3730        }),
3731    ));
3732}
3733
3734async fn load_chat_history(storage: std::sync::Arc<Storage>, session_id: &str) -> Vec<ChatMessage> {
3735    let Some(session) = storage.get_session(session_id).await else {
3736        return Vec::new();
3737    };
3738    let messages = session
3739        .messages
3740        .into_iter()
3741        .map(|m| {
3742            let role = format!("{:?}", m.role).to_lowercase();
3743            let content = m
3744                .parts
3745                .into_iter()
3746                .map(|part| match part {
3747                    MessagePart::Text { text } => text,
3748                    MessagePart::Reasoning { text } => text,
3749                    MessagePart::ToolInvocation { tool, result, .. } => {
3750                        format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
3751                    }
3752                })
3753                .collect::<Vec<_>>()
3754                .join("\n");
3755            ChatMessage { role, content }
3756        })
3757        .collect::<Vec<_>>();
3758    compact_chat_history(messages)
3759}
3760
3761struct ToolSideEventContext<'a> {
3762    session_id: &'a str,
3763    message_id: &'a str,
3764    tool: &'a str,
3765    args: &'a serde_json::Value,
3766    metadata: &'a serde_json::Value,
3767    workspace_root: Option<&'a str>,
3768    effective_cwd: Option<&'a str>,
3769}
3770
3771async fn emit_tool_side_events(
3772    storage: std::sync::Arc<Storage>,
3773    bus: &EventBus,
3774    ctx: ToolSideEventContext<'_>,
3775) {
3776    let ToolSideEventContext {
3777        session_id,
3778        message_id,
3779        tool,
3780        args,
3781        metadata,
3782        workspace_root,
3783        effective_cwd,
3784    } = ctx;
3785    if tool == "todo_write" {
3786        let todos_from_metadata = metadata
3787            .get("todos")
3788            .and_then(|v| v.as_array())
3789            .cloned()
3790            .unwrap_or_default();
3791
3792        if !todos_from_metadata.is_empty() {
3793            let _ = storage.set_todos(session_id, todos_from_metadata).await;
3794        } else {
3795            let current = storage.get_todos(session_id).await;
3796            if let Some(updated) = apply_todo_updates_from_args(current, args) {
3797                let _ = storage.set_todos(session_id, updated).await;
3798            }
3799        }
3800
3801        let normalized = storage.get_todos(session_id).await;
3802        bus.publish(EngineEvent::new(
3803            "todo.updated",
3804            json!({
3805                "sessionID": session_id,
3806                "todos": normalized,
3807                "workspaceRoot": workspace_root,
3808                "effectiveCwd": effective_cwd
3809            }),
3810        ));
3811    }
3812    if tool == "question" {
3813        let questions = metadata
3814            .get("questions")
3815            .and_then(|v| v.as_array())
3816            .cloned()
3817            .unwrap_or_default();
3818        if questions.is_empty() {
3819            tracing::warn!(
3820                "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
3821                session_id,
3822                message_id
3823            );
3824        } else {
3825            let request = storage
3826                .add_question_request(session_id, message_id, questions.clone())
3827                .await
3828                .ok();
3829            bus.publish(EngineEvent::new(
3830                "question.asked",
3831                json!({
3832                    "id": request
3833                        .as_ref()
3834                        .map(|req| req.id.clone())
3835                        .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3836                    "sessionID": session_id,
3837                    "messageID": message_id,
3838                    "questions": questions,
3839                    "tool": request.and_then(|req| {
3840                        req.tool.map(|tool| {
3841                            json!({
3842                                "callID": tool.call_id,
3843                                "messageID": tool.message_id
3844                            })
3845                        })
3846                    }),
3847                    "workspaceRoot": workspace_root,
3848                    "effectiveCwd": effective_cwd
3849                }),
3850            ));
3851        }
3852    }
3853    if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
3854        for event in events {
3855            let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
3856                continue;
3857            };
3858            if !event_type.starts_with("agent_team.") {
3859                continue;
3860            }
3861            let mut properties = event
3862                .get("properties")
3863                .and_then(|v| v.as_object())
3864                .cloned()
3865                .unwrap_or_default();
3866            properties
3867                .entry("sessionID".to_string())
3868                .or_insert(json!(session_id));
3869            properties
3870                .entry("messageID".to_string())
3871                .or_insert(json!(message_id));
3872            properties
3873                .entry("workspaceRoot".to_string())
3874                .or_insert(json!(workspace_root));
3875            properties
3876                .entry("effectiveCwd".to_string())
3877                .or_insert(json!(effective_cwd));
3878            bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
3879        }
3880    }
3881}
3882
3883fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
3884    let obj = args.as_object()?;
3885    let mut todos = current;
3886    let mut changed = false;
3887
3888    if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
3889        for item in items {
3890            let Some(item_obj) = item.as_object() else {
3891                continue;
3892            };
3893            let status = item_obj
3894                .get("status")
3895                .and_then(|v| v.as_str())
3896                .map(normalize_todo_status);
3897            let target = item_obj
3898                .get("task_id")
3899                .or_else(|| item_obj.get("todo_id"))
3900                .or_else(|| item_obj.get("id"));
3901
3902            if let (Some(status), Some(target)) = (status, target) {
3903                changed |= apply_single_todo_status_update(&mut todos, target, &status);
3904            }
3905        }
3906    }
3907
3908    let status = obj
3909        .get("status")
3910        .and_then(|v| v.as_str())
3911        .map(normalize_todo_status);
3912    let target = obj
3913        .get("task_id")
3914        .or_else(|| obj.get("todo_id"))
3915        .or_else(|| obj.get("id"));
3916    if let (Some(status), Some(target)) = (status, target) {
3917        changed |= apply_single_todo_status_update(&mut todos, target, &status);
3918    }
3919
3920    if changed {
3921        Some(todos)
3922    } else {
3923        None
3924    }
3925}
3926
3927fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
3928    let idx_from_value = match target {
3929        Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
3930        Value::String(s) => {
3931            let trimmed = s.trim();
3932            trimmed
3933                .parse::<usize>()
3934                .ok()
3935                .map(|v| v.saturating_sub(1))
3936                .or_else(|| {
3937                    let digits = trimmed
3938                        .chars()
3939                        .rev()
3940                        .take_while(|c| c.is_ascii_digit())
3941                        .collect::<String>()
3942                        .chars()
3943                        .rev()
3944                        .collect::<String>();
3945                    digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
3946                })
3947        }
3948        _ => None,
3949    };
3950
3951    if let Some(idx) = idx_from_value {
3952        if idx < todos.len() {
3953            if let Some(obj) = todos[idx].as_object_mut() {
3954                obj.insert("status".to_string(), Value::String(status.to_string()));
3955                return true;
3956            }
3957        }
3958    }
3959
3960    let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
3961    if let Some(id_target) = id_target {
3962        for todo in todos.iter_mut() {
3963            if let Some(obj) = todo.as_object_mut() {
3964                if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
3965                    obj.insert("status".to_string(), Value::String(status.to_string()));
3966                    return true;
3967                }
3968            }
3969        }
3970    }
3971
3972    false
3973}
3974
3975fn normalize_todo_status(raw: &str) -> String {
3976    match raw.trim().to_lowercase().as_str() {
3977        "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
3978        "done" | "complete" | "completed" => "completed".to_string(),
3979        "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
3980        "open" | "todo" | "pending" => "pending".to_string(),
3981        other => other.to_string(),
3982    }
3983}
3984
3985fn compact_chat_history(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
3986    const MAX_CONTEXT_CHARS: usize = 80_000;
3987    const KEEP_RECENT_MESSAGES: usize = 40;
3988
3989    if messages.len() <= KEEP_RECENT_MESSAGES {
3990        let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
3991        if total_chars <= MAX_CONTEXT_CHARS {
3992            return messages;
3993        }
3994    }
3995
3996    let mut kept = messages;
3997    let mut dropped_count = 0usize;
3998    let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
3999
4000    while kept.len() > KEEP_RECENT_MESSAGES || total_chars > MAX_CONTEXT_CHARS {
4001        if kept.is_empty() {
4002            break;
4003        }
4004        let removed = kept.remove(0);
4005        total_chars = total_chars.saturating_sub(removed.content.len());
4006        dropped_count += 1;
4007    }
4008
4009    if dropped_count > 0 {
4010        kept.insert(
4011            0,
4012            ChatMessage {
4013                role: "system".to_string(),
4014                content: format!(
4015                    "[history compacted: omitted {} older messages to fit context window]",
4016                    dropped_count
4017                ),
4018            },
4019        );
4020    }
4021    kept
4022}
4023
4024#[cfg(test)]
4025mod tests {
4026    use super::*;
4027    use crate::{EventBus, Storage};
4028    use uuid::Uuid;
4029
4030    #[tokio::test]
4031    async fn todo_updated_event_is_normalized() {
4032        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
4033        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
4034        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
4035        let session_id = session.id.clone();
4036        storage.save_session(session).await.expect("save session");
4037
4038        let bus = EventBus::new();
4039        let mut rx = bus.subscribe();
4040        emit_tool_side_events(
4041            storage.clone(),
4042            &bus,
4043            ToolSideEventContext {
4044                session_id: &session_id,
4045                message_id: "m1",
4046                tool: "todo_write",
4047                args: &json!({"todos":[{"content":"ship parity"}]}),
4048                metadata: &json!({"todos":[{"content":"ship parity"}]}),
4049                workspace_root: Some("."),
4050                effective_cwd: Some("."),
4051            },
4052        )
4053        .await;
4054
4055        let event = rx.recv().await.expect("event");
4056        assert_eq!(event.event_type, "todo.updated");
4057        let todos = event
4058            .properties
4059            .get("todos")
4060            .and_then(|v| v.as_array())
4061            .cloned()
4062            .unwrap_or_default();
4063        assert_eq!(todos.len(), 1);
4064        assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
4065        assert_eq!(
4066            todos[0].get("content").and_then(|v| v.as_str()),
4067            Some("ship parity")
4068        );
4069        assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
4070    }
4071
4072    #[tokio::test]
4073    async fn question_asked_event_contains_tool_reference() {
4074        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
4075        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
4076        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
4077        let session_id = session.id.clone();
4078        storage.save_session(session).await.expect("save session");
4079
4080        let bus = EventBus::new();
4081        let mut rx = bus.subscribe();
4082        emit_tool_side_events(
4083            storage,
4084            &bus,
4085            ToolSideEventContext {
4086                session_id: &session_id,
4087                message_id: "msg-1",
4088                tool: "question",
4089                args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
4090                metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
4091                workspace_root: Some("."),
4092                effective_cwd: Some("."),
4093            },
4094        )
4095        .await;
4096
4097        let event = rx.recv().await.expect("event");
4098        assert_eq!(event.event_type, "question.asked");
4099        assert_eq!(
4100            event
4101                .properties
4102                .get("sessionID")
4103                .and_then(|v| v.as_str())
4104                .unwrap_or(""),
4105            session_id
4106        );
4107        let tool = event
4108            .properties
4109            .get("tool")
4110            .cloned()
4111            .unwrap_or_else(|| json!({}));
4112        assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
4113        assert_eq!(
4114            tool.get("messageID").and_then(|v| v.as_str()),
4115            Some("msg-1")
4116        );
4117    }
4118
4119    #[test]
4120    fn compact_chat_history_keeps_recent_and_inserts_summary() {
4121        let mut messages = Vec::new();
4122        for i in 0..60 {
4123            messages.push(ChatMessage {
4124                role: "user".to_string(),
4125                content: format!("message-{i}"),
4126            });
4127        }
4128        let compacted = compact_chat_history(messages);
4129        assert!(compacted.len() <= 41);
4130        assert_eq!(compacted[0].role, "system");
4131        assert!(compacted[0].content.contains("history compacted"));
4132        assert!(compacted.iter().any(|m| m.content.contains("message-59")));
4133    }
4134
4135    #[test]
4136    fn extracts_todos_from_checklist_and_numbered_lines() {
4137        let input = r#"
4138Plan:
4139- [ ] Audit current implementation
4140- [ ] Add planner fallback
41411. Add regression test coverage
4142"#;
4143        let todos = extract_todo_candidates_from_text(input);
4144        assert_eq!(todos.len(), 3);
4145        assert_eq!(
4146            todos[0].get("content").and_then(|v| v.as_str()),
4147            Some("Audit current implementation")
4148        );
4149    }
4150
4151    #[test]
4152    fn does_not_extract_todos_from_plain_prose_lines() {
4153        let input = r#"
4154I need more information to proceed.
4155Can you tell me the event size and budget?
4156Once I have that, I can provide a detailed plan.
4157"#;
4158        let todos = extract_todo_candidates_from_text(input);
4159        assert!(todos.is_empty());
4160    }
4161
4162    #[test]
4163    fn parses_wrapped_tool_call_from_markdown_response() {
4164        let input = r#"
4165Here is the tool call:
4166```json
4167{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
4168```
4169"#;
4170        let parsed = parse_tool_invocation_from_response(input).expect("tool call");
4171        assert_eq!(parsed.0, "todo_write");
4172        assert!(parsed.1.get("todos").is_some());
4173    }
4174
4175    #[test]
4176    fn parses_function_style_todowrite_call() {
4177        let input = r#"Status: Completed
4178Call: todowrite(task_id=2, status="completed")"#;
4179        let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
4180        assert_eq!(parsed.0, "todo_write");
4181        assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
4182        assert_eq!(
4183            parsed.1.get("status").and_then(|v| v.as_str()),
4184            Some("completed")
4185        );
4186    }
4187
4188    #[test]
4189    fn parses_multiple_function_style_todowrite_calls() {
4190        let input = r#"
4191Call: todowrite(task_id=2, status="completed")
4192Call: todowrite(task_id=3, status="in_progress")
4193"#;
4194        let parsed = parse_tool_invocations_from_response(input);
4195        assert_eq!(parsed.len(), 2);
4196        assert_eq!(parsed[0].0, "todo_write");
4197        assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
4198        assert_eq!(
4199            parsed[0].1.get("status").and_then(|v| v.as_str()),
4200            Some("completed")
4201        );
4202        assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
4203        assert_eq!(
4204            parsed[1].1.get("status").and_then(|v| v.as_str()),
4205            Some("in_progress")
4206        );
4207    }
4208
4209    #[test]
4210    fn applies_todo_status_update_from_task_id_args() {
4211        let current = vec![
4212            json!({"id":"todo-1","content":"a","status":"pending"}),
4213            json!({"id":"todo-2","content":"b","status":"pending"}),
4214            json!({"id":"todo-3","content":"c","status":"pending"}),
4215        ];
4216        let updated =
4217            apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
4218                .expect("status update");
4219        assert_eq!(
4220            updated[1].get("status").and_then(|v| v.as_str()),
4221            Some("completed")
4222        );
4223    }
4224
4225    #[test]
4226    fn normalizes_todo_write_tasks_alias() {
4227        let normalized = normalize_todo_write_args(
4228            json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
4229            "",
4230        );
4231        let todos = normalized
4232            .get("todos")
4233            .and_then(|v| v.as_array())
4234            .cloned()
4235            .unwrap_or_default();
4236        assert_eq!(todos.len(), 2);
4237        assert_eq!(
4238            todos[0].get("content").and_then(|v| v.as_str()),
4239            Some("Book venue")
4240        );
4241        assert_eq!(
4242            todos[1].get("content").and_then(|v| v.as_str()),
4243            Some("Send invites")
4244        );
4245    }
4246
4247    #[test]
4248    fn normalizes_todo_write_from_completion_when_args_empty() {
4249        let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
4250        let normalized = normalize_todo_write_args(json!({}), completion);
4251        let todos = normalized
4252            .get("todos")
4253            .and_then(|v| v.as_array())
4254            .cloned()
4255            .unwrap_or_default();
4256        assert_eq!(todos.len(), 3);
4257        assert!(!is_empty_todo_write_args(&normalized));
4258    }
4259
4260    #[test]
4261    fn empty_todo_write_args_allows_status_updates() {
4262        let args = json!({"task_id": 2, "status":"completed"});
4263        assert!(!is_empty_todo_write_args(&args));
4264    }
4265
4266    #[test]
4267    fn streamed_websearch_args_fallback_to_query_string() {
4268        let parsed = parse_streamed_tool_args("websearch", "meaning of life");
4269        assert_eq!(
4270            parsed.get("query").and_then(|v| v.as_str()),
4271            Some("meaning of life")
4272        );
4273    }
4274
4275    #[test]
4276    fn streamed_websearch_stringified_json_args_are_unwrapped() {
4277        let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
4278        assert_eq!(
4279            parsed.get("query").and_then(|v| v.as_str()),
4280            Some("donkey gestation period")
4281        );
4282    }
4283
4284    #[test]
4285    fn streamed_websearch_args_strip_arg_key_value_wrappers() {
4286        let parsed = parse_streamed_tool_args(
4287            "websearch",
4288            "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
4289        );
4290        assert_eq!(
4291            parsed.get("query").and_then(|v| v.as_str()),
4292            Some("taj card what is it benefits how to apply")
4293        );
4294    }
4295
4296    #[test]
4297    fn normalize_tool_args_websearch_infers_from_user_text() {
4298        let normalized =
4299            normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
4300        assert_eq!(
4301            normalized.args.get("query").and_then(|v| v.as_str()),
4302            Some("meaning of life")
4303        );
4304        assert_eq!(normalized.args_source, "inferred_from_user");
4305        assert_eq!(normalized.args_integrity, "recovered");
4306    }
4307
4308    #[test]
4309    fn normalize_tool_args_websearch_keeps_existing_query() {
4310        let normalized = normalize_tool_args(
4311            "websearch",
4312            json!({"query":"already set"}),
4313            "web search should not override",
4314            "",
4315        );
4316        assert_eq!(
4317            normalized.args.get("query").and_then(|v| v.as_str()),
4318            Some("already set")
4319        );
4320        assert_eq!(normalized.args_source, "provider_json");
4321        assert_eq!(normalized.args_integrity, "ok");
4322    }
4323
4324    #[test]
4325    fn normalize_tool_args_websearch_fails_when_unrecoverable() {
4326        let normalized = normalize_tool_args("websearch", json!({}), "search", "");
4327        assert!(normalized.query.is_none());
4328        assert!(normalized.missing_terminal);
4329        assert_eq!(normalized.args_source, "missing");
4330        assert_eq!(normalized.args_integrity, "empty");
4331    }
4332
4333    #[test]
4334    fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
4335        let normalized = normalize_tool_args(
4336            "webfetch",
4337            json!({}),
4338            "Please fetch `https://tandem.frumu.ai/docs/` in markdown mode",
4339            "",
4340        );
4341        assert!(!normalized.missing_terminal);
4342        assert_eq!(
4343            normalized.args.get("url").and_then(|v| v.as_str()),
4344            Some("https://tandem.frumu.ai/docs/")
4345        );
4346        assert_eq!(normalized.args_source, "inferred_from_user");
4347        assert_eq!(normalized.args_integrity, "recovered");
4348    }
4349
4350    #[test]
4351    fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
4352        let normalized = normalize_tool_args(
4353            "webfetch",
4354            json!({"args":{"uri":"https://example.com/page"}}),
4355            "",
4356            "",
4357        );
4358        assert!(!normalized.missing_terminal);
4359        assert_eq!(
4360            normalized.args.get("url").and_then(|v| v.as_str()),
4361            Some("https://example.com/page")
4362        );
4363        assert_eq!(normalized.args_source, "provider_json");
4364    }
4365
4366    #[test]
4367    fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
4368        let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
4369        assert!(normalized.missing_terminal);
4370        assert_eq!(
4371            normalized.missing_terminal_reason.as_deref(),
4372            Some("WEBFETCH_URL_MISSING")
4373        );
4374    }
4375
4376    #[test]
4377    fn normalize_tool_args_write_requires_path() {
4378        let normalized = normalize_tool_args("write", json!({}), "", "");
4379        assert!(normalized.missing_terminal);
4380        assert_eq!(
4381            normalized.missing_terminal_reason.as_deref(),
4382            Some("FILE_PATH_MISSING")
4383        );
4384    }
4385
4386    #[test]
4387    fn normalize_tool_args_write_recovers_alias_path_key() {
4388        let normalized = normalize_tool_args(
4389            "write",
4390            json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
4391            "",
4392            "",
4393        );
4394        assert!(!normalized.missing_terminal);
4395        assert_eq!(
4396            normalized.args.get("path").and_then(|v| v.as_str()),
4397            Some("docs/CONCEPT.md")
4398        );
4399        assert_eq!(
4400            normalized.args.get("content").and_then(|v| v.as_str()),
4401            Some("hello")
4402        );
4403    }
4404
4405    #[test]
4406    fn normalize_tool_args_read_infers_path_from_user_prompt() {
4407        let normalized = normalize_tool_args(
4408            "read",
4409            json!({}),
4410            "Please inspect `FEATURE_LIST.md` and summarize key sections.",
4411            "",
4412        );
4413        assert!(!normalized.missing_terminal);
4414        assert_eq!(
4415            normalized.args.get("path").and_then(|v| v.as_str()),
4416            Some("FEATURE_LIST.md")
4417        );
4418        assert_eq!(normalized.args_source, "inferred_from_user");
4419        assert_eq!(normalized.args_integrity, "recovered");
4420    }
4421
4422    #[test]
4423    fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
4424        let normalized = normalize_tool_args(
4425            "read",
4426            json!({}),
4427            "generic instruction",
4428            "I will read src-tauri/src/orchestrator/engine.rs first.",
4429        );
4430        assert!(normalized.missing_terminal);
4431        assert_eq!(
4432            normalized.missing_terminal_reason.as_deref(),
4433            Some("FILE_PATH_MISSING")
4434        );
4435    }
4436
4437    #[test]
4438    fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
4439        let normalized = normalize_tool_args(
4440            "write",
4441            json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
4442            "",
4443            "",
4444        );
4445        assert!(!normalized.missing_terminal);
4446        assert_eq!(
4447            normalized.args.get("path").and_then(|v| v.as_str()),
4448            Some("docs/CONCEPT.md")
4449        );
4450    }
4451
4452    #[test]
4453    fn normalize_tool_args_write_recovers_content_alias() {
4454        let normalized = normalize_tool_args(
4455            "write",
4456            json!({"path":"docs/FEATURES.md","body":"feature notes"}),
4457            "",
4458            "",
4459        );
4460        assert!(!normalized.missing_terminal);
4461        assert_eq!(
4462            normalized.args.get("content").and_then(|v| v.as_str()),
4463            Some("feature notes")
4464        );
4465    }
4466
4467    #[test]
4468    fn normalize_tool_args_write_fails_when_content_missing() {
4469        let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
4470        assert!(normalized.missing_terminal);
4471        assert_eq!(
4472            normalized.missing_terminal_reason.as_deref(),
4473            Some("WRITE_CONTENT_MISSING")
4474        );
4475    }
4476
4477    #[test]
4478    fn normalize_tool_args_write_recovers_raw_nested_string_content() {
4479        let normalized = normalize_tool_args(
4480            "write",
4481            json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
4482            "",
4483            "",
4484        );
4485        assert!(!normalized.missing_terminal);
4486        assert_eq!(
4487            normalized.args.get("path").and_then(|v| v.as_str()),
4488            Some("docs/FEATURES.md")
4489        );
4490        assert_eq!(
4491            normalized.args.get("content").and_then(|v| v.as_str()),
4492            Some("Line 1\nLine 2")
4493        );
4494    }
4495
4496    #[test]
4497    fn normalize_tool_args_write_does_not_treat_path_as_content() {
4498        let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
4499        assert!(normalized.missing_terminal);
4500        assert_eq!(
4501            normalized.missing_terminal_reason.as_deref(),
4502            Some("WRITE_CONTENT_MISSING")
4503        );
4504    }
4505
4506    #[test]
4507    fn normalize_tool_args_read_infers_path_from_bold_markdown() {
4508        let normalized = normalize_tool_args(
4509            "read",
4510            json!({}),
4511            "Please read **FEATURE_LIST.md** and summarize.",
4512            "",
4513        );
4514        assert!(!normalized.missing_terminal);
4515        assert_eq!(
4516            normalized.args.get("path").and_then(|v| v.as_str()),
4517            Some("FEATURE_LIST.md")
4518        );
4519    }
4520
4521    #[test]
4522    fn normalize_tool_args_shell_infers_command_from_user_prompt() {
4523        let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
4524        assert!(!normalized.missing_terminal);
4525        assert_eq!(
4526            normalized.args.get("command").and_then(|v| v.as_str()),
4527            Some("rg -n \"TODO\" .")
4528        );
4529        assert_eq!(normalized.args_source, "inferred_from_user");
4530        assert_eq!(normalized.args_integrity, "recovered");
4531    }
4532
4533    #[test]
4534    fn normalize_tool_args_read_rejects_root_only_path() {
4535        let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
4536        assert!(normalized.missing_terminal);
4537        assert_eq!(
4538            normalized.missing_terminal_reason.as_deref(),
4539            Some("FILE_PATH_MISSING")
4540        );
4541    }
4542
4543    #[test]
4544    fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
4545        let normalized =
4546            normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
4547        assert!(!normalized.missing_terminal);
4548        assert_eq!(
4549            normalized.args.get("path").and_then(|v| v.as_str()),
4550            Some("CONCEPT.md")
4551        );
4552        assert_eq!(normalized.args_source, "inferred_from_user");
4553        assert_eq!(normalized.args_integrity, "recovered");
4554    }
4555
4556    #[test]
4557    fn normalize_tool_args_read_rejects_tool_call_markup_path() {
4558        let normalized = normalize_tool_args(
4559            "read",
4560            json!({
4561                "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
4562            }),
4563            "",
4564            "",
4565        );
4566        assert!(normalized.missing_terminal);
4567        assert_eq!(
4568            normalized.missing_terminal_reason.as_deref(),
4569            Some("FILE_PATH_MISSING")
4570        );
4571    }
4572
4573    #[test]
4574    fn normalize_tool_args_read_rejects_glob_pattern_path() {
4575        let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
4576        assert!(normalized.missing_terminal);
4577        assert_eq!(
4578            normalized.missing_terminal_reason.as_deref(),
4579            Some("FILE_PATH_MISSING")
4580        );
4581    }
4582
4583    #[test]
4584    fn normalize_tool_args_read_rejects_placeholder_path() {
4585        let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
4586        assert!(normalized.missing_terminal);
4587        assert_eq!(
4588            normalized.missing_terminal_reason.as_deref(),
4589            Some("FILE_PATH_MISSING")
4590        );
4591    }
4592
4593    #[test]
4594    fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
4595        let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
4596        assert!(normalized.missing_terminal);
4597        assert_eq!(
4598            normalized.missing_terminal_reason.as_deref(),
4599            Some("FILE_PATH_MISSING")
4600        );
4601    }
4602
4603    #[test]
4604    fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
4605        let normalized = normalize_tool_args(
4606            "read",
4607            json!({"path":"tool/policy"}),
4608            "Read `T1011U kitöltési útmutató.pdf` and summarize.",
4609            "",
4610        );
4611        assert!(!normalized.missing_terminal);
4612        assert_eq!(
4613            normalized.args.get("path").and_then(|v| v.as_str()),
4614            Some("T1011U kitöltési útmutató.pdf")
4615        );
4616        assert_eq!(normalized.args_source, "inferred_from_user");
4617        assert_eq!(normalized.args_integrity, "recovered");
4618    }
4619
4620    #[test]
4621    fn normalize_tool_name_strips_default_api_namespace() {
4622        assert_eq!(normalize_tool_name("default_api:read"), "read");
4623        assert_eq!(normalize_tool_name("functions.shell"), "bash");
4624    }
4625
4626    #[test]
4627    fn mcp_server_from_tool_name_parses_server_segment() {
4628        assert_eq!(
4629            mcp_server_from_tool_name("mcp.arcade.jira_getboards"),
4630            Some("arcade")
4631        );
4632        assert_eq!(mcp_server_from_tool_name("read"), None);
4633        assert_eq!(mcp_server_from_tool_name("mcp"), None);
4634    }
4635
4636    #[test]
4637    fn batch_helpers_use_name_when_tool_is_wrapper() {
4638        let args = json!({
4639            "tool_calls":[
4640                {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
4641                {"tool":"default_api:glob","args":{"pattern":"*.md"}}
4642            ]
4643        });
4644        let calls = extract_batch_calls(&args);
4645        assert_eq!(calls.len(), 2);
4646        assert_eq!(calls[0].0, "read");
4647        assert_eq!(calls[1].0, "glob");
4648        assert!(is_read_only_batch_call(&args));
4649        let sig = batch_tool_signature(&args).unwrap_or_default();
4650        assert!(sig.contains("read:"));
4651        assert!(sig.contains("glob:"));
4652    }
4653
4654    #[test]
4655    fn batch_helpers_resolve_nested_function_name() {
4656        let args = json!({
4657            "tool_calls":[
4658                {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
4659            ]
4660        });
4661        let calls = extract_batch_calls(&args);
4662        assert_eq!(calls.len(), 1);
4663        assert_eq!(calls[0].0, "read");
4664        assert!(is_read_only_batch_call(&args));
4665    }
4666
4667    #[test]
4668    fn batch_output_classifier_detects_non_productive_unknown_results() {
4669        let output = r#"
4670[
4671  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
4672  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
4673]
4674"#;
4675        assert!(is_non_productive_batch_output(output));
4676    }
4677
4678    #[test]
4679    fn runtime_prompt_includes_execution_environment_block() {
4680        let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {
4681            os: HostOs::Windows,
4682            arch: "x86_64".to_string(),
4683            shell_family: ShellFamily::Powershell,
4684            path_style: PathStyle::Windows,
4685        });
4686        assert!(prompt.contains("[Execution Environment]"));
4687        assert!(prompt.contains("Host OS: windows"));
4688        assert!(prompt.contains("Shell: powershell"));
4689        assert!(prompt.contains("Path style: windows"));
4690    }
4691
4692    #[test]
4693    fn extract_mcp_auth_required_metadata_parses_expected_shape() {
4694        let metadata = json!({
4695            "server": "arcade",
4696            "mcpAuth": {
4697                "required": true,
4698                "challengeId": "abc123",
4699                "authorizationUrl": "https://example.com/oauth",
4700                "message": "Authorize first",
4701                "pending": true,
4702                "blocked": true,
4703                "retryAfterMs": 8000
4704            }
4705        });
4706        let parsed = extract_mcp_auth_required_metadata(&metadata).expect("expected metadata");
4707        assert_eq!(parsed.challenge_id, "abc123");
4708        assert_eq!(parsed.authorization_url, "https://example.com/oauth");
4709        assert_eq!(parsed.message, "Authorize first");
4710        assert_eq!(parsed.server.as_deref(), Some("arcade"));
4711        assert!(parsed.pending);
4712        assert!(parsed.blocked);
4713        assert_eq!(parsed.retry_after_ms, Some(8000));
4714    }
4715
4716    #[test]
4717    fn auth_required_output_detector_matches_auth_text() {
4718        assert!(is_auth_required_tool_output(
4719            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com"
4720        ));
4721        assert!(is_auth_required_tool_output(
4722            "Authorization pending for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com\nRetry after 8s."
4723        ));
4724        assert!(!is_auth_required_tool_output("Tool `read` result: ok"));
4725    }
4726
4727    #[test]
4728    fn guard_budget_output_detector_matches_expected_text() {
4729        assert!(is_guard_budget_tool_output(
4730            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
4731        ));
4732        assert!(!is_guard_budget_tool_output("Tool `read` result: ok"));
4733    }
4734
4735    #[test]
4736    fn summarize_guard_budget_outputs_returns_run_scoped_message() {
4737        let outputs = vec![
4738            "Tool `mcp.arcade.gmail_sendemail` call skipped: per-run guard budget exceeded (10)."
4739                .to_string(),
4740            "Tool `mcp.arcade.jira_getboards` call skipped: per-run guard budget exceeded (10)."
4741                .to_string(),
4742        ];
4743        let summary = summarize_guard_budget_outputs(&outputs).expect("expected summary");
4744        assert!(summary.contains("per-run tool guard budget"));
4745        assert!(summary.contains("fresh run"));
4746    }
4747
4748    #[test]
4749    fn summarize_auth_pending_outputs_returns_summary_when_all_are_auth_related() {
4750        let outputs = vec![
4751            "Authorization pending for `mcp.arcade.gmail_sendemail`.\nAuthorize here: https://example.com/a".to_string(),
4752            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com/b".to_string(),
4753        ];
4754        let summary = summarize_auth_pending_outputs(&outputs).expect("summary expected");
4755        assert!(summary.contains("Authorization is required before I can continue"));
4756        assert!(summary.contains("gmail_sendemail"));
4757        assert!(summary.contains("gmail_whoami"));
4758    }
4759
4760    #[test]
4761    fn summarize_auth_pending_outputs_returns_none_for_mixed_outputs() {
4762        let outputs = vec![
4763            "Authorization required for `mcp.arcade.gmail_whoami`.\nAuthorize here: https://example.com".to_string(),
4764            "Tool `read` result:\nok".to_string(),
4765        ];
4766        assert!(summarize_auth_pending_outputs(&outputs).is_none());
4767    }
4768}