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