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"
1464            | "webfetch_html"
1465    )
1466}
1467
1468fn is_batch_wrapper_tool_name(name: &str) -> bool {
1469    matches!(
1470        normalize_tool_name(name).as_str(),
1471        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1472    )
1473}
1474
1475fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1476    obj.get(key)
1477        .and_then(|v| v.as_str())
1478        .map(str::trim)
1479        .filter(|s| !s.is_empty())
1480}
1481
1482fn nested_non_empty_string_at<'a>(
1483    obj: &'a Map<String, Value>,
1484    parent: &str,
1485    key: &str,
1486) -> Option<&'a str> {
1487    obj.get(parent)
1488        .and_then(|v| v.as_object())
1489        .and_then(|nested| nested.get(key))
1490        .and_then(|v| v.as_str())
1491        .map(str::trim)
1492        .filter(|s| !s.is_empty())
1493}
1494
1495fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1496    let calls = args
1497        .get("tool_calls")
1498        .and_then(|v| v.as_array())
1499        .cloned()
1500        .unwrap_or_default();
1501    calls
1502        .into_iter()
1503        .filter_map(|call| {
1504            let obj = call.as_object()?;
1505            let tool_raw = non_empty_string_at(obj, "tool")
1506                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
1507                .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
1508                .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
1509                .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
1510            let name_raw = non_empty_string_at(obj, "name")
1511                .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
1512                .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
1513                .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
1514                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
1515            let effective = match (tool_raw, name_raw) {
1516                (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1517                (Some(t), _) => t,
1518                (None, Some(n)) => n,
1519                (None, None) => return None,
1520            };
1521            let normalized = normalize_tool_name(effective);
1522            let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1523            Some((normalized, call_args))
1524        })
1525        .collect()
1526}
1527
1528fn is_read_only_batch_call(args: &Value) -> bool {
1529    let calls = extract_batch_calls(args);
1530    !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1531}
1532
1533fn batch_tool_signature(args: &Value) -> Option<String> {
1534    let calls = extract_batch_calls(args);
1535    if calls.is_empty() {
1536        return None;
1537    }
1538    let parts = calls
1539        .into_iter()
1540        .map(|(tool, call_args)| tool_signature(&tool, &call_args))
1541        .collect::<Vec<_>>();
1542    Some(format!("batch:{}", parts.join("|")))
1543}
1544
1545fn is_non_productive_batch_output(output: &str) -> bool {
1546    let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
1547        return false;
1548    };
1549    let Some(items) = value.as_array() else {
1550        return false;
1551    };
1552    if items.is_empty() {
1553        return true;
1554    }
1555    items.iter().all(|item| {
1556        let text = item
1557            .get("output")
1558            .and_then(|v| v.as_str())
1559            .map(str::trim)
1560            .unwrap_or_default()
1561            .to_ascii_lowercase();
1562        text.is_empty()
1563            || text.starts_with("unknown tool:")
1564            || text.contains("call skipped")
1565            || text.contains("guard budget exceeded")
1566    })
1567}
1568
1569fn tool_budget_for(tool_name: &str) -> usize {
1570    match normalize_tool_name(tool_name).as_str() {
1571        "glob" => 4,
1572        "read" => 8,
1573        "websearch" => 3,
1574        "batch" => 4,
1575        "grep" | "search" | "codesearch" => 6,
1576        _ => 10,
1577    }
1578}
1579
1580#[derive(Debug, Clone)]
1581struct NormalizedToolArgs {
1582    args: Value,
1583    args_source: String,
1584    args_integrity: String,
1585    query: Option<String>,
1586    missing_terminal: bool,
1587    missing_terminal_reason: Option<String>,
1588}
1589
1590fn normalize_tool_args(
1591    tool_name: &str,
1592    raw_args: Value,
1593    latest_user_text: &str,
1594    latest_assistant_context: &str,
1595) -> NormalizedToolArgs {
1596    let normalized_tool = normalize_tool_name(tool_name);
1597    let mut args = raw_args;
1598    let mut args_source = if args.is_string() {
1599        "provider_string".to_string()
1600    } else {
1601        "provider_json".to_string()
1602    };
1603    let mut args_integrity = "ok".to_string();
1604    let mut query = None;
1605    let mut missing_terminal = false;
1606    let mut missing_terminal_reason = None;
1607
1608    if normalized_tool == "websearch" {
1609        if let Some(found) = extract_websearch_query(&args) {
1610            query = Some(found);
1611            args = set_websearch_query_and_source(args, query.clone(), "tool_args");
1612        } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
1613            args_source = "inferred_from_user".to_string();
1614            args_integrity = "recovered".to_string();
1615            query = Some(inferred);
1616            args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
1617        } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
1618            args_source = "recovered_from_context".to_string();
1619            args_integrity = "recovered".to_string();
1620            query = Some(recovered);
1621            args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
1622        } else {
1623            args_source = "missing".to_string();
1624            args_integrity = "empty".to_string();
1625            missing_terminal = true;
1626            missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
1627        }
1628    } else if is_shell_tool_name(&normalized_tool) {
1629        if let Some(command) = extract_shell_command(&args) {
1630            args = set_shell_command(args, command);
1631        } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
1632            args_source = "inferred_from_context".to_string();
1633            args_integrity = "recovered".to_string();
1634            args = set_shell_command(args, inferred);
1635        } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
1636            args_source = "inferred_from_user".to_string();
1637            args_integrity = "recovered".to_string();
1638            args = set_shell_command(args, inferred);
1639        } else {
1640            args_source = "missing".to_string();
1641            args_integrity = "empty".to_string();
1642            missing_terminal = true;
1643            missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
1644        }
1645    } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
1646        if let Some(path) = extract_file_path_arg(&args) {
1647            args = set_file_path_arg(args, path);
1648        } else if let Some(inferred) = infer_file_path_from_text(latest_assistant_context) {
1649            args_source = "inferred_from_context".to_string();
1650            args_integrity = "recovered".to_string();
1651            args = set_file_path_arg(args, inferred);
1652        } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
1653            args_source = "inferred_from_user".to_string();
1654            args_integrity = "recovered".to_string();
1655            args = set_file_path_arg(args, inferred);
1656        } else {
1657            args_source = "missing".to_string();
1658            args_integrity = "empty".to_string();
1659            missing_terminal = true;
1660            missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
1661        }
1662
1663        if !missing_terminal && normalized_tool == "write" {
1664            if let Some(content) = extract_write_content_arg(&args) {
1665                args = set_write_content_arg(args, content);
1666            } else {
1667                args_source = "missing".to_string();
1668                args_integrity = "empty".to_string();
1669                missing_terminal = true;
1670                missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
1671            }
1672        }
1673    }
1674
1675    NormalizedToolArgs {
1676        args,
1677        args_source,
1678        args_integrity,
1679        query,
1680        missing_terminal,
1681        missing_terminal_reason,
1682    }
1683}
1684
1685fn is_shell_tool_name(tool_name: &str) -> bool {
1686    matches!(
1687        tool_name.trim().to_ascii_lowercase().as_str(),
1688        "bash" | "shell" | "powershell" | "cmd"
1689    )
1690}
1691
1692fn set_file_path_arg(args: Value, path: String) -> Value {
1693    let mut obj = args.as_object().cloned().unwrap_or_default();
1694    obj.insert("path".to_string(), Value::String(path));
1695    Value::Object(obj)
1696}
1697
1698fn set_write_content_arg(args: Value, content: String) -> Value {
1699    let mut obj = args.as_object().cloned().unwrap_or_default();
1700    obj.insert("content".to_string(), Value::String(content));
1701    Value::Object(obj)
1702}
1703
1704fn extract_file_path_arg(args: &Value) -> Option<String> {
1705    extract_file_path_arg_internal(args, 0)
1706}
1707
1708fn extract_write_content_arg(args: &Value) -> Option<String> {
1709    extract_write_content_arg_internal(args, 0)
1710}
1711
1712fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
1713    if depth > 5 {
1714        return None;
1715    }
1716
1717    match args {
1718        Value::String(raw) => {
1719            let trimmed = raw.trim();
1720            if trimmed.is_empty() {
1721                return None;
1722            }
1723            // If the provider sent plain string args, treat it as a path directly.
1724            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1725                return sanitize_path_candidate(trimmed);
1726            }
1727            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1728                return extract_file_path_arg_internal(&parsed, depth + 1);
1729            }
1730            sanitize_path_candidate(trimmed)
1731        }
1732        Value::Array(items) => items
1733            .iter()
1734            .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
1735        Value::Object(obj) => {
1736            for key in FILE_PATH_KEYS {
1737                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1738                    if let Some(path) = sanitize_path_candidate(raw) {
1739                        return Some(path);
1740                    }
1741                }
1742            }
1743            for container in NESTED_ARGS_KEYS {
1744                if let Some(nested) = obj.get(container) {
1745                    if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
1746                        return Some(path);
1747                    }
1748                }
1749            }
1750            None
1751        }
1752        _ => None,
1753    }
1754}
1755
1756fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
1757    if depth > 5 {
1758        return None;
1759    }
1760
1761    match args {
1762        Value::String(raw) => {
1763            let trimmed = raw.trim();
1764            if trimmed.is_empty() {
1765                return None;
1766            }
1767            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1768                return extract_write_content_arg_internal(&parsed, depth + 1);
1769            }
1770            // Some providers collapse args to a plain string. Recover as content only when
1771            // it does not look like a standalone file path token.
1772            if sanitize_path_candidate(trimmed).is_some()
1773                && !trimmed.contains('\n')
1774                && trimmed.split_whitespace().count() <= 3
1775            {
1776                return None;
1777            }
1778            Some(trimmed.to_string())
1779        }
1780        Value::Array(items) => items
1781            .iter()
1782            .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
1783        Value::Object(obj) => {
1784            for key in WRITE_CONTENT_KEYS {
1785                if let Some(value) = obj.get(key) {
1786                    if let Some(raw) = value.as_str() {
1787                        if !raw.is_empty() {
1788                            return Some(raw.to_string());
1789                        }
1790                    } else if let Some(recovered) =
1791                        extract_write_content_arg_internal(value, depth + 1)
1792                    {
1793                        return Some(recovered);
1794                    }
1795                }
1796            }
1797            for container in NESTED_ARGS_KEYS {
1798                if let Some(nested) = obj.get(container) {
1799                    if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
1800                        return Some(content);
1801                    }
1802                }
1803            }
1804            None
1805        }
1806        _ => None,
1807    }
1808}
1809
1810fn set_shell_command(args: Value, command: String) -> Value {
1811    let mut obj = args.as_object().cloned().unwrap_or_default();
1812    obj.insert("command".to_string(), Value::String(command));
1813    Value::Object(obj)
1814}
1815
1816fn extract_shell_command(args: &Value) -> Option<String> {
1817    extract_shell_command_internal(args, 0)
1818}
1819
1820fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
1821    if depth > 5 {
1822        return None;
1823    }
1824
1825    match args {
1826        Value::String(raw) => {
1827            let trimmed = raw.trim();
1828            if trimmed.is_empty() {
1829                return None;
1830            }
1831            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1832                return sanitize_shell_command_candidate(trimmed);
1833            }
1834            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1835                return extract_shell_command_internal(&parsed, depth + 1);
1836            }
1837            sanitize_shell_command_candidate(trimmed)
1838        }
1839        Value::Array(items) => items
1840            .iter()
1841            .find_map(|item| extract_shell_command_internal(item, depth + 1)),
1842        Value::Object(obj) => {
1843            for key in SHELL_COMMAND_KEYS {
1844                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1845                    if let Some(command) = sanitize_shell_command_candidate(raw) {
1846                        return Some(command);
1847                    }
1848                }
1849            }
1850            for container in NESTED_ARGS_KEYS {
1851                if let Some(nested) = obj.get(container) {
1852                    if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
1853                        return Some(command);
1854                    }
1855                }
1856            }
1857            None
1858        }
1859        _ => None,
1860    }
1861}
1862
1863fn infer_shell_command_from_text(text: &str) -> Option<String> {
1864    let trimmed = text.trim();
1865    if trimmed.is_empty() {
1866        return None;
1867    }
1868
1869    // Prefer explicit backtick commands first.
1870    let mut in_tick = false;
1871    let mut tick_buf = String::new();
1872    for ch in trimmed.chars() {
1873        if ch == '`' {
1874            if in_tick {
1875                if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
1876                    if looks_like_shell_command(&candidate) {
1877                        return Some(candidate);
1878                    }
1879                }
1880                tick_buf.clear();
1881            }
1882            in_tick = !in_tick;
1883            continue;
1884        }
1885        if in_tick {
1886            tick_buf.push(ch);
1887        }
1888    }
1889
1890    for line in trimmed.lines() {
1891        let line = line.trim();
1892        if line.is_empty() {
1893            continue;
1894        }
1895        let lower = line.to_ascii_lowercase();
1896        for prefix in [
1897            "run ",
1898            "execute ",
1899            "call ",
1900            "use bash ",
1901            "use shell ",
1902            "bash ",
1903            "shell ",
1904            "powershell ",
1905            "pwsh ",
1906        ] {
1907            if lower.starts_with(prefix) {
1908                let candidate = line[prefix.len()..].trim();
1909                if let Some(command) = sanitize_shell_command_candidate(candidate) {
1910                    if looks_like_shell_command(&command) {
1911                        return Some(command);
1912                    }
1913                }
1914            }
1915        }
1916    }
1917
1918    None
1919}
1920
1921fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
1922    let mut obj = args.as_object().cloned().unwrap_or_default();
1923    if let Some(q) = query {
1924        obj.insert("query".to_string(), Value::String(q));
1925    }
1926    obj.insert(
1927        "__query_source".to_string(),
1928        Value::String(query_source.to_string()),
1929    );
1930    Value::Object(obj)
1931}
1932
1933fn extract_websearch_query(args: &Value) -> Option<String> {
1934    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
1935    for key in QUERY_KEYS {
1936        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
1937            let trimmed = value.trim();
1938            if !trimmed.is_empty() {
1939                return Some(trimmed.to_string());
1940            }
1941        }
1942    }
1943    for container in ["arguments", "args", "input", "params"] {
1944        if let Some(obj) = args.get(container) {
1945            for key in QUERY_KEYS {
1946                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
1947                    let trimmed = value.trim();
1948                    if !trimmed.is_empty() {
1949                        return Some(trimmed.to_string());
1950                    }
1951                }
1952            }
1953        }
1954    }
1955    args.as_str()
1956        .map(str::trim)
1957        .filter(|s| !s.is_empty())
1958        .map(ToString::to_string)
1959}
1960
1961fn infer_websearch_query_from_text(text: &str) -> Option<String> {
1962    let trimmed = text.trim();
1963    if trimmed.is_empty() {
1964        return None;
1965    }
1966
1967    let lower = trimmed.to_lowercase();
1968    const PREFIXES: [&str; 11] = [
1969        "web search",
1970        "websearch",
1971        "search web for",
1972        "search web",
1973        "search for",
1974        "search",
1975        "look up",
1976        "lookup",
1977        "find",
1978        "web lookup",
1979        "query",
1980    ];
1981
1982    let mut candidate = trimmed;
1983    for prefix in PREFIXES {
1984        if lower.starts_with(prefix) && lower.len() >= prefix.len() {
1985            let remainder = trimmed[prefix.len()..]
1986                .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
1987            candidate = remainder;
1988            break;
1989        }
1990    }
1991
1992    let normalized = candidate
1993        .trim()
1994        .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
1995        .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
1996        .trim()
1997        .to_string();
1998
1999    if normalized.split_whitespace().count() < 2 {
2000        return None;
2001    }
2002    Some(normalized)
2003}
2004
2005fn infer_file_path_from_text(text: &str) -> Option<String> {
2006    let trimmed = text.trim();
2007    if trimmed.is_empty() {
2008        return None;
2009    }
2010
2011    let mut candidates: Vec<String> = Vec::new();
2012
2013    // Prefer backtick-delimited paths when available.
2014    let mut in_tick = false;
2015    let mut tick_buf = String::new();
2016    for ch in trimmed.chars() {
2017        if ch == '`' {
2018            if in_tick {
2019                let cand = sanitize_path_candidate(&tick_buf);
2020                if let Some(path) = cand {
2021                    candidates.push(path);
2022                }
2023                tick_buf.clear();
2024            }
2025            in_tick = !in_tick;
2026            continue;
2027        }
2028        if in_tick {
2029            tick_buf.push(ch);
2030        }
2031    }
2032
2033    // Fallback: scan whitespace tokens.
2034    for raw in trimmed.split_whitespace() {
2035        if let Some(path) = sanitize_path_candidate(raw) {
2036            candidates.push(path);
2037        }
2038    }
2039
2040    let mut deduped = Vec::new();
2041    let mut seen = HashSet::new();
2042    for candidate in candidates {
2043        if seen.insert(candidate.clone()) {
2044            deduped.push(candidate);
2045        }
2046    }
2047
2048    deduped.into_iter().next()
2049}
2050
2051fn sanitize_path_candidate(raw: &str) -> Option<String> {
2052    let token = raw
2053        .trim()
2054        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2055        .trim_start_matches(['(', '[', '{', '<'])
2056        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2057        .trim_end_matches('.')
2058        .trim();
2059
2060    if token.is_empty() {
2061        return None;
2062    }
2063    let lower = token.to_ascii_lowercase();
2064    if lower.starts_with("http://") || lower.starts_with("https://") {
2065        return None;
2066    }
2067    if is_malformed_tool_path_token(token) {
2068        return None;
2069    }
2070    if is_root_only_path_token(token) {
2071        return None;
2072    }
2073
2074    let looks_like_path = token.contains('/') || token.contains('\\');
2075    let has_file_ext = [
2076        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
2077        ".py", ".go", ".java", ".cpp", ".c", ".h",
2078    ]
2079    .iter()
2080    .any(|ext| lower.ends_with(ext));
2081
2082    if !looks_like_path && !has_file_ext {
2083        return None;
2084    }
2085
2086    Some(token.to_string())
2087}
2088
2089fn is_malformed_tool_path_token(token: &str) -> bool {
2090    let lower = token.to_ascii_lowercase();
2091    // XML-ish tool-call wrappers emitted by some model responses.
2092    if lower.contains("<tool_call")
2093        || lower.contains("</tool_call")
2094        || lower.contains("<function=")
2095        || lower.contains("<parameter=")
2096        || lower.contains("</function>")
2097        || lower.contains("</parameter>")
2098    {
2099        return true;
2100    }
2101    // Multiline payloads are not valid single file paths.
2102    if token.contains('\n') || token.contains('\r') {
2103        return true;
2104    }
2105    // Glob patterns are not concrete file paths for read/write/edit.
2106    if token.contains('*') || token.contains('?') {
2107        return true;
2108    }
2109    false
2110}
2111
2112fn is_root_only_path_token(token: &str) -> bool {
2113    let trimmed = token.trim();
2114    if trimmed.is_empty() {
2115        return true;
2116    }
2117    if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
2118        return true;
2119    }
2120    // Windows drive root placeholders, e.g. `C:` or `C:\`.
2121    let bytes = trimmed.as_bytes();
2122    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
2123        return true;
2124    }
2125    if bytes.len() == 3
2126        && bytes[1] == b':'
2127        && (bytes[0] as char).is_ascii_alphabetic()
2128        && (bytes[2] == b'\\' || bytes[2] == b'/')
2129    {
2130        return true;
2131    }
2132    false
2133}
2134
2135fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
2136    let token = raw
2137        .trim()
2138        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
2139        .trim();
2140    if token.is_empty() {
2141        return None;
2142    }
2143    Some(token.to_string())
2144}
2145
2146fn looks_like_shell_command(candidate: &str) -> bool {
2147    let lower = candidate.to_ascii_lowercase();
2148    if lower.is_empty() {
2149        return false;
2150    }
2151    let first = lower.split_whitespace().next().unwrap_or_default();
2152    let common = [
2153        "rg",
2154        "git",
2155        "cargo",
2156        "pnpm",
2157        "npm",
2158        "node",
2159        "python",
2160        "pytest",
2161        "pwsh",
2162        "powershell",
2163        "cmd",
2164        "dir",
2165        "ls",
2166        "cat",
2167        "type",
2168        "echo",
2169        "cd",
2170        "mkdir",
2171        "cp",
2172        "copy",
2173        "move",
2174        "del",
2175        "rm",
2176    ];
2177    common.contains(&first)
2178        || first.starts_with("get-")
2179        || first.starts_with("./")
2180        || first.starts_with(".\\")
2181        || lower.contains(" | ")
2182        || lower.contains(" && ")
2183        || lower.contains(" ; ")
2184}
2185
2186const FILE_PATH_KEYS: [&str; 10] = [
2187    "path",
2188    "file_path",
2189    "filePath",
2190    "filepath",
2191    "filename",
2192    "file",
2193    "target",
2194    "targetFile",
2195    "absolutePath",
2196    "uri",
2197];
2198
2199const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
2200
2201const WRITE_CONTENT_KEYS: [&str; 8] = [
2202    "content",
2203    "text",
2204    "body",
2205    "value",
2206    "markdown",
2207    "document",
2208    "output",
2209    "file_content",
2210];
2211
2212const NESTED_ARGS_KEYS: [&str; 10] = [
2213    "arguments",
2214    "args",
2215    "input",
2216    "params",
2217    "payload",
2218    "data",
2219    "tool_input",
2220    "toolInput",
2221    "tool_args",
2222    "toolArgs",
2223];
2224
2225fn tool_signature(tool_name: &str, args: &Value) -> String {
2226    let normalized = normalize_tool_name(tool_name);
2227    if normalized == "websearch" {
2228        let query = extract_websearch_query(args)
2229            .unwrap_or_default()
2230            .to_lowercase();
2231        let limit = args
2232            .get("limit")
2233            .or_else(|| args.get("numResults"))
2234            .or_else(|| args.get("num_results"))
2235            .and_then(|v| v.as_u64())
2236            .unwrap_or(8);
2237        let domains = args
2238            .get("domains")
2239            .or_else(|| args.get("domain"))
2240            .map(|v| v.to_string())
2241            .unwrap_or_default();
2242        let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
2243        return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
2244    }
2245    format!("{}:{}", normalized, args)
2246}
2247
2248fn stable_hash(input: &str) -> String {
2249    let mut hasher = DefaultHasher::new();
2250    input.hash(&mut hasher);
2251    format!("{:016x}", hasher.finish())
2252}
2253
2254fn summarize_tool_outputs(outputs: &[String]) -> String {
2255    outputs
2256        .iter()
2257        .take(6)
2258        .map(|output| truncate_text(output, 600))
2259        .collect::<Vec<_>>()
2260        .join("\n\n")
2261}
2262
2263fn is_os_mismatch_tool_output(output: &str) -> bool {
2264    let lower = output.to_ascii_lowercase();
2265    lower.contains("os error 3")
2266        || lower.contains("system cannot find the path specified")
2267        || lower.contains("command not found")
2268        || lower.contains("is not recognized as an internal or external command")
2269        || lower.contains("shell command blocked on windows")
2270}
2271
2272fn tandem_runtime_system_prompt(host: &HostRuntimeContext) -> String {
2273    let mut sections = Vec::new();
2274    if os_aware_prompts_enabled() {
2275        sections.push(format!(
2276            "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
2277            host_os_label(host.os),
2278            shell_family_label(host.shell_family),
2279            path_style_label(host.path_style),
2280            host.arch
2281        ));
2282    }
2283    sections.push(
2284        "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
2285Use tool calls to inspect and modify the workspace when needed instead of asking the user
2286to manually run basic discovery steps. Permission prompts may occur for some tools; if
2287a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
2288            .to_string(),
2289    );
2290    if host.os == HostOs::Windows {
2291        sections.push(
2292            "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
2293Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
2294If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
2295                .to_string(),
2296        );
2297    } else {
2298        sections.push(
2299            "POSIX guidance: standard shell commands are available.
2300Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
2301                .to_string(),
2302        );
2303    }
2304    sections.join("\n\n")
2305}
2306
2307fn os_aware_prompts_enabled() -> bool {
2308    std::env::var("TANDEM_OS_AWARE_PROMPTS")
2309        .ok()
2310        .map(|v| {
2311            let normalized = v.trim().to_ascii_lowercase();
2312            !(normalized == "0" || normalized == "false" || normalized == "off")
2313        })
2314        .unwrap_or(true)
2315}
2316
2317fn host_os_label(os: HostOs) -> &'static str {
2318    match os {
2319        HostOs::Windows => "windows",
2320        HostOs::Linux => "linux",
2321        HostOs::Macos => "macos",
2322    }
2323}
2324
2325fn shell_family_label(shell: ShellFamily) -> &'static str {
2326    match shell {
2327        ShellFamily::Powershell => "powershell",
2328        ShellFamily::Posix => "posix",
2329    }
2330}
2331
2332fn path_style_label(path_style: PathStyle) -> &'static str {
2333    match path_style {
2334        PathStyle::Windows => "windows",
2335        PathStyle::Posix => "posix",
2336    }
2337}
2338
2339fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
2340    let user = user_text.to_lowercase();
2341    let reply = completion.to_lowercase();
2342
2343    let asked_for_project_context = [
2344        "what is this project",
2345        "what's this project",
2346        "explain this project",
2347        "analyze this project",
2348        "inspect this project",
2349        "look at the project",
2350        "use glob",
2351        "run glob",
2352    ]
2353    .iter()
2354    .any(|needle| user.contains(needle));
2355
2356    if !asked_for_project_context {
2357        return false;
2358    }
2359
2360    let assistant_claimed_no_access = [
2361        "can't inspect",
2362        "cannot inspect",
2363        "don't have visibility",
2364        "haven't been able to inspect",
2365        "i don't know what this project is",
2366        "need your help to",
2367        "sandbox",
2368        "system restriction",
2369    ]
2370    .iter()
2371    .any(|needle| reply.contains(needle));
2372
2373    // If the user is explicitly asking for project inspection and the model replies with
2374    // a no-access narrative instead of making a tool call, force a minimal read-only probe.
2375    asked_for_project_context && assistant_claimed_no_access
2376}
2377
2378fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
2379    let raw = input.trim();
2380    if !raw.starts_with("/tool ") {
2381        return None;
2382    }
2383    let rest = raw.trim_start_matches("/tool ").trim();
2384    let mut split = rest.splitn(2, ' ');
2385    let tool = normalize_tool_name(split.next()?.trim());
2386    let args = split
2387        .next()
2388        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
2389        .unwrap_or_else(|| json!({}));
2390    Some((tool, args))
2391}
2392
2393fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
2394    let trimmed = input.trim();
2395    if trimmed.is_empty() {
2396        return Vec::new();
2397    }
2398
2399    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
2400        if let Some(found) = extract_tool_call_from_value(&parsed) {
2401            return vec![found];
2402        }
2403    }
2404
2405    if let Some(block) = extract_first_json_object(trimmed) {
2406        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
2407            if let Some(found) = extract_tool_call_from_value(&parsed) {
2408                return vec![found];
2409            }
2410        }
2411    }
2412
2413    parse_function_style_tool_calls(trimmed)
2414}
2415
2416#[cfg(test)]
2417fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
2418    parse_tool_invocations_from_response(input)
2419        .into_iter()
2420        .next()
2421}
2422
2423fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
2424    let mut calls = Vec::new();
2425    let lower = input.to_lowercase();
2426    let names = [
2427        "todo_write",
2428        "todowrite",
2429        "update_todo_list",
2430        "update_todos",
2431    ];
2432    let mut cursor = 0usize;
2433
2434    while cursor < lower.len() {
2435        let mut best: Option<(usize, &str)> = None;
2436        for name in names {
2437            let needle = format!("{name}(");
2438            if let Some(rel_idx) = lower[cursor..].find(&needle) {
2439                let idx = cursor + rel_idx;
2440                if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
2441                    best = Some((idx, name));
2442                }
2443            }
2444        }
2445
2446        let Some((tool_start, tool_name)) = best else {
2447            break;
2448        };
2449
2450        let open_paren = tool_start + tool_name.len();
2451        if let Some(close_paren) = find_matching_paren(input, open_paren) {
2452            if let Some(args_text) = input.get(open_paren + 1..close_paren) {
2453                let args = parse_function_style_args(args_text.trim());
2454                calls.push((normalize_tool_name(tool_name), Value::Object(args)));
2455            }
2456            cursor = close_paren.saturating_add(1);
2457        } else {
2458            cursor = tool_start.saturating_add(tool_name.len());
2459        }
2460    }
2461
2462    calls
2463}
2464
2465fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
2466    if input.as_bytes().get(open_paren).copied()? != b'(' {
2467        return None;
2468    }
2469
2470    let mut depth = 0usize;
2471    let mut in_single = false;
2472    let mut in_double = false;
2473    let mut escaped = false;
2474
2475    for (offset, ch) in input.get(open_paren..)?.char_indices() {
2476        if escaped {
2477            escaped = false;
2478            continue;
2479        }
2480        if ch == '\\' && (in_single || in_double) {
2481            escaped = true;
2482            continue;
2483        }
2484        if ch == '\'' && !in_double {
2485            in_single = !in_single;
2486            continue;
2487        }
2488        if ch == '"' && !in_single {
2489            in_double = !in_double;
2490            continue;
2491        }
2492        if in_single || in_double {
2493            continue;
2494        }
2495
2496        match ch {
2497            '(' => depth += 1,
2498            ')' => {
2499                depth = depth.saturating_sub(1);
2500                if depth == 0 {
2501                    return Some(open_paren + offset);
2502                }
2503            }
2504            _ => {}
2505        }
2506    }
2507
2508    None
2509}
2510
2511fn parse_function_style_args(input: &str) -> Map<String, Value> {
2512    let mut args = Map::new();
2513    if input.trim().is_empty() {
2514        return args;
2515    }
2516
2517    let mut parts = Vec::<String>::new();
2518    let mut current = String::new();
2519    let mut in_single = false;
2520    let mut in_double = false;
2521    let mut escaped = false;
2522    let mut depth_paren = 0usize;
2523    let mut depth_bracket = 0usize;
2524    let mut depth_brace = 0usize;
2525
2526    for ch in input.chars() {
2527        if escaped {
2528            current.push(ch);
2529            escaped = false;
2530            continue;
2531        }
2532        if ch == '\\' && (in_single || in_double) {
2533            current.push(ch);
2534            escaped = true;
2535            continue;
2536        }
2537        if ch == '\'' && !in_double {
2538            in_single = !in_single;
2539            current.push(ch);
2540            continue;
2541        }
2542        if ch == '"' && !in_single {
2543            in_double = !in_double;
2544            current.push(ch);
2545            continue;
2546        }
2547        if in_single || in_double {
2548            current.push(ch);
2549            continue;
2550        }
2551
2552        match ch {
2553            '(' => depth_paren += 1,
2554            ')' => depth_paren = depth_paren.saturating_sub(1),
2555            '[' => depth_bracket += 1,
2556            ']' => depth_bracket = depth_bracket.saturating_sub(1),
2557            '{' => depth_brace += 1,
2558            '}' => depth_brace = depth_brace.saturating_sub(1),
2559            ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
2560                let part = current.trim();
2561                if !part.is_empty() {
2562                    parts.push(part.to_string());
2563                }
2564                current.clear();
2565                continue;
2566            }
2567            _ => {}
2568        }
2569        current.push(ch);
2570    }
2571    let tail = current.trim();
2572    if !tail.is_empty() {
2573        parts.push(tail.to_string());
2574    }
2575
2576    for part in parts {
2577        let Some((raw_key, raw_value)) = part
2578            .split_once('=')
2579            .or_else(|| part.split_once(':'))
2580            .map(|(k, v)| (k.trim(), v.trim()))
2581        else {
2582            continue;
2583        };
2584        let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
2585        if key.is_empty() {
2586            continue;
2587        }
2588        let value = parse_scalar_like_value(raw_value);
2589        args.insert(key.to_string(), value);
2590    }
2591
2592    args
2593}
2594
2595fn parse_scalar_like_value(raw: &str) -> Value {
2596    let trimmed = raw.trim();
2597    if trimmed.is_empty() {
2598        return Value::Null;
2599    }
2600
2601    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
2602        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
2603    {
2604        return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
2605    }
2606
2607    if trimmed.eq_ignore_ascii_case("true") {
2608        return Value::Bool(true);
2609    }
2610    if trimmed.eq_ignore_ascii_case("false") {
2611        return Value::Bool(false);
2612    }
2613    if trimmed.eq_ignore_ascii_case("null") {
2614        return Value::Null;
2615    }
2616
2617    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
2618        return v;
2619    }
2620    if let Ok(v) = trimmed.parse::<i64>() {
2621        return Value::Number(Number::from(v));
2622    }
2623    if let Ok(v) = trimmed.parse::<f64>() {
2624        if let Some(n) = Number::from_f64(v) {
2625            return Value::Number(n);
2626        }
2627    }
2628
2629    Value::String(trimmed.to_string())
2630}
2631
2632fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
2633    if is_todo_status_update_args(&args) {
2634        return args;
2635    }
2636
2637    let mut obj = match args {
2638        Value::Object(map) => map,
2639        Value::Array(items) => {
2640            return json!({ "todos": normalize_todo_arg_items(items) });
2641        }
2642        Value::String(text) => {
2643            let derived = extract_todo_candidates_from_text(&text);
2644            if !derived.is_empty() {
2645                return json!({ "todos": derived });
2646            }
2647            return json!({});
2648        }
2649        _ => return json!({}),
2650    };
2651
2652    if obj
2653        .get("todos")
2654        .and_then(|v| v.as_array())
2655        .map(|arr| !arr.is_empty())
2656        .unwrap_or(false)
2657    {
2658        return Value::Object(obj);
2659    }
2660
2661    for alias in ["tasks", "items", "list", "checklist"] {
2662        if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
2663            let normalized = normalize_todo_arg_items(items.clone());
2664            if !normalized.is_empty() {
2665                obj.insert("todos".to_string(), Value::Array(normalized));
2666                return Value::Object(obj);
2667            }
2668        }
2669    }
2670
2671    let derived = extract_todo_candidates_from_text(completion);
2672    if !derived.is_empty() {
2673        obj.insert("todos".to_string(), Value::Array(derived));
2674    }
2675    Value::Object(obj)
2676}
2677
2678fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
2679    items
2680        .into_iter()
2681        .filter_map(|item| match item {
2682            Value::String(text) => {
2683                let content = text.trim();
2684                if content.is_empty() {
2685                    None
2686                } else {
2687                    Some(json!({"content": content}))
2688                }
2689            }
2690            Value::Object(mut obj) => {
2691                if !obj.contains_key("content") {
2692                    if let Some(text) = obj.get("text").cloned() {
2693                        obj.insert("content".to_string(), text);
2694                    } else if let Some(title) = obj.get("title").cloned() {
2695                        obj.insert("content".to_string(), title);
2696                    } else if let Some(name) = obj.get("name").cloned() {
2697                        obj.insert("content".to_string(), name);
2698                    }
2699                }
2700                let content = obj
2701                    .get("content")
2702                    .and_then(|v| v.as_str())
2703                    .map(str::trim)
2704                    .unwrap_or("");
2705                if content.is_empty() {
2706                    None
2707                } else {
2708                    Some(Value::Object(obj))
2709                }
2710            }
2711            _ => None,
2712        })
2713        .collect()
2714}
2715
2716fn is_todo_status_update_args(args: &Value) -> bool {
2717    let Some(obj) = args.as_object() else {
2718        return false;
2719    };
2720    let has_status = obj
2721        .get("status")
2722        .and_then(|v| v.as_str())
2723        .map(|s| !s.trim().is_empty())
2724        .unwrap_or(false);
2725    let has_target =
2726        obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
2727    has_status && has_target
2728}
2729
2730fn is_empty_todo_write_args(args: &Value) -> bool {
2731    if is_todo_status_update_args(args) {
2732        return false;
2733    }
2734    let Some(obj) = args.as_object() else {
2735        return true;
2736    };
2737    !obj.get("todos")
2738        .and_then(|v| v.as_array())
2739        .map(|arr| !arr.is_empty())
2740        .unwrap_or(false)
2741}
2742
2743fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
2744    let trimmed = raw_args.trim();
2745    if trimmed.is_empty() {
2746        return json!({});
2747    }
2748
2749    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
2750        return normalize_streamed_tool_args(tool_name, parsed, trimmed);
2751    }
2752
2753    // Some providers emit non-JSON argument text (for example: raw query strings
2754    // or key=value fragments). Recover the common forms instead of dropping to {}.
2755    let kv_args = parse_function_style_args(trimmed);
2756    if !kv_args.is_empty() {
2757        return normalize_streamed_tool_args(tool_name, Value::Object(kv_args), trimmed);
2758    }
2759
2760    if normalize_tool_name(tool_name) == "websearch" {
2761        return json!({ "query": trimmed });
2762    }
2763
2764    json!({})
2765}
2766
2767fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
2768    let normalized_tool = normalize_tool_name(tool_name);
2769    if normalized_tool != "websearch" {
2770        return parsed;
2771    }
2772
2773    match parsed {
2774        Value::Object(mut obj) => {
2775            if !has_websearch_query(&obj) && !raw.trim().is_empty() {
2776                obj.insert("query".to_string(), Value::String(raw.trim().to_string()));
2777            }
2778            Value::Object(obj)
2779        }
2780        Value::String(s) => {
2781            let q = s.trim();
2782            if q.is_empty() {
2783                json!({})
2784            } else {
2785                json!({ "query": q })
2786            }
2787        }
2788        other => other,
2789    }
2790}
2791
2792fn has_websearch_query(obj: &Map<String, Value>) -> bool {
2793    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2794    QUERY_KEYS.iter().any(|key| {
2795        obj.get(*key)
2796            .and_then(|v| v.as_str())
2797            .map(|s| !s.trim().is_empty())
2798            .unwrap_or(false)
2799    })
2800}
2801
2802fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
2803    if let Some(obj) = value.as_object() {
2804        if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
2805            return Some((
2806                normalize_tool_name(tool),
2807                obj.get("args").cloned().unwrap_or_else(|| json!({})),
2808            ));
2809        }
2810
2811        if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
2812            let args = obj
2813                .get("args")
2814                .cloned()
2815                .or_else(|| obj.get("arguments").cloned())
2816                .unwrap_or_else(|| json!({}));
2817            let normalized_tool = normalize_tool_name(tool);
2818            let args = if let Some(raw) = args.as_str() {
2819                parse_streamed_tool_args(&normalized_tool, raw)
2820            } else {
2821                args
2822            };
2823            return Some((normalized_tool, args));
2824        }
2825
2826        for key in [
2827            "tool_call",
2828            "toolCall",
2829            "call",
2830            "function_call",
2831            "functionCall",
2832        ] {
2833            if let Some(nested) = obj.get(key) {
2834                if let Some(found) = extract_tool_call_from_value(nested) {
2835                    return Some(found);
2836                }
2837            }
2838        }
2839    }
2840
2841    if let Some(items) = value.as_array() {
2842        for item in items {
2843            if let Some(found) = extract_tool_call_from_value(item) {
2844                return Some(found);
2845            }
2846        }
2847    }
2848
2849    None
2850}
2851
2852fn extract_first_json_object(input: &str) -> Option<String> {
2853    let mut start = None;
2854    let mut depth = 0usize;
2855    for (idx, ch) in input.char_indices() {
2856        if ch == '{' {
2857            if start.is_none() {
2858                start = Some(idx);
2859            }
2860            depth += 1;
2861        } else if ch == '}' {
2862            if depth == 0 {
2863                continue;
2864            }
2865            depth -= 1;
2866            if depth == 0 {
2867                let begin = start?;
2868                let block = input.get(begin..=idx)?;
2869                return Some(block.to_string());
2870            }
2871        }
2872    }
2873    None
2874}
2875
2876fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
2877    let mut seen = HashSet::<String>::new();
2878    let mut todos = Vec::new();
2879
2880    for raw_line in input.lines() {
2881        let mut line = raw_line.trim();
2882        let mut structured_line = false;
2883        if line.is_empty() {
2884            continue;
2885        }
2886        if line.starts_with("```") {
2887            continue;
2888        }
2889        if line.ends_with(':') {
2890            continue;
2891        }
2892        if let Some(rest) = line
2893            .strip_prefix("- [ ]")
2894            .or_else(|| line.strip_prefix("* [ ]"))
2895            .or_else(|| line.strip_prefix("- [x]"))
2896            .or_else(|| line.strip_prefix("* [x]"))
2897        {
2898            line = rest.trim();
2899            structured_line = true;
2900        } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
2901            line = rest.trim();
2902            structured_line = true;
2903        } else {
2904            let bytes = line.as_bytes();
2905            let mut i = 0usize;
2906            while i < bytes.len() && bytes[i].is_ascii_digit() {
2907                i += 1;
2908            }
2909            if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
2910                line = line[i + 1..].trim();
2911                structured_line = true;
2912            }
2913        }
2914        if !structured_line {
2915            continue;
2916        }
2917
2918        let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
2919        if content.len() < 5 || content.len() > 180 {
2920            continue;
2921        }
2922        let key = content.to_lowercase();
2923        if seen.contains(&key) {
2924            continue;
2925        }
2926        seen.insert(key);
2927        todos.push(json!({ "content": content }));
2928        if todos.len() >= 25 {
2929            break;
2930        }
2931    }
2932
2933    todos
2934}
2935
2936async fn emit_plan_todo_fallback(
2937    storage: std::sync::Arc<Storage>,
2938    bus: &EventBus,
2939    session_id: &str,
2940    message_id: &str,
2941    completion: &str,
2942) {
2943    let todos = extract_todo_candidates_from_text(completion);
2944    if todos.is_empty() {
2945        return;
2946    }
2947
2948    let invoke_part = WireMessagePart::tool_invocation(
2949        session_id,
2950        message_id,
2951        "todo_write",
2952        json!({"todos": todos.clone()}),
2953    );
2954    let call_id = invoke_part.id.clone();
2955    bus.publish(EngineEvent::new(
2956        "message.part.updated",
2957        json!({"part": invoke_part}),
2958    ));
2959
2960    if storage.set_todos(session_id, todos).await.is_err() {
2961        let mut failed_part =
2962            WireMessagePart::tool_result(session_id, message_id, "todo_write", json!(null));
2963        failed_part.id = call_id;
2964        failed_part.state = Some("failed".to_string());
2965        failed_part.error = Some("failed to persist plan todos".to_string());
2966        bus.publish(EngineEvent::new(
2967            "message.part.updated",
2968            json!({"part": failed_part}),
2969        ));
2970        return;
2971    }
2972
2973    let normalized = storage.get_todos(session_id).await;
2974    let mut result_part = WireMessagePart::tool_result(
2975        session_id,
2976        message_id,
2977        "todo_write",
2978        json!({ "todos": normalized }),
2979    );
2980    result_part.id = call_id;
2981    bus.publish(EngineEvent::new(
2982        "message.part.updated",
2983        json!({"part": result_part}),
2984    ));
2985    bus.publish(EngineEvent::new(
2986        "todo.updated",
2987        json!({
2988            "sessionID": session_id,
2989            "todos": normalized
2990        }),
2991    ));
2992}
2993
2994async fn emit_plan_question_fallback(
2995    storage: std::sync::Arc<Storage>,
2996    bus: &EventBus,
2997    session_id: &str,
2998    message_id: &str,
2999    completion: &str,
3000) {
3001    let trimmed = completion.trim();
3002    if trimmed.is_empty() {
3003        return;
3004    }
3005
3006    let hints = extract_todo_candidates_from_text(trimmed)
3007        .into_iter()
3008        .take(6)
3009        .filter_map(|v| {
3010            v.get("content")
3011                .and_then(|c| c.as_str())
3012                .map(ToString::to_string)
3013        })
3014        .collect::<Vec<_>>();
3015
3016    let mut options = hints
3017        .iter()
3018        .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
3019        .collect::<Vec<_>>();
3020    if options.is_empty() {
3021        options = vec![
3022            json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
3023            json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
3024            json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
3025        ];
3026    }
3027
3028    let question_payload = vec![json!({
3029        "header":"Planning Input",
3030        "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
3031        "options": options,
3032        "multiple": true,
3033        "custom": true
3034    })];
3035
3036    let request = storage
3037        .add_question_request(session_id, message_id, question_payload.clone())
3038        .await
3039        .ok();
3040    bus.publish(EngineEvent::new(
3041        "question.asked",
3042        json!({
3043            "id": request
3044                .as_ref()
3045                .map(|req| req.id.clone())
3046                .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3047            "sessionID": session_id,
3048            "messageID": message_id,
3049            "questions": question_payload,
3050            "tool": request.and_then(|req| {
3051                req.tool.map(|tool| {
3052                    json!({
3053                        "callID": tool.call_id,
3054                        "messageID": tool.message_id
3055                    })
3056                })
3057            })
3058        }),
3059    ));
3060}
3061
3062async fn load_chat_history(storage: std::sync::Arc<Storage>, session_id: &str) -> Vec<ChatMessage> {
3063    let Some(session) = storage.get_session(session_id).await else {
3064        return Vec::new();
3065    };
3066    let messages = session
3067        .messages
3068        .into_iter()
3069        .map(|m| {
3070            let role = format!("{:?}", m.role).to_lowercase();
3071            let content = m
3072                .parts
3073                .into_iter()
3074                .map(|part| match part {
3075                    MessagePart::Text { text } => text,
3076                    MessagePart::Reasoning { text } => text,
3077                    MessagePart::ToolInvocation { tool, result, .. } => {
3078                        format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
3079                    }
3080                })
3081                .collect::<Vec<_>>()
3082                .join("\n");
3083            ChatMessage { role, content }
3084        })
3085        .collect::<Vec<_>>();
3086    compact_chat_history(messages)
3087}
3088
3089async fn emit_tool_side_events(
3090    storage: std::sync::Arc<Storage>,
3091    bus: &EventBus,
3092    session_id: &str,
3093    message_id: &str,
3094    tool: &str,
3095    args: &serde_json::Value,
3096    metadata: &serde_json::Value,
3097    workspace_root: Option<&str>,
3098    effective_cwd: Option<&str>,
3099) {
3100    if tool == "todo_write" {
3101        let todos_from_metadata = metadata
3102            .get("todos")
3103            .and_then(|v| v.as_array())
3104            .cloned()
3105            .unwrap_or_default();
3106
3107        if !todos_from_metadata.is_empty() {
3108            let _ = storage.set_todos(session_id, todos_from_metadata).await;
3109        } else {
3110            let current = storage.get_todos(session_id).await;
3111            if let Some(updated) = apply_todo_updates_from_args(current, args) {
3112                let _ = storage.set_todos(session_id, updated).await;
3113            }
3114        }
3115
3116        let normalized = storage.get_todos(session_id).await;
3117        bus.publish(EngineEvent::new(
3118            "todo.updated",
3119            json!({
3120                "sessionID": session_id,
3121                "todos": normalized,
3122                "workspaceRoot": workspace_root,
3123                "effectiveCwd": effective_cwd
3124            }),
3125        ));
3126    }
3127    if tool == "question" {
3128        let questions = metadata
3129            .get("questions")
3130            .and_then(|v| v.as_array())
3131            .cloned()
3132            .unwrap_or_default();
3133        let request = storage
3134            .add_question_request(session_id, message_id, questions.clone())
3135            .await
3136            .ok();
3137        bus.publish(EngineEvent::new(
3138            "question.asked",
3139            json!({
3140                "id": request
3141                    .as_ref()
3142                    .map(|req| req.id.clone())
3143                    .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3144                "sessionID": session_id,
3145                "messageID": message_id,
3146                "questions": questions,
3147                "tool": request.and_then(|req| {
3148                    req.tool.map(|tool| {
3149                        json!({
3150                            "callID": tool.call_id,
3151                            "messageID": tool.message_id
3152                        })
3153                    })
3154                }),
3155                "workspaceRoot": workspace_root,
3156                "effectiveCwd": effective_cwd
3157            }),
3158        ));
3159    }
3160}
3161
3162fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
3163    let obj = args.as_object()?;
3164    let mut todos = current;
3165    let mut changed = false;
3166
3167    if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
3168        for item in items {
3169            let Some(item_obj) = item.as_object() else {
3170                continue;
3171            };
3172            let status = item_obj
3173                .get("status")
3174                .and_then(|v| v.as_str())
3175                .map(normalize_todo_status);
3176            let target = item_obj
3177                .get("task_id")
3178                .or_else(|| item_obj.get("todo_id"))
3179                .or_else(|| item_obj.get("id"));
3180
3181            if let (Some(status), Some(target)) = (status, target) {
3182                changed |= apply_single_todo_status_update(&mut todos, target, &status);
3183            }
3184        }
3185    }
3186
3187    let status = obj
3188        .get("status")
3189        .and_then(|v| v.as_str())
3190        .map(normalize_todo_status);
3191    let target = obj
3192        .get("task_id")
3193        .or_else(|| obj.get("todo_id"))
3194        .or_else(|| obj.get("id"));
3195    if let (Some(status), Some(target)) = (status, target) {
3196        changed |= apply_single_todo_status_update(&mut todos, target, &status);
3197    }
3198
3199    if changed {
3200        Some(todos)
3201    } else {
3202        None
3203    }
3204}
3205
3206fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
3207    let idx_from_value = match target {
3208        Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
3209        Value::String(s) => {
3210            let trimmed = s.trim();
3211            trimmed
3212                .parse::<usize>()
3213                .ok()
3214                .map(|v| v.saturating_sub(1))
3215                .or_else(|| {
3216                    let digits = trimmed
3217                        .chars()
3218                        .rev()
3219                        .take_while(|c| c.is_ascii_digit())
3220                        .collect::<String>()
3221                        .chars()
3222                        .rev()
3223                        .collect::<String>();
3224                    digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
3225                })
3226        }
3227        _ => None,
3228    };
3229
3230    if let Some(idx) = idx_from_value {
3231        if idx < todos.len() {
3232            if let Some(obj) = todos[idx].as_object_mut() {
3233                obj.insert("status".to_string(), Value::String(status.to_string()));
3234                return true;
3235            }
3236        }
3237    }
3238
3239    let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
3240    if let Some(id_target) = id_target {
3241        for todo in todos.iter_mut() {
3242            if let Some(obj) = todo.as_object_mut() {
3243                if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
3244                    obj.insert("status".to_string(), Value::String(status.to_string()));
3245                    return true;
3246                }
3247            }
3248        }
3249    }
3250
3251    false
3252}
3253
3254fn normalize_todo_status(raw: &str) -> String {
3255    match raw.trim().to_lowercase().as_str() {
3256        "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
3257        "done" | "complete" | "completed" => "completed".to_string(),
3258        "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
3259        "open" | "todo" | "pending" => "pending".to_string(),
3260        other => other.to_string(),
3261    }
3262}
3263
3264fn compact_chat_history(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
3265    const MAX_CONTEXT_CHARS: usize = 80_000;
3266    const KEEP_RECENT_MESSAGES: usize = 40;
3267
3268    if messages.len() <= KEEP_RECENT_MESSAGES {
3269        let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
3270        if total_chars <= MAX_CONTEXT_CHARS {
3271            return messages;
3272        }
3273    }
3274
3275    let mut kept = messages;
3276    let mut dropped_count = 0usize;
3277    let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
3278
3279    while kept.len() > KEEP_RECENT_MESSAGES || total_chars > MAX_CONTEXT_CHARS {
3280        if kept.is_empty() {
3281            break;
3282        }
3283        let removed = kept.remove(0);
3284        total_chars = total_chars.saturating_sub(removed.content.len());
3285        dropped_count += 1;
3286    }
3287
3288    if dropped_count > 0 {
3289        kept.insert(
3290            0,
3291            ChatMessage {
3292                role: "system".to_string(),
3293                content: format!(
3294                    "[history compacted: omitted {} older messages to fit context window]",
3295                    dropped_count
3296                ),
3297            },
3298        );
3299    }
3300    kept
3301}
3302
3303#[cfg(test)]
3304mod tests {
3305    use super::*;
3306    use crate::{EventBus, Storage};
3307    use uuid::Uuid;
3308
3309    #[tokio::test]
3310    async fn todo_updated_event_is_normalized() {
3311        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3312        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3313        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3314        let session_id = session.id.clone();
3315        storage.save_session(session).await.expect("save session");
3316
3317        let bus = EventBus::new();
3318        let mut rx = bus.subscribe();
3319        emit_tool_side_events(
3320            storage.clone(),
3321            &bus,
3322            &session_id,
3323            "m1",
3324            "todo_write",
3325            &json!({"todos":[{"content":"ship parity"}]}),
3326            &json!({"todos":[{"content":"ship parity"}]}),
3327            Some("."),
3328            Some("."),
3329        )
3330        .await;
3331
3332        let event = rx.recv().await.expect("event");
3333        assert_eq!(event.event_type, "todo.updated");
3334        let todos = event
3335            .properties
3336            .get("todos")
3337            .and_then(|v| v.as_array())
3338            .cloned()
3339            .unwrap_or_default();
3340        assert_eq!(todos.len(), 1);
3341        assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
3342        assert_eq!(
3343            todos[0].get("content").and_then(|v| v.as_str()),
3344            Some("ship parity")
3345        );
3346        assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
3347    }
3348
3349    #[tokio::test]
3350    async fn question_asked_event_contains_tool_reference() {
3351        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3352        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3353        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3354        let session_id = session.id.clone();
3355        storage.save_session(session).await.expect("save session");
3356
3357        let bus = EventBus::new();
3358        let mut rx = bus.subscribe();
3359        emit_tool_side_events(
3360            storage,
3361            &bus,
3362            &session_id,
3363            "msg-1",
3364            "question",
3365            &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3366            &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3367            Some("."),
3368            Some("."),
3369        )
3370        .await;
3371
3372        let event = rx.recv().await.expect("event");
3373        assert_eq!(event.event_type, "question.asked");
3374        assert_eq!(
3375            event
3376                .properties
3377                .get("sessionID")
3378                .and_then(|v| v.as_str())
3379                .unwrap_or(""),
3380            session_id
3381        );
3382        let tool = event
3383            .properties
3384            .get("tool")
3385            .cloned()
3386            .unwrap_or_else(|| json!({}));
3387        assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
3388        assert_eq!(
3389            tool.get("messageID").and_then(|v| v.as_str()),
3390            Some("msg-1")
3391        );
3392    }
3393
3394    #[test]
3395    fn compact_chat_history_keeps_recent_and_inserts_summary() {
3396        let mut messages = Vec::new();
3397        for i in 0..60 {
3398            messages.push(ChatMessage {
3399                role: "user".to_string(),
3400                content: format!("message-{i}"),
3401            });
3402        }
3403        let compacted = compact_chat_history(messages);
3404        assert!(compacted.len() <= 41);
3405        assert_eq!(compacted[0].role, "system");
3406        assert!(compacted[0].content.contains("history compacted"));
3407        assert!(compacted.iter().any(|m| m.content.contains("message-59")));
3408    }
3409
3410    #[test]
3411    fn extracts_todos_from_checklist_and_numbered_lines() {
3412        let input = r#"
3413Plan:
3414- [ ] Audit current implementation
3415- [ ] Add planner fallback
34161. Add regression test coverage
3417"#;
3418        let todos = extract_todo_candidates_from_text(input);
3419        assert_eq!(todos.len(), 3);
3420        assert_eq!(
3421            todos[0].get("content").and_then(|v| v.as_str()),
3422            Some("Audit current implementation")
3423        );
3424    }
3425
3426    #[test]
3427    fn does_not_extract_todos_from_plain_prose_lines() {
3428        let input = r#"
3429I need more information to proceed.
3430Can you tell me the event size and budget?
3431Once I have that, I can provide a detailed plan.
3432"#;
3433        let todos = extract_todo_candidates_from_text(input);
3434        assert!(todos.is_empty());
3435    }
3436
3437    #[test]
3438    fn parses_wrapped_tool_call_from_markdown_response() {
3439        let input = r#"
3440Here is the tool call:
3441```json
3442{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
3443```
3444"#;
3445        let parsed = parse_tool_invocation_from_response(input).expect("tool call");
3446        assert_eq!(parsed.0, "todo_write");
3447        assert!(parsed.1.get("todos").is_some());
3448    }
3449
3450    #[test]
3451    fn parses_function_style_todowrite_call() {
3452        let input = r#"Status: Completed
3453Call: todowrite(task_id=2, status="completed")"#;
3454        let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
3455        assert_eq!(parsed.0, "todo_write");
3456        assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3457        assert_eq!(
3458            parsed.1.get("status").and_then(|v| v.as_str()),
3459            Some("completed")
3460        );
3461    }
3462
3463    #[test]
3464    fn parses_multiple_function_style_todowrite_calls() {
3465        let input = r#"
3466Call: todowrite(task_id=2, status="completed")
3467Call: todowrite(task_id=3, status="in_progress")
3468"#;
3469        let parsed = parse_tool_invocations_from_response(input);
3470        assert_eq!(parsed.len(), 2);
3471        assert_eq!(parsed[0].0, "todo_write");
3472        assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3473        assert_eq!(
3474            parsed[0].1.get("status").and_then(|v| v.as_str()),
3475            Some("completed")
3476        );
3477        assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
3478        assert_eq!(
3479            parsed[1].1.get("status").and_then(|v| v.as_str()),
3480            Some("in_progress")
3481        );
3482    }
3483
3484    #[test]
3485    fn applies_todo_status_update_from_task_id_args() {
3486        let current = vec![
3487            json!({"id":"todo-1","content":"a","status":"pending"}),
3488            json!({"id":"todo-2","content":"b","status":"pending"}),
3489            json!({"id":"todo-3","content":"c","status":"pending"}),
3490        ];
3491        let updated =
3492            apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
3493                .expect("status update");
3494        assert_eq!(
3495            updated[1].get("status").and_then(|v| v.as_str()),
3496            Some("completed")
3497        );
3498    }
3499
3500    #[test]
3501    fn normalizes_todo_write_tasks_alias() {
3502        let normalized = normalize_todo_write_args(
3503            json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
3504            "",
3505        );
3506        let todos = normalized
3507            .get("todos")
3508            .and_then(|v| v.as_array())
3509            .cloned()
3510            .unwrap_or_default();
3511        assert_eq!(todos.len(), 2);
3512        assert_eq!(
3513            todos[0].get("content").and_then(|v| v.as_str()),
3514            Some("Book venue")
3515        );
3516        assert_eq!(
3517            todos[1].get("content").and_then(|v| v.as_str()),
3518            Some("Send invites")
3519        );
3520    }
3521
3522    #[test]
3523    fn normalizes_todo_write_from_completion_when_args_empty() {
3524        let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
3525        let normalized = normalize_todo_write_args(json!({}), completion);
3526        let todos = normalized
3527            .get("todos")
3528            .and_then(|v| v.as_array())
3529            .cloned()
3530            .unwrap_or_default();
3531        assert_eq!(todos.len(), 3);
3532        assert!(!is_empty_todo_write_args(&normalized));
3533    }
3534
3535    #[test]
3536    fn empty_todo_write_args_allows_status_updates() {
3537        let args = json!({"task_id": 2, "status":"completed"});
3538        assert!(!is_empty_todo_write_args(&args));
3539    }
3540
3541    #[test]
3542    fn streamed_websearch_args_fallback_to_query_string() {
3543        let parsed = parse_streamed_tool_args("websearch", "meaning of life");
3544        assert_eq!(
3545            parsed.get("query").and_then(|v| v.as_str()),
3546            Some("meaning of life")
3547        );
3548    }
3549
3550    #[test]
3551    fn streamed_websearch_stringified_json_args_are_unwrapped() {
3552        let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
3553        assert_eq!(
3554            parsed.get("query").and_then(|v| v.as_str()),
3555            Some("donkey gestation period")
3556        );
3557    }
3558
3559    #[test]
3560    fn normalize_tool_args_websearch_infers_from_user_text() {
3561        let normalized =
3562            normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
3563        assert_eq!(
3564            normalized.args.get("query").and_then(|v| v.as_str()),
3565            Some("meaning of life")
3566        );
3567        assert_eq!(normalized.args_source, "inferred_from_user");
3568        assert_eq!(normalized.args_integrity, "recovered");
3569    }
3570
3571    #[test]
3572    fn normalize_tool_args_websearch_keeps_existing_query() {
3573        let normalized = normalize_tool_args(
3574            "websearch",
3575            json!({"query":"already set"}),
3576            "web search should not override",
3577            "",
3578        );
3579        assert_eq!(
3580            normalized.args.get("query").and_then(|v| v.as_str()),
3581            Some("already set")
3582        );
3583        assert_eq!(normalized.args_source, "provider_json");
3584        assert_eq!(normalized.args_integrity, "ok");
3585    }
3586
3587    #[test]
3588    fn normalize_tool_args_websearch_fails_when_unrecoverable() {
3589        let normalized = normalize_tool_args("websearch", json!({}), "search", "");
3590        assert!(normalized.query.is_none());
3591        assert!(normalized.missing_terminal);
3592        assert_eq!(normalized.args_source, "missing");
3593        assert_eq!(normalized.args_integrity, "empty");
3594    }
3595
3596    #[test]
3597    fn normalize_tool_args_write_requires_path() {
3598        let normalized = normalize_tool_args("write", json!({}), "", "");
3599        assert!(normalized.missing_terminal);
3600        assert_eq!(
3601            normalized.missing_terminal_reason.as_deref(),
3602            Some("FILE_PATH_MISSING")
3603        );
3604    }
3605
3606    #[test]
3607    fn normalize_tool_args_write_recovers_alias_path_key() {
3608        let normalized = normalize_tool_args(
3609            "write",
3610            json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
3611            "",
3612            "",
3613        );
3614        assert!(!normalized.missing_terminal);
3615        assert_eq!(
3616            normalized.args.get("path").and_then(|v| v.as_str()),
3617            Some("docs/CONCEPT.md")
3618        );
3619        assert_eq!(
3620            normalized.args.get("content").and_then(|v| v.as_str()),
3621            Some("hello")
3622        );
3623    }
3624
3625    #[test]
3626    fn normalize_tool_args_read_infers_path_from_user_prompt() {
3627        let normalized = normalize_tool_args(
3628            "read",
3629            json!({}),
3630            "Please inspect `FEATURE_LIST.md` and summarize key sections.",
3631            "",
3632        );
3633        assert!(!normalized.missing_terminal);
3634        assert_eq!(
3635            normalized.args.get("path").and_then(|v| v.as_str()),
3636            Some("FEATURE_LIST.md")
3637        );
3638        assert_eq!(normalized.args_source, "inferred_from_user");
3639        assert_eq!(normalized.args_integrity, "recovered");
3640    }
3641
3642    #[test]
3643    fn normalize_tool_args_read_infers_path_from_assistant_context() {
3644        let normalized = normalize_tool_args(
3645            "read",
3646            json!({}),
3647            "generic instruction",
3648            "I will read src-tauri/src/orchestrator/engine.rs first.",
3649        );
3650        assert!(!normalized.missing_terminal);
3651        assert_eq!(
3652            normalized.args.get("path").and_then(|v| v.as_str()),
3653            Some("src-tauri/src/orchestrator/engine.rs")
3654        );
3655        assert_eq!(normalized.args_source, "inferred_from_context");
3656        assert_eq!(normalized.args_integrity, "recovered");
3657    }
3658
3659    #[test]
3660    fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
3661        let normalized = normalize_tool_args(
3662            "write",
3663            json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
3664            "",
3665            "",
3666        );
3667        assert!(!normalized.missing_terminal);
3668        assert_eq!(
3669            normalized.args.get("path").and_then(|v| v.as_str()),
3670            Some("docs/CONCEPT.md")
3671        );
3672    }
3673
3674    #[test]
3675    fn normalize_tool_args_write_recovers_content_alias() {
3676        let normalized = normalize_tool_args(
3677            "write",
3678            json!({"path":"docs/FEATURES.md","body":"feature notes"}),
3679            "",
3680            "",
3681        );
3682        assert!(!normalized.missing_terminal);
3683        assert_eq!(
3684            normalized.args.get("content").and_then(|v| v.as_str()),
3685            Some("feature notes")
3686        );
3687    }
3688
3689    #[test]
3690    fn normalize_tool_args_write_fails_when_content_missing() {
3691        let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
3692        assert!(normalized.missing_terminal);
3693        assert_eq!(
3694            normalized.missing_terminal_reason.as_deref(),
3695            Some("WRITE_CONTENT_MISSING")
3696        );
3697    }
3698
3699    #[test]
3700    fn normalize_tool_args_write_recovers_raw_nested_string_content() {
3701        let normalized = normalize_tool_args(
3702            "write",
3703            json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
3704            "",
3705            "",
3706        );
3707        assert!(!normalized.missing_terminal);
3708        assert_eq!(
3709            normalized.args.get("path").and_then(|v| v.as_str()),
3710            Some("docs/FEATURES.md")
3711        );
3712        assert_eq!(
3713            normalized.args.get("content").and_then(|v| v.as_str()),
3714            Some("Line 1\nLine 2")
3715        );
3716    }
3717
3718    #[test]
3719    fn normalize_tool_args_write_does_not_treat_path_as_content() {
3720        let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
3721        assert!(normalized.missing_terminal);
3722        assert_eq!(
3723            normalized.missing_terminal_reason.as_deref(),
3724            Some("WRITE_CONTENT_MISSING")
3725        );
3726    }
3727
3728    #[test]
3729    fn normalize_tool_args_read_infers_path_from_bold_markdown() {
3730        let normalized = normalize_tool_args(
3731            "read",
3732            json!({}),
3733            "Please read **FEATURE_LIST.md** and summarize.",
3734            "",
3735        );
3736        assert!(!normalized.missing_terminal);
3737        assert_eq!(
3738            normalized.args.get("path").and_then(|v| v.as_str()),
3739            Some("FEATURE_LIST.md")
3740        );
3741    }
3742
3743    #[test]
3744    fn normalize_tool_args_shell_infers_command_from_user_prompt() {
3745        let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
3746        assert!(!normalized.missing_terminal);
3747        assert_eq!(
3748            normalized.args.get("command").and_then(|v| v.as_str()),
3749            Some("rg -n \"TODO\" .")
3750        );
3751        assert_eq!(normalized.args_source, "inferred_from_user");
3752        assert_eq!(normalized.args_integrity, "recovered");
3753    }
3754
3755    #[test]
3756    fn normalize_tool_args_read_rejects_root_only_path() {
3757        let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
3758        assert!(normalized.missing_terminal);
3759        assert_eq!(
3760            normalized.missing_terminal_reason.as_deref(),
3761            Some("FILE_PATH_MISSING")
3762        );
3763    }
3764
3765    #[test]
3766    fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
3767        let normalized =
3768            normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
3769        assert!(!normalized.missing_terminal);
3770        assert_eq!(
3771            normalized.args.get("path").and_then(|v| v.as_str()),
3772            Some("CONCEPT.md")
3773        );
3774        assert_eq!(normalized.args_source, "inferred_from_user");
3775        assert_eq!(normalized.args_integrity, "recovered");
3776    }
3777
3778    #[test]
3779    fn normalize_tool_args_read_rejects_tool_call_markup_path() {
3780        let normalized = normalize_tool_args(
3781            "read",
3782            json!({
3783                "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
3784            }),
3785            "",
3786            "",
3787        );
3788        assert!(normalized.missing_terminal);
3789        assert_eq!(
3790            normalized.missing_terminal_reason.as_deref(),
3791            Some("FILE_PATH_MISSING")
3792        );
3793    }
3794
3795    #[test]
3796    fn normalize_tool_args_read_rejects_glob_pattern_path() {
3797        let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
3798        assert!(normalized.missing_terminal);
3799        assert_eq!(
3800            normalized.missing_terminal_reason.as_deref(),
3801            Some("FILE_PATH_MISSING")
3802        );
3803    }
3804
3805    #[test]
3806    fn normalize_tool_name_strips_default_api_namespace() {
3807        assert_eq!(normalize_tool_name("default_api:read"), "read");
3808        assert_eq!(normalize_tool_name("functions.shell"), "bash");
3809    }
3810
3811    #[test]
3812    fn batch_helpers_use_name_when_tool_is_wrapper() {
3813        let args = json!({
3814            "tool_calls":[
3815                {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
3816                {"tool":"default_api:glob","args":{"pattern":"*.md"}}
3817            ]
3818        });
3819        let calls = extract_batch_calls(&args);
3820        assert_eq!(calls.len(), 2);
3821        assert_eq!(calls[0].0, "read");
3822        assert_eq!(calls[1].0, "glob");
3823        assert!(is_read_only_batch_call(&args));
3824        let sig = batch_tool_signature(&args).unwrap_or_default();
3825        assert!(sig.contains("read:"));
3826        assert!(sig.contains("glob:"));
3827    }
3828
3829    #[test]
3830    fn batch_helpers_resolve_nested_function_name() {
3831        let args = json!({
3832            "tool_calls":[
3833                {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
3834            ]
3835        });
3836        let calls = extract_batch_calls(&args);
3837        assert_eq!(calls.len(), 1);
3838        assert_eq!(calls[0].0, "read");
3839        assert!(is_read_only_batch_call(&args));
3840    }
3841
3842    #[test]
3843    fn batch_output_classifier_detects_non_productive_unknown_results() {
3844        let output = r#"
3845[
3846  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
3847  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
3848]
3849"#;
3850        assert!(is_non_productive_batch_output(output));
3851    }
3852
3853    #[test]
3854    fn runtime_prompt_includes_execution_environment_block() {
3855        let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {
3856            os: HostOs::Windows,
3857            arch: "x86_64".to_string(),
3858            shell_family: ShellFamily::Powershell,
3859            path_style: PathStyle::Windows,
3860        });
3861        assert!(prompt.contains("[Execution Environment]"));
3862        assert!(prompt.contains("Host OS: windows"));
3863        assert!(prompt.contains("Shell: powershell"));
3864        assert!(prompt.contains("Path style: windows"));
3865    }
3866}
3867
3868