Skip to main content

tandem_core/
engine_loop.rs

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