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