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