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                obj.insert(
1014                    "__session_id".to_string(),
1015                    Value::String(session_id.to_string()),
1016                );
1017            }
1018            tracing::info!(
1019                "tool execution context session_id={} tool={} workspace_root={} effective_cwd={}",
1020                session_id,
1021                tool,
1022                workspace_root,
1023                effective_cwd
1024            );
1025        }
1026        let mut invoke_part =
1027            WireMessagePart::tool_invocation(session_id, message_id, tool.clone(), args.clone());
1028        if let Some(call_id) = tool_call_id.clone() {
1029            invoke_part.id = Some(call_id);
1030        }
1031        let invoke_part_id = invoke_part.id.clone();
1032        self.event_bus.publish(EngineEvent::new(
1033            "message.part.updated",
1034            json!({"part": invoke_part}),
1035        ));
1036        let args_for_side_events = args.clone();
1037        if tool == "spawn_agent" {
1038            let hook = self.spawn_agent_hook.read().await.clone();
1039            if let Some(hook) = hook {
1040                let spawned = hook
1041                    .spawn_agent(SpawnAgentToolContext {
1042                        session_id: session_id.to_string(),
1043                        message_id: message_id.to_string(),
1044                        tool_call_id: invoke_part_id.clone(),
1045                        args: args_for_side_events.clone(),
1046                    })
1047                    .await?;
1048                let output = self.plugins.transform_tool_output(spawned.output).await;
1049                let output = truncate_text(&output, 16_000);
1050                emit_tool_side_events(
1051                    self.storage.clone(),
1052                    &self.event_bus,
1053                    ToolSideEventContext {
1054                        session_id,
1055                        message_id,
1056                        tool: &tool,
1057                        args: &args_for_side_events,
1058                        metadata: &spawned.metadata,
1059                        workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1060                        effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1061                    },
1062                )
1063                .await;
1064                let mut result_part = WireMessagePart::tool_result(
1065                    session_id,
1066                    message_id,
1067                    tool.clone(),
1068                    json!(output.clone()),
1069                );
1070                result_part.id = invoke_part_id;
1071                self.event_bus.publish(EngineEvent::new(
1072                    "message.part.updated",
1073                    json!({"part": result_part}),
1074                ));
1075                return Ok(Some(truncate_text(
1076                    &format!("Tool `{tool}` result:\n{output}"),
1077                    16_000,
1078                )));
1079            }
1080            let output = "spawn_agent is unavailable in this runtime (no spawn hook installed).";
1081            let mut failed_part =
1082                WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1083            failed_part.id = invoke_part_id.clone();
1084            failed_part.state = Some("failed".to_string());
1085            failed_part.error = Some(output.to_string());
1086            self.event_bus.publish(EngineEvent::new(
1087                "message.part.updated",
1088                json!({"part": failed_part}),
1089            ));
1090            return Ok(Some(output.to_string()));
1091        }
1092        let result = match self
1093            .tools
1094            .execute_with_cancel(&tool, args, cancel.clone())
1095            .await
1096        {
1097            Ok(result) => result,
1098            Err(err) => {
1099                let mut failed_part =
1100                    WireMessagePart::tool_result(session_id, message_id, tool.clone(), json!(null));
1101                failed_part.id = invoke_part_id.clone();
1102                failed_part.state = Some("failed".to_string());
1103                failed_part.error = Some(err.to_string());
1104                self.event_bus.publish(EngineEvent::new(
1105                    "message.part.updated",
1106                    json!({"part": failed_part}),
1107                ));
1108                return Err(err);
1109            }
1110        };
1111        emit_tool_side_events(
1112            self.storage.clone(),
1113            &self.event_bus,
1114            ToolSideEventContext {
1115                session_id,
1116                message_id,
1117                tool: &tool,
1118                args: &args_for_side_events,
1119                metadata: &result.metadata,
1120                workspace_root: tool_context.as_ref().map(|ctx| ctx.0.as_str()),
1121                effective_cwd: tool_context.as_ref().map(|ctx| ctx.1.as_str()),
1122            },
1123        )
1124        .await;
1125        let output = self.plugins.transform_tool_output(result.output).await;
1126        let output = truncate_text(&output, 16_000);
1127        let mut result_part = WireMessagePart::tool_result(
1128            session_id,
1129            message_id,
1130            tool.clone(),
1131            json!(output.clone()),
1132        );
1133        result_part.id = invoke_part_id;
1134        self.event_bus.publish(EngineEvent::new(
1135            "message.part.updated",
1136            json!({"part": result_part}),
1137        ));
1138        Ok(Some(truncate_text(
1139            &format!("Tool `{tool}` result:\n{output}"),
1140            16_000,
1141        )))
1142    }
1143
1144    async fn find_recent_matching_user_message_id(
1145        &self,
1146        session_id: &str,
1147        text: &str,
1148    ) -> Option<String> {
1149        let session = self.storage.get_session(session_id).await?;
1150        let last = session.messages.last()?;
1151        if !matches!(last.role, MessageRole::User) {
1152            return None;
1153        }
1154        let age_ms = (Utc::now() - last.created_at).num_milliseconds().max(0) as u64;
1155        if age_ms > 10_000 {
1156            return None;
1157        }
1158        let last_text = last
1159            .parts
1160            .iter()
1161            .filter_map(|part| match part {
1162                MessagePart::Text { text } => Some(text.clone()),
1163                _ => None,
1164            })
1165            .collect::<Vec<_>>()
1166            .join("\n");
1167        if last_text == text {
1168            return Some(last.id.clone());
1169        }
1170        None
1171    }
1172
1173    async fn auto_rename_session_from_user_text(&self, session_id: &str, fallback_text: &str) {
1174        let Some(mut session) = self.storage.get_session(session_id).await else {
1175            return;
1176        };
1177        if !title_needs_repair(&session.title) {
1178            return;
1179        }
1180
1181        let first_user_text = session.messages.iter().find_map(|message| {
1182            if !matches!(message.role, MessageRole::User) {
1183                return None;
1184            }
1185            message.parts.iter().find_map(|part| match part {
1186                MessagePart::Text { text } if !text.trim().is_empty() => Some(text.clone()),
1187                _ => None,
1188            })
1189        });
1190
1191        let source = first_user_text.unwrap_or_else(|| fallback_text.to_string());
1192        let Some(title) = derive_session_title_from_prompt(&source, 60) else {
1193            return;
1194        };
1195
1196        session.title = title;
1197        session.time.updated = Utc::now();
1198        let _ = self.storage.save_session(session).await;
1199    }
1200
1201    async fn workspace_sandbox_violation(
1202        &self,
1203        session_id: &str,
1204        tool: &str,
1205        args: &Value,
1206    ) -> Option<String> {
1207        if self.workspace_override_active(session_id).await {
1208            return None;
1209        }
1210        let session = self.storage.get_session(session_id).await?;
1211        let workspace = session
1212            .workspace_root
1213            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1214        let workspace_path = PathBuf::from(&workspace);
1215        let candidate_paths = extract_tool_candidate_paths(tool, args);
1216        if candidate_paths.is_empty() {
1217            if is_shell_tool_name(tool) {
1218                if let Some(command) = extract_shell_command(args) {
1219                    if shell_command_targets_sensitive_path(&command) {
1220                        return Some(format!(
1221                            "Sandbox blocked `{tool}` command targeting sensitive paths."
1222                        ));
1223                    }
1224                }
1225            }
1226            return None;
1227        }
1228        if let Some(sensitive) = candidate_paths.iter().find(|path| {
1229            let raw = Path::new(path);
1230            let resolved = if raw.is_absolute() {
1231                raw.to_path_buf()
1232            } else {
1233                workspace_path.join(raw)
1234            };
1235            is_sensitive_path_candidate(&resolved)
1236        }) {
1237            return Some(format!(
1238                "Sandbox blocked `{tool}` path `{sensitive}` (sensitive path policy)."
1239            ));
1240        }
1241
1242        let outside = candidate_paths.iter().find(|path| {
1243            let raw = Path::new(path);
1244            let resolved = if raw.is_absolute() {
1245                raw.to_path_buf()
1246            } else {
1247                workspace_path.join(raw)
1248            };
1249            !crate::is_within_workspace_root(&resolved, &workspace_path)
1250        })?;
1251        Some(format!(
1252            "Sandbox blocked `{tool}` path `{outside}` (workspace root: `{workspace}`)"
1253        ))
1254    }
1255
1256    async fn resolve_tool_execution_context(&self, session_id: &str) -> Option<(String, String)> {
1257        let session = self.storage.get_session(session_id).await?;
1258        let workspace_root = session
1259            .workspace_root
1260            .or_else(|| crate::normalize_workspace_path(&session.directory))?;
1261        let effective_cwd = if session.directory.trim().is_empty()
1262            || session.directory.trim() == "."
1263        {
1264            workspace_root.clone()
1265        } else {
1266            crate::normalize_workspace_path(&session.directory).unwrap_or(workspace_root.clone())
1267        };
1268        Some((workspace_root, effective_cwd))
1269    }
1270
1271    async fn workspace_override_active(&self, session_id: &str) -> bool {
1272        let now = chrono::Utc::now().timestamp_millis().max(0) as u64;
1273        let mut overrides = self.workspace_overrides.write().await;
1274        overrides.retain(|_, expires_at| *expires_at > now);
1275        overrides
1276            .get(session_id)
1277            .map(|expires_at| *expires_at > now)
1278            .unwrap_or(false)
1279    }
1280
1281    async fn generate_final_narrative_without_tools(
1282        &self,
1283        session_id: &str,
1284        active_agent: &AgentDefinition,
1285        provider_hint: Option<&str>,
1286        model_id: Option<&str>,
1287        cancel: CancellationToken,
1288        tool_outputs: &[String],
1289    ) -> Option<String> {
1290        if cancel.is_cancelled() {
1291            return None;
1292        }
1293        let mut messages = load_chat_history(self.storage.clone(), session_id).await;
1294        let mut system_parts = vec![tandem_runtime_system_prompt(&self.host_runtime_context)];
1295        if let Some(system) = active_agent.system_prompt.as_ref() {
1296            system_parts.push(system.clone());
1297        }
1298        messages.insert(
1299            0,
1300            ChatMessage {
1301                role: "system".to_string(),
1302                content: system_parts.join("\n\n"),
1303            },
1304        );
1305        messages.push(ChatMessage {
1306            role: "user".to_string(),
1307            content: format!(
1308                "Tool observations:\n{}\n\nProvide a direct final answer now. Do not call tools.",
1309                summarize_tool_outputs(tool_outputs)
1310            ),
1311        });
1312        let stream = self
1313            .providers
1314            .stream_for_provider(provider_hint, model_id, messages, None, cancel.clone())
1315            .await
1316            .ok()?;
1317        tokio::pin!(stream);
1318        let mut completion = String::new();
1319        while let Some(chunk) = stream.next().await {
1320            if cancel.is_cancelled() {
1321                return None;
1322            }
1323            match chunk {
1324                Ok(StreamChunk::TextDelta(delta)) => completion.push_str(&delta),
1325                Ok(StreamChunk::Done { .. }) => break,
1326                Ok(_) => {}
1327                Err(_) => return None,
1328            }
1329        }
1330        let completion = truncate_text(&completion, 16_000);
1331        if completion.trim().is_empty() {
1332            None
1333        } else {
1334            Some(completion)
1335        }
1336    }
1337}
1338
1339fn resolve_model_route(
1340    request_model: Option<&ModelSpec>,
1341    session_model: Option<&ModelSpec>,
1342) -> Option<(String, String)> {
1343    fn normalize(spec: &ModelSpec) -> Option<(String, String)> {
1344        let provider_id = spec.provider_id.trim();
1345        let model_id = spec.model_id.trim();
1346        if provider_id.is_empty() || model_id.is_empty() {
1347            return None;
1348        }
1349        Some((provider_id.to_string(), model_id.to_string()))
1350    }
1351
1352    request_model
1353        .and_then(normalize)
1354        .or_else(|| session_model.and_then(normalize))
1355}
1356
1357fn truncate_text(input: &str, max_len: usize) -> String {
1358    if input.len() <= max_len {
1359        return input.to_string();
1360    }
1361    let mut out = input[..max_len].to_string();
1362    out.push_str("...<truncated>");
1363    out
1364}
1365
1366fn provider_error_code(error_text: &str) -> &'static str {
1367    let lower = error_text.to_lowercase();
1368    if lower.contains("invalid_function_parameters")
1369        || lower.contains("array schema missing items")
1370        || lower.contains("tool schema")
1371    {
1372        return "TOOL_SCHEMA_INVALID";
1373    }
1374    if lower.contains("rate limit") || lower.contains("too many requests") || lower.contains("429")
1375    {
1376        return "RATE_LIMIT_EXCEEDED";
1377    }
1378    if lower.contains("context length")
1379        || lower.contains("max tokens")
1380        || lower.contains("token limit")
1381    {
1382        return "CONTEXT_LENGTH_EXCEEDED";
1383    }
1384    if lower.contains("unauthorized")
1385        || lower.contains("authentication")
1386        || lower.contains("401")
1387        || lower.contains("403")
1388    {
1389        return "AUTHENTICATION_ERROR";
1390    }
1391    if lower.contains("timeout") || lower.contains("timed out") {
1392        return "TIMEOUT";
1393    }
1394    if lower.contains("server error")
1395        || lower.contains("500")
1396        || lower.contains("502")
1397        || lower.contains("503")
1398        || lower.contains("504")
1399    {
1400        return "PROVIDER_SERVER_ERROR";
1401    }
1402    "PROVIDER_REQUEST_FAILED"
1403}
1404
1405fn normalize_tool_name(name: &str) -> String {
1406    let mut normalized = name.trim().to_ascii_lowercase().replace('-', "_");
1407    for prefix in [
1408        "default_api:",
1409        "default_api.",
1410        "functions.",
1411        "function.",
1412        "tools.",
1413        "tool.",
1414        "builtin:",
1415        "builtin.",
1416    ] {
1417        if let Some(rest) = normalized.strip_prefix(prefix) {
1418            let trimmed = rest.trim();
1419            if !trimmed.is_empty() {
1420                normalized = trimmed.to_string();
1421                break;
1422            }
1423        }
1424    }
1425    match normalized.as_str() {
1426        "todowrite" | "update_todo_list" | "update_todos" => "todo_write".to_string(),
1427        "run_command" | "shell" | "powershell" | "cmd" => "bash".to_string(),
1428        other => other.to_string(),
1429    }
1430}
1431
1432fn extract_tool_candidate_paths(tool: &str, args: &Value) -> Vec<String> {
1433    let Some(obj) = args.as_object() else {
1434        return Vec::new();
1435    };
1436    let keys: &[&str] = match tool {
1437        "read" | "write" | "edit" | "grep" | "codesearch" => &["path", "filePath", "cwd"],
1438        "glob" => &["pattern"],
1439        "lsp" => &["filePath", "path"],
1440        "bash" => &["cwd"],
1441        "apply_patch" => &[],
1442        _ => &["path", "cwd"],
1443    };
1444    keys.iter()
1445        .filter_map(|key| obj.get(*key))
1446        .filter_map(|value| value.as_str())
1447        .filter(|s| !s.trim().is_empty())
1448        .map(ToString::to_string)
1449        .collect()
1450}
1451
1452fn agent_can_use_tool(agent: &AgentDefinition, tool_name: &str) -> bool {
1453    let target = normalize_tool_name(tool_name);
1454    match agent.tools.as_ref() {
1455        None => true,
1456        Some(list) => list.iter().any(|t| normalize_tool_name(t) == target),
1457    }
1458}
1459
1460fn enforce_skill_scope(
1461    tool_name: &str,
1462    args: Value,
1463    equipped_skills: Option<&[String]>,
1464) -> Result<Value, String> {
1465    if normalize_tool_name(tool_name) != "skill" {
1466        return Ok(args);
1467    }
1468    let Some(configured) = equipped_skills else {
1469        return Ok(args);
1470    };
1471
1472    let mut allowed = configured
1473        .iter()
1474        .map(|s| s.trim().to_string())
1475        .filter(|s| !s.is_empty())
1476        .collect::<Vec<_>>();
1477    if allowed
1478        .iter()
1479        .any(|s| s == "*" || s.eq_ignore_ascii_case("all"))
1480    {
1481        return Ok(args);
1482    }
1483    allowed.sort();
1484    allowed.dedup();
1485    if allowed.is_empty() {
1486        return Err("No skills are equipped for this agent.".to_string());
1487    }
1488
1489    let requested = args
1490        .get("name")
1491        .and_then(|v| v.as_str())
1492        .map(|v| v.trim().to_string())
1493        .unwrap_or_default();
1494    if !requested.is_empty() && !allowed.iter().any(|s| s == &requested) {
1495        return Err(format!(
1496            "Skill '{}' is not equipped for this agent. Equipped skills: {}",
1497            requested,
1498            allowed.join(", ")
1499        ));
1500    }
1501
1502    let mut out = if let Some(obj) = args.as_object() {
1503        Value::Object(obj.clone())
1504    } else {
1505        json!({})
1506    };
1507    if let Some(obj) = out.as_object_mut() {
1508        obj.insert("allowed_skills".to_string(), json!(allowed));
1509    }
1510    Ok(out)
1511}
1512
1513fn is_read_only_tool(tool_name: &str) -> bool {
1514    matches!(
1515        normalize_tool_name(tool_name).as_str(),
1516        "glob"
1517            | "read"
1518            | "grep"
1519            | "search"
1520            | "codesearch"
1521            | "list"
1522            | "ls"
1523            | "lsp"
1524            | "websearch"
1525            | "webfetch"
1526            | "webfetch_html"
1527    )
1528}
1529
1530fn is_batch_wrapper_tool_name(name: &str) -> bool {
1531    matches!(
1532        normalize_tool_name(name).as_str(),
1533        "default_api" | "default" | "api" | "function" | "functions" | "tool" | "tools"
1534    )
1535}
1536
1537fn non_empty_string_at<'a>(obj: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
1538    obj.get(key)
1539        .and_then(|v| v.as_str())
1540        .map(str::trim)
1541        .filter(|s| !s.is_empty())
1542}
1543
1544fn nested_non_empty_string_at<'a>(
1545    obj: &'a Map<String, Value>,
1546    parent: &str,
1547    key: &str,
1548) -> Option<&'a str> {
1549    obj.get(parent)
1550        .and_then(|v| v.as_object())
1551        .and_then(|nested| nested.get(key))
1552        .and_then(|v| v.as_str())
1553        .map(str::trim)
1554        .filter(|s| !s.is_empty())
1555}
1556
1557fn extract_batch_calls(args: &Value) -> Vec<(String, Value)> {
1558    let calls = args
1559        .get("tool_calls")
1560        .and_then(|v| v.as_array())
1561        .cloned()
1562        .unwrap_or_default();
1563    calls
1564        .into_iter()
1565        .filter_map(|call| {
1566            let obj = call.as_object()?;
1567            let tool_raw = non_empty_string_at(obj, "tool")
1568                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"))
1569                .or_else(|| nested_non_empty_string_at(obj, "function", "tool"))
1570                .or_else(|| nested_non_empty_string_at(obj, "function_call", "tool"))
1571                .or_else(|| nested_non_empty_string_at(obj, "call", "tool"));
1572            let name_raw = non_empty_string_at(obj, "name")
1573                .or_else(|| nested_non_empty_string_at(obj, "function", "name"))
1574                .or_else(|| nested_non_empty_string_at(obj, "function_call", "name"))
1575                .or_else(|| nested_non_empty_string_at(obj, "call", "name"))
1576                .or_else(|| nested_non_empty_string_at(obj, "tool", "name"));
1577            let effective = match (tool_raw, name_raw) {
1578                (Some(t), Some(n)) if is_batch_wrapper_tool_name(t) => n,
1579                (Some(t), _) => t,
1580                (None, Some(n)) => n,
1581                (None, None) => return None,
1582            };
1583            let normalized = normalize_tool_name(effective);
1584            let call_args = obj.get("args").cloned().unwrap_or_else(|| json!({}));
1585            Some((normalized, call_args))
1586        })
1587        .collect()
1588}
1589
1590fn is_read_only_batch_call(args: &Value) -> bool {
1591    let calls = extract_batch_calls(args);
1592    !calls.is_empty() && calls.iter().all(|(tool, _)| is_read_only_tool(tool))
1593}
1594
1595fn batch_tool_signature(args: &Value) -> Option<String> {
1596    let calls = extract_batch_calls(args);
1597    if calls.is_empty() {
1598        return None;
1599    }
1600    let parts = calls
1601        .into_iter()
1602        .map(|(tool, call_args)| tool_signature(&tool, &call_args))
1603        .collect::<Vec<_>>();
1604    Some(format!("batch:{}", parts.join("|")))
1605}
1606
1607fn is_non_productive_batch_output(output: &str) -> bool {
1608    let Ok(value) = serde_json::from_str::<Value>(output.trim()) else {
1609        return false;
1610    };
1611    let Some(items) = value.as_array() else {
1612        return false;
1613    };
1614    if items.is_empty() {
1615        return true;
1616    }
1617    items.iter().all(|item| {
1618        let text = item
1619            .get("output")
1620            .and_then(|v| v.as_str())
1621            .map(str::trim)
1622            .unwrap_or_default()
1623            .to_ascii_lowercase();
1624        text.is_empty()
1625            || text.starts_with("unknown tool:")
1626            || text.contains("call skipped")
1627            || text.contains("guard budget exceeded")
1628    })
1629}
1630
1631fn tool_budget_for(tool_name: &str) -> usize {
1632    match normalize_tool_name(tool_name).as_str() {
1633        "glob" => 4,
1634        "read" => 8,
1635        "websearch" => 3,
1636        "batch" => 4,
1637        "grep" | "search" | "codesearch" => 6,
1638        _ => 10,
1639    }
1640}
1641
1642fn is_sensitive_path_candidate(path: &Path) -> bool {
1643    let lowered = path.to_string_lossy().to_ascii_lowercase();
1644    if lowered.contains("/.ssh/")
1645        || lowered.ends_with("/.ssh")
1646        || lowered.contains("/.gnupg/")
1647        || lowered.ends_with("/.gnupg")
1648    {
1649        return true;
1650    }
1651    if lowered.contains("/.aws/credentials")
1652        || lowered.ends_with("/.npmrc")
1653        || lowered.ends_with("/.netrc")
1654        || lowered.ends_with("/.pypirc")
1655    {
1656        return true;
1657    }
1658    if lowered.contains("id_rsa")
1659        || lowered.contains("id_ed25519")
1660        || lowered.contains("id_ecdsa")
1661        || lowered.contains(".pem")
1662        || lowered.contains(".p12")
1663        || lowered.contains(".pfx")
1664        || lowered.contains(".key")
1665    {
1666        return true;
1667    }
1668    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1669        let n = name.to_ascii_lowercase();
1670        if n == ".env" || n.starts_with(".env.") {
1671            return true;
1672        }
1673    }
1674    false
1675}
1676
1677fn shell_command_targets_sensitive_path(command: &str) -> bool {
1678    let lower = command.to_ascii_lowercase();
1679    let patterns = [
1680        ".env",
1681        ".ssh",
1682        ".gnupg",
1683        ".aws/credentials",
1684        "id_rsa",
1685        "id_ed25519",
1686        ".pem",
1687        ".p12",
1688        ".pfx",
1689        ".key",
1690    ];
1691    patterns.iter().any(|p| lower.contains(p))
1692}
1693
1694#[derive(Debug, Clone)]
1695struct NormalizedToolArgs {
1696    args: Value,
1697    args_source: String,
1698    args_integrity: String,
1699    query: Option<String>,
1700    missing_terminal: bool,
1701    missing_terminal_reason: Option<String>,
1702}
1703
1704fn normalize_tool_args(
1705    tool_name: &str,
1706    raw_args: Value,
1707    latest_user_text: &str,
1708    latest_assistant_context: &str,
1709) -> NormalizedToolArgs {
1710    let normalized_tool = normalize_tool_name(tool_name);
1711    let mut args = raw_args;
1712    let mut args_source = if args.is_string() {
1713        "provider_string".to_string()
1714    } else {
1715        "provider_json".to_string()
1716    };
1717    let mut args_integrity = "ok".to_string();
1718    let mut query = None;
1719    let mut missing_terminal = false;
1720    let mut missing_terminal_reason = None;
1721
1722    if normalized_tool == "websearch" {
1723        if let Some(found) = extract_websearch_query(&args) {
1724            query = Some(found);
1725            args = set_websearch_query_and_source(args, query.clone(), "tool_args");
1726        } else if let Some(inferred) = infer_websearch_query_from_text(latest_user_text) {
1727            args_source = "inferred_from_user".to_string();
1728            args_integrity = "recovered".to_string();
1729            query = Some(inferred);
1730            args = set_websearch_query_and_source(args, query.clone(), "inferred_from_user");
1731        } else if let Some(recovered) = infer_websearch_query_from_text(latest_assistant_context) {
1732            args_source = "recovered_from_context".to_string();
1733            args_integrity = "recovered".to_string();
1734            query = Some(recovered);
1735            args = set_websearch_query_and_source(args, query.clone(), "recovered_from_context");
1736        } else {
1737            args_source = "missing".to_string();
1738            args_integrity = "empty".to_string();
1739            missing_terminal = true;
1740            missing_terminal_reason = Some("WEBSEARCH_QUERY_MISSING".to_string());
1741        }
1742    } else if is_shell_tool_name(&normalized_tool) {
1743        if let Some(command) = extract_shell_command(&args) {
1744            args = set_shell_command(args, command);
1745        } else if let Some(inferred) = infer_shell_command_from_text(latest_assistant_context) {
1746            args_source = "inferred_from_context".to_string();
1747            args_integrity = "recovered".to_string();
1748            args = set_shell_command(args, inferred);
1749        } else if let Some(inferred) = infer_shell_command_from_text(latest_user_text) {
1750            args_source = "inferred_from_user".to_string();
1751            args_integrity = "recovered".to_string();
1752            args = set_shell_command(args, inferred);
1753        } else {
1754            args_source = "missing".to_string();
1755            args_integrity = "empty".to_string();
1756            missing_terminal = true;
1757            missing_terminal_reason = Some("BASH_COMMAND_MISSING".to_string());
1758        }
1759    } else if matches!(normalized_tool.as_str(), "read" | "write" | "edit") {
1760        if let Some(path) = extract_file_path_arg(&args) {
1761            args = set_file_path_arg(args, path);
1762        } else if let Some(inferred) = infer_file_path_from_text(latest_user_text) {
1763            args_source = "inferred_from_user".to_string();
1764            args_integrity = "recovered".to_string();
1765            args = set_file_path_arg(args, inferred);
1766        } else {
1767            args_source = "missing".to_string();
1768            args_integrity = "empty".to_string();
1769            missing_terminal = true;
1770            missing_terminal_reason = Some("FILE_PATH_MISSING".to_string());
1771        }
1772
1773        if !missing_terminal && normalized_tool == "write" {
1774            if let Some(content) = extract_write_content_arg(&args) {
1775                args = set_write_content_arg(args, content);
1776            } else {
1777                args_source = "missing".to_string();
1778                args_integrity = "empty".to_string();
1779                missing_terminal = true;
1780                missing_terminal_reason = Some("WRITE_CONTENT_MISSING".to_string());
1781            }
1782        }
1783    } else if matches!(normalized_tool.as_str(), "webfetch" | "webfetch_html") {
1784        if let Some(url) = extract_webfetch_url_arg(&args) {
1785            args = set_webfetch_url_arg(args, url);
1786        } else if let Some(inferred) = infer_url_from_text(latest_assistant_context) {
1787            args_source = "inferred_from_context".to_string();
1788            args_integrity = "recovered".to_string();
1789            args = set_webfetch_url_arg(args, inferred);
1790        } else if let Some(inferred) = infer_url_from_text(latest_user_text) {
1791            args_source = "inferred_from_user".to_string();
1792            args_integrity = "recovered".to_string();
1793            args = set_webfetch_url_arg(args, inferred);
1794        } else {
1795            args_source = "missing".to_string();
1796            args_integrity = "empty".to_string();
1797            missing_terminal = true;
1798            missing_terminal_reason = Some("WEBFETCH_URL_MISSING".to_string());
1799        }
1800    }
1801
1802    NormalizedToolArgs {
1803        args,
1804        args_source,
1805        args_integrity,
1806        query,
1807        missing_terminal,
1808        missing_terminal_reason,
1809    }
1810}
1811
1812fn is_shell_tool_name(tool_name: &str) -> bool {
1813    matches!(
1814        tool_name.trim().to_ascii_lowercase().as_str(),
1815        "bash" | "shell" | "powershell" | "cmd"
1816    )
1817}
1818
1819fn set_file_path_arg(args: Value, path: String) -> Value {
1820    let mut obj = args.as_object().cloned().unwrap_or_default();
1821    obj.insert("path".to_string(), Value::String(path));
1822    Value::Object(obj)
1823}
1824
1825fn set_write_content_arg(args: Value, content: String) -> Value {
1826    let mut obj = args.as_object().cloned().unwrap_or_default();
1827    obj.insert("content".to_string(), Value::String(content));
1828    Value::Object(obj)
1829}
1830
1831fn extract_file_path_arg(args: &Value) -> Option<String> {
1832    extract_file_path_arg_internal(args, 0)
1833}
1834
1835fn extract_write_content_arg(args: &Value) -> Option<String> {
1836    extract_write_content_arg_internal(args, 0)
1837}
1838
1839fn extract_file_path_arg_internal(args: &Value, depth: usize) -> Option<String> {
1840    if depth > 5 {
1841        return None;
1842    }
1843
1844    match args {
1845        Value::String(raw) => {
1846            let trimmed = raw.trim();
1847            if trimmed.is_empty() {
1848                return None;
1849            }
1850            // If the provider sent plain string args, treat it as a path directly.
1851            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1852                return sanitize_path_candidate(trimmed);
1853            }
1854            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1855                return extract_file_path_arg_internal(&parsed, depth + 1);
1856            }
1857            sanitize_path_candidate(trimmed)
1858        }
1859        Value::Array(items) => items
1860            .iter()
1861            .find_map(|item| extract_file_path_arg_internal(item, depth + 1)),
1862        Value::Object(obj) => {
1863            for key in FILE_PATH_KEYS {
1864                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1865                    if let Some(path) = sanitize_path_candidate(raw) {
1866                        return Some(path);
1867                    }
1868                }
1869            }
1870            for container in NESTED_ARGS_KEYS {
1871                if let Some(nested) = obj.get(container) {
1872                    if let Some(path) = extract_file_path_arg_internal(nested, depth + 1) {
1873                        return Some(path);
1874                    }
1875                }
1876            }
1877            None
1878        }
1879        _ => None,
1880    }
1881}
1882
1883fn extract_write_content_arg_internal(args: &Value, depth: usize) -> Option<String> {
1884    if depth > 5 {
1885        return None;
1886    }
1887
1888    match args {
1889        Value::String(raw) => {
1890            let trimmed = raw.trim();
1891            if trimmed.is_empty() {
1892                return None;
1893            }
1894            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1895                return extract_write_content_arg_internal(&parsed, depth + 1);
1896            }
1897            // Some providers collapse args to a plain string. Recover as content only when
1898            // it does not look like a standalone file path token.
1899            if sanitize_path_candidate(trimmed).is_some()
1900                && !trimmed.contains('\n')
1901                && trimmed.split_whitespace().count() <= 3
1902            {
1903                return None;
1904            }
1905            Some(trimmed.to_string())
1906        }
1907        Value::Array(items) => items
1908            .iter()
1909            .find_map(|item| extract_write_content_arg_internal(item, depth + 1)),
1910        Value::Object(obj) => {
1911            for key in WRITE_CONTENT_KEYS {
1912                if let Some(value) = obj.get(key) {
1913                    if let Some(raw) = value.as_str() {
1914                        if !raw.is_empty() {
1915                            return Some(raw.to_string());
1916                        }
1917                    } else if let Some(recovered) =
1918                        extract_write_content_arg_internal(value, depth + 1)
1919                    {
1920                        return Some(recovered);
1921                    }
1922                }
1923            }
1924            for container in NESTED_ARGS_KEYS {
1925                if let Some(nested) = obj.get(container) {
1926                    if let Some(content) = extract_write_content_arg_internal(nested, depth + 1) {
1927                        return Some(content);
1928                    }
1929                }
1930            }
1931            None
1932        }
1933        _ => None,
1934    }
1935}
1936
1937fn set_shell_command(args: Value, command: String) -> Value {
1938    let mut obj = args.as_object().cloned().unwrap_or_default();
1939    obj.insert("command".to_string(), Value::String(command));
1940    Value::Object(obj)
1941}
1942
1943fn extract_shell_command(args: &Value) -> Option<String> {
1944    extract_shell_command_internal(args, 0)
1945}
1946
1947fn extract_shell_command_internal(args: &Value, depth: usize) -> Option<String> {
1948    if depth > 5 {
1949        return None;
1950    }
1951
1952    match args {
1953        Value::String(raw) => {
1954            let trimmed = raw.trim();
1955            if trimmed.is_empty() {
1956                return None;
1957            }
1958            if !(trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('"')) {
1959                return sanitize_shell_command_candidate(trimmed);
1960            }
1961            if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
1962                return extract_shell_command_internal(&parsed, depth + 1);
1963            }
1964            sanitize_shell_command_candidate(trimmed)
1965        }
1966        Value::Array(items) => items
1967            .iter()
1968            .find_map(|item| extract_shell_command_internal(item, depth + 1)),
1969        Value::Object(obj) => {
1970            for key in SHELL_COMMAND_KEYS {
1971                if let Some(raw) = obj.get(key).and_then(|v| v.as_str()) {
1972                    if let Some(command) = sanitize_shell_command_candidate(raw) {
1973                        return Some(command);
1974                    }
1975                }
1976            }
1977            for container in NESTED_ARGS_KEYS {
1978                if let Some(nested) = obj.get(container) {
1979                    if let Some(command) = extract_shell_command_internal(nested, depth + 1) {
1980                        return Some(command);
1981                    }
1982                }
1983            }
1984            None
1985        }
1986        _ => None,
1987    }
1988}
1989
1990fn infer_shell_command_from_text(text: &str) -> Option<String> {
1991    let trimmed = text.trim();
1992    if trimmed.is_empty() {
1993        return None;
1994    }
1995
1996    // Prefer explicit backtick commands first.
1997    let mut in_tick = false;
1998    let mut tick_buf = String::new();
1999    for ch in trimmed.chars() {
2000        if ch == '`' {
2001            if in_tick {
2002                if let Some(candidate) = sanitize_shell_command_candidate(&tick_buf) {
2003                    if looks_like_shell_command(&candidate) {
2004                        return Some(candidate);
2005                    }
2006                }
2007                tick_buf.clear();
2008            }
2009            in_tick = !in_tick;
2010            continue;
2011        }
2012        if in_tick {
2013            tick_buf.push(ch);
2014        }
2015    }
2016
2017    for line in trimmed.lines() {
2018        let line = line.trim();
2019        if line.is_empty() {
2020            continue;
2021        }
2022        let lower = line.to_ascii_lowercase();
2023        for prefix in [
2024            "run ",
2025            "execute ",
2026            "call ",
2027            "use bash ",
2028            "use shell ",
2029            "bash ",
2030            "shell ",
2031            "powershell ",
2032            "pwsh ",
2033        ] {
2034            if lower.starts_with(prefix) {
2035                let candidate = line[prefix.len()..].trim();
2036                if let Some(command) = sanitize_shell_command_candidate(candidate) {
2037                    if looks_like_shell_command(&command) {
2038                        return Some(command);
2039                    }
2040                }
2041            }
2042        }
2043    }
2044
2045    None
2046}
2047
2048fn set_websearch_query_and_source(args: Value, query: Option<String>, query_source: &str) -> Value {
2049    let mut obj = args.as_object().cloned().unwrap_or_default();
2050    if let Some(q) = query {
2051        obj.insert("query".to_string(), Value::String(q));
2052    }
2053    obj.insert(
2054        "__query_source".to_string(),
2055        Value::String(query_source.to_string()),
2056    );
2057    Value::Object(obj)
2058}
2059
2060fn set_webfetch_url_arg(args: Value, url: String) -> Value {
2061    let mut obj = args.as_object().cloned().unwrap_or_default();
2062    obj.insert("url".to_string(), Value::String(url));
2063    Value::Object(obj)
2064}
2065
2066fn extract_webfetch_url_arg(args: &Value) -> Option<String> {
2067    const URL_KEYS: [&str; 5] = ["url", "uri", "link", "href", "target_url"];
2068    for key in URL_KEYS {
2069        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2070            if let Some(url) = sanitize_url_candidate(value) {
2071                return Some(url);
2072            }
2073        }
2074    }
2075    for container in ["arguments", "args", "input", "params"] {
2076        if let Some(obj) = args.get(container) {
2077            for key in URL_KEYS {
2078                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2079                    if let Some(url) = sanitize_url_candidate(value) {
2080                        return Some(url);
2081                    }
2082                }
2083            }
2084        }
2085    }
2086    args.as_str().and_then(sanitize_url_candidate)
2087}
2088
2089fn extract_websearch_query(args: &Value) -> Option<String> {
2090    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
2091    for key in QUERY_KEYS {
2092        if let Some(value) = args.get(key).and_then(|v| v.as_str()) {
2093            if let Some(query) = sanitize_websearch_query_candidate(value) {
2094                return Some(query);
2095            }
2096        }
2097    }
2098    for container in ["arguments", "args", "input", "params"] {
2099        if let Some(obj) = args.get(container) {
2100            for key in QUERY_KEYS {
2101                if let Some(value) = obj.get(key).and_then(|v| v.as_str()) {
2102                    if let Some(query) = sanitize_websearch_query_candidate(value) {
2103                        return Some(query);
2104                    }
2105                }
2106            }
2107        }
2108    }
2109    args.as_str().and_then(sanitize_websearch_query_candidate)
2110}
2111
2112fn sanitize_websearch_query_candidate(raw: &str) -> Option<String> {
2113    let trimmed = raw.trim();
2114    if trimmed.is_empty() {
2115        return None;
2116    }
2117
2118    let lower = trimmed.to_ascii_lowercase();
2119    if let Some(start) = lower.find("<arg_value>") {
2120        let value_start = start + "<arg_value>".len();
2121        let tail = &trimmed[value_start..];
2122        let value = if let Some(end) = tail.to_ascii_lowercase().find("</arg_value>") {
2123            &tail[..end]
2124        } else {
2125            tail
2126        };
2127        let cleaned = value.trim();
2128        if !cleaned.is_empty() {
2129            return Some(cleaned.to_string());
2130        }
2131    }
2132
2133    let without_wrappers = trimmed
2134        .replace("<arg_key>", " ")
2135        .replace("</arg_key>", " ")
2136        .replace("<arg_value>", " ")
2137        .replace("</arg_value>", " ");
2138    let collapsed = without_wrappers
2139        .split_whitespace()
2140        .collect::<Vec<_>>()
2141        .join(" ");
2142    if collapsed.is_empty() {
2143        return None;
2144    }
2145
2146    let collapsed_lower = collapsed.to_ascii_lowercase();
2147    if let Some(rest) = collapsed_lower.strip_prefix("websearch query ") {
2148        let offset = collapsed.len() - rest.len();
2149        let q = collapsed[offset..].trim();
2150        if !q.is_empty() {
2151            return Some(q.to_string());
2152        }
2153    }
2154    if let Some(rest) = collapsed_lower.strip_prefix("query ") {
2155        let offset = collapsed.len() - rest.len();
2156        let q = collapsed[offset..].trim();
2157        if !q.is_empty() {
2158            return Some(q.to_string());
2159        }
2160    }
2161
2162    Some(collapsed)
2163}
2164
2165fn infer_websearch_query_from_text(text: &str) -> Option<String> {
2166    let trimmed = text.trim();
2167    if trimmed.is_empty() {
2168        return None;
2169    }
2170
2171    let lower = trimmed.to_lowercase();
2172    const PREFIXES: [&str; 11] = [
2173        "web search",
2174        "websearch",
2175        "search web for",
2176        "search web",
2177        "search for",
2178        "search",
2179        "look up",
2180        "lookup",
2181        "find",
2182        "web lookup",
2183        "query",
2184    ];
2185
2186    let mut candidate = trimmed;
2187    for prefix in PREFIXES {
2188        if lower.starts_with(prefix) && lower.len() >= prefix.len() {
2189            let remainder = trimmed[prefix.len()..]
2190                .trim_start_matches(|c: char| c.is_whitespace() || c == ':' || c == '-');
2191            candidate = remainder;
2192            break;
2193        }
2194    }
2195
2196    let normalized = candidate
2197        .trim()
2198        .trim_matches(|c: char| c == '"' || c == '\'' || c.is_whitespace())
2199        .trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?'))
2200        .trim()
2201        .to_string();
2202
2203    if normalized.split_whitespace().count() < 2 {
2204        return None;
2205    }
2206    Some(normalized)
2207}
2208
2209fn infer_file_path_from_text(text: &str) -> Option<String> {
2210    let trimmed = text.trim();
2211    if trimmed.is_empty() {
2212        return None;
2213    }
2214
2215    let mut candidates: Vec<String> = Vec::new();
2216
2217    // Prefer backtick-delimited paths when available.
2218    let mut in_tick = false;
2219    let mut tick_buf = String::new();
2220    for ch in trimmed.chars() {
2221        if ch == '`' {
2222            if in_tick {
2223                let cand = sanitize_path_candidate(&tick_buf);
2224                if let Some(path) = cand {
2225                    candidates.push(path);
2226                }
2227                tick_buf.clear();
2228            }
2229            in_tick = !in_tick;
2230            continue;
2231        }
2232        if in_tick {
2233            tick_buf.push(ch);
2234        }
2235    }
2236
2237    // Fallback: scan whitespace tokens.
2238    for raw in trimmed.split_whitespace() {
2239        if let Some(path) = sanitize_path_candidate(raw) {
2240            candidates.push(path);
2241        }
2242    }
2243
2244    let mut deduped = Vec::new();
2245    let mut seen = HashSet::new();
2246    for candidate in candidates {
2247        if seen.insert(candidate.clone()) {
2248            deduped.push(candidate);
2249        }
2250    }
2251
2252    deduped.into_iter().next()
2253}
2254
2255fn infer_url_from_text(text: &str) -> Option<String> {
2256    let trimmed = text.trim();
2257    if trimmed.is_empty() {
2258        return None;
2259    }
2260
2261    let mut candidates: Vec<String> = Vec::new();
2262
2263    // Prefer backtick-delimited URLs when available.
2264    let mut in_tick = false;
2265    let mut tick_buf = String::new();
2266    for ch in trimmed.chars() {
2267        if ch == '`' {
2268            if in_tick {
2269                if let Some(url) = sanitize_url_candidate(&tick_buf) {
2270                    candidates.push(url);
2271                }
2272                tick_buf.clear();
2273            }
2274            in_tick = !in_tick;
2275            continue;
2276        }
2277        if in_tick {
2278            tick_buf.push(ch);
2279        }
2280    }
2281
2282    // Fallback: scan whitespace tokens.
2283    for raw in trimmed.split_whitespace() {
2284        if let Some(url) = sanitize_url_candidate(raw) {
2285            candidates.push(url);
2286        }
2287    }
2288
2289    let mut seen = HashSet::new();
2290    candidates
2291        .into_iter()
2292        .find(|candidate| seen.insert(candidate.clone()))
2293}
2294
2295fn sanitize_url_candidate(raw: &str) -> Option<String> {
2296    let token = raw
2297        .trim()
2298        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2299        .trim_start_matches(['(', '[', '{', '<'])
2300        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2301        .trim_end_matches('.')
2302        .trim();
2303
2304    if token.is_empty() {
2305        return None;
2306    }
2307    let lower = token.to_ascii_lowercase();
2308    if !(lower.starts_with("http://") || lower.starts_with("https://")) {
2309        return None;
2310    }
2311    Some(token.to_string())
2312}
2313
2314fn sanitize_path_candidate(raw: &str) -> Option<String> {
2315    let token = raw
2316        .trim()
2317        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | '*' | '|'))
2318        .trim_start_matches(['(', '[', '{', '<'])
2319        .trim_end_matches([',', ';', ':', ')', ']', '}', '>'])
2320        .trim_end_matches('.')
2321        .trim();
2322
2323    if token.is_empty() {
2324        return None;
2325    }
2326    let lower = token.to_ascii_lowercase();
2327    if lower.starts_with("http://") || lower.starts_with("https://") {
2328        return None;
2329    }
2330    if is_malformed_tool_path_token(token) {
2331        return None;
2332    }
2333    if is_root_only_path_token(token) {
2334        return None;
2335    }
2336    if is_placeholder_path_token(token) {
2337        return None;
2338    }
2339
2340    let looks_like_path = token.contains('/') || token.contains('\\');
2341    let has_file_ext = [
2342        ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".rs", ".ts", ".tsx", ".js", ".jsx",
2343        ".py", ".go", ".java", ".cpp", ".c", ".h", ".pdf", ".docx", ".pptx", ".xlsx", ".rtf",
2344    ]
2345    .iter()
2346    .any(|ext| lower.ends_with(ext));
2347
2348    if !looks_like_path && !has_file_ext {
2349        return None;
2350    }
2351
2352    Some(token.to_string())
2353}
2354
2355fn is_placeholder_path_token(token: &str) -> bool {
2356    let lowered = token.trim().to_ascii_lowercase();
2357    if lowered.is_empty() {
2358        return true;
2359    }
2360    matches!(
2361        lowered.as_str(),
2362        "files/directories"
2363            | "file/directory"
2364            | "relative/or/absolute/path"
2365            | "path/to/file"
2366            | "path/to/your/file"
2367            | "tool/policy"
2368            | "tools/policy"
2369            | "the expected artifact file"
2370            | "workspace/file"
2371    )
2372}
2373
2374fn is_malformed_tool_path_token(token: &str) -> bool {
2375    let lower = token.to_ascii_lowercase();
2376    // XML-ish tool-call wrappers emitted by some model responses.
2377    if lower.contains("<tool_call")
2378        || lower.contains("</tool_call")
2379        || lower.contains("<function=")
2380        || lower.contains("<parameter=")
2381        || lower.contains("</function>")
2382        || lower.contains("</parameter>")
2383    {
2384        return true;
2385    }
2386    // Multiline payloads are not valid single file paths.
2387    if token.contains('\n') || token.contains('\r') {
2388        return true;
2389    }
2390    // Glob patterns are not concrete file paths for read/write/edit.
2391    if token.contains('*') || token.contains('?') {
2392        return true;
2393    }
2394    false
2395}
2396
2397fn is_root_only_path_token(token: &str) -> bool {
2398    let trimmed = token.trim();
2399    if trimmed.is_empty() {
2400        return true;
2401    }
2402    if matches!(trimmed, "/" | "\\" | "." | ".." | "~") {
2403        return true;
2404    }
2405    // Windows drive root placeholders, e.g. `C:` or `C:\`.
2406    let bytes = trimmed.as_bytes();
2407    if bytes.len() == 2 && bytes[1] == b':' && (bytes[0] as char).is_ascii_alphabetic() {
2408        return true;
2409    }
2410    if bytes.len() == 3
2411        && bytes[1] == b':'
2412        && (bytes[0] as char).is_ascii_alphabetic()
2413        && (bytes[2] == b'\\' || bytes[2] == b'/')
2414    {
2415        return true;
2416    }
2417    false
2418}
2419
2420fn sanitize_shell_command_candidate(raw: &str) -> Option<String> {
2421    let token = raw
2422        .trim()
2423        .trim_matches(|c: char| matches!(c, '`' | '"' | '\'' | ',' | ';'))
2424        .trim();
2425    if token.is_empty() {
2426        return None;
2427    }
2428    Some(token.to_string())
2429}
2430
2431fn looks_like_shell_command(candidate: &str) -> bool {
2432    let lower = candidate.to_ascii_lowercase();
2433    if lower.is_empty() {
2434        return false;
2435    }
2436    let first = lower.split_whitespace().next().unwrap_or_default();
2437    let common = [
2438        "rg",
2439        "git",
2440        "cargo",
2441        "pnpm",
2442        "npm",
2443        "node",
2444        "python",
2445        "pytest",
2446        "pwsh",
2447        "powershell",
2448        "cmd",
2449        "dir",
2450        "ls",
2451        "cat",
2452        "type",
2453        "echo",
2454        "cd",
2455        "mkdir",
2456        "cp",
2457        "copy",
2458        "move",
2459        "del",
2460        "rm",
2461    ];
2462    common.contains(&first)
2463        || first.starts_with("get-")
2464        || first.starts_with("./")
2465        || first.starts_with(".\\")
2466        || lower.contains(" | ")
2467        || lower.contains(" && ")
2468        || lower.contains(" ; ")
2469}
2470
2471const FILE_PATH_KEYS: [&str; 10] = [
2472    "path",
2473    "file_path",
2474    "filePath",
2475    "filepath",
2476    "filename",
2477    "file",
2478    "target",
2479    "targetFile",
2480    "absolutePath",
2481    "uri",
2482];
2483
2484const SHELL_COMMAND_KEYS: [&str; 4] = ["command", "cmd", "script", "line"];
2485
2486const WRITE_CONTENT_KEYS: [&str; 8] = [
2487    "content",
2488    "text",
2489    "body",
2490    "value",
2491    "markdown",
2492    "document",
2493    "output",
2494    "file_content",
2495];
2496
2497const NESTED_ARGS_KEYS: [&str; 10] = [
2498    "arguments",
2499    "args",
2500    "input",
2501    "params",
2502    "payload",
2503    "data",
2504    "tool_input",
2505    "toolInput",
2506    "tool_args",
2507    "toolArgs",
2508];
2509
2510fn tool_signature(tool_name: &str, args: &Value) -> String {
2511    let normalized = normalize_tool_name(tool_name);
2512    if normalized == "websearch" {
2513        let query = extract_websearch_query(args)
2514            .unwrap_or_default()
2515            .to_lowercase();
2516        let limit = args
2517            .get("limit")
2518            .or_else(|| args.get("numResults"))
2519            .or_else(|| args.get("num_results"))
2520            .and_then(|v| v.as_u64())
2521            .unwrap_or(8);
2522        let domains = args
2523            .get("domains")
2524            .or_else(|| args.get("domain"))
2525            .map(|v| v.to_string())
2526            .unwrap_or_default();
2527        let recency = args.get("recency").and_then(|v| v.as_u64()).unwrap_or(0);
2528        return format!("websearch:q={query}|limit={limit}|domains={domains}|recency={recency}");
2529    }
2530    format!("{}:{}", normalized, args)
2531}
2532
2533fn stable_hash(input: &str) -> String {
2534    let mut hasher = DefaultHasher::new();
2535    input.hash(&mut hasher);
2536    format!("{:016x}", hasher.finish())
2537}
2538
2539fn summarize_tool_outputs(outputs: &[String]) -> String {
2540    outputs
2541        .iter()
2542        .take(6)
2543        .map(|output| truncate_text(output, 600))
2544        .collect::<Vec<_>>()
2545        .join("\n\n")
2546}
2547
2548fn is_os_mismatch_tool_output(output: &str) -> bool {
2549    let lower = output.to_ascii_lowercase();
2550    lower.contains("os error 3")
2551        || lower.contains("system cannot find the path specified")
2552        || lower.contains("command not found")
2553        || lower.contains("is not recognized as an internal or external command")
2554        || lower.contains("shell command blocked on windows")
2555}
2556
2557fn tandem_runtime_system_prompt(host: &HostRuntimeContext) -> String {
2558    let mut sections = Vec::new();
2559    if os_aware_prompts_enabled() {
2560        sections.push(format!(
2561            "[Execution Environment]\nHost OS: {}\nShell: {}\nPath style: {}\nArchitecture: {}",
2562            host_os_label(host.os),
2563            shell_family_label(host.shell_family),
2564            path_style_label(host.path_style),
2565            host.arch
2566        ));
2567    }
2568    sections.push(
2569        "You are operating inside Tandem (Desktop/TUI) as an engine-backed coding assistant.
2570Use tool calls to inspect and modify the workspace when needed instead of asking the user
2571to manually run basic discovery steps. Permission prompts may occur for some tools; if
2572a tool is denied or blocked, explain what was blocked and suggest a concrete next step."
2573            .to_string(),
2574    );
2575    if host.os == HostOs::Windows {
2576        sections.push(
2577            "Windows guidance: prefer cross-platform tools (`glob`, `grep`, `read`, `write`, `edit`) and PowerShell-native commands.
2578Avoid Unix-only shell syntax (`ls -la`, `find ... -type f`, `cat` pipelines) unless translated.
2579If a shell command fails with a path/shell mismatch, immediately switch to cross-platform tools (`read`, `glob`, `grep`)."
2580                .to_string(),
2581        );
2582    } else {
2583        sections.push(
2584            "POSIX guidance: standard shell commands are available.
2585Use cross-platform tools (`glob`, `grep`, `read`) when they are simpler and safer for codebase exploration."
2586                .to_string(),
2587        );
2588    }
2589    sections.join("\n\n")
2590}
2591
2592fn os_aware_prompts_enabled() -> bool {
2593    std::env::var("TANDEM_OS_AWARE_PROMPTS")
2594        .ok()
2595        .map(|v| {
2596            let normalized = v.trim().to_ascii_lowercase();
2597            !(normalized == "0" || normalized == "false" || normalized == "off")
2598        })
2599        .unwrap_or(true)
2600}
2601
2602fn host_os_label(os: HostOs) -> &'static str {
2603    match os {
2604        HostOs::Windows => "windows",
2605        HostOs::Linux => "linux",
2606        HostOs::Macos => "macos",
2607    }
2608}
2609
2610fn shell_family_label(shell: ShellFamily) -> &'static str {
2611    match shell {
2612        ShellFamily::Powershell => "powershell",
2613        ShellFamily::Posix => "posix",
2614    }
2615}
2616
2617fn path_style_label(path_style: PathStyle) -> &'static str {
2618    match path_style {
2619        PathStyle::Windows => "windows",
2620        PathStyle::Posix => "posix",
2621    }
2622}
2623
2624fn should_force_workspace_probe(user_text: &str, completion: &str) -> bool {
2625    let user = user_text.to_lowercase();
2626    let reply = completion.to_lowercase();
2627
2628    let asked_for_project_context = [
2629        "what is this project",
2630        "what's this project",
2631        "what project is this",
2632        "explain this project",
2633        "analyze this project",
2634        "inspect this project",
2635        "look at the project",
2636        "summarize this project",
2637        "show me this project",
2638        "what files are in",
2639        "show files",
2640        "list files",
2641        "read files",
2642        "browse files",
2643        "use glob",
2644        "run glob",
2645    ]
2646    .iter()
2647    .any(|needle| user.contains(needle));
2648
2649    if !asked_for_project_context {
2650        return false;
2651    }
2652
2653    let assistant_claimed_no_access = [
2654        "can't inspect",
2655        "cannot inspect",
2656        "unable to inspect",
2657        "unable to directly inspect",
2658        "can't access",
2659        "cannot access",
2660        "unable to access",
2661        "can't read files",
2662        "cannot read files",
2663        "unable to read files",
2664        "tool restriction",
2665        "tool restrictions",
2666        "don't have visibility",
2667        "no visibility",
2668        "haven't been able to inspect",
2669        "i don't know what this project is",
2670        "need your help to",
2671        "sandbox",
2672        "restriction",
2673        "system restriction",
2674        "permissions restrictions",
2675    ]
2676    .iter()
2677    .any(|needle| reply.contains(needle));
2678
2679    // If the user is explicitly asking for project inspection and the model replies with
2680    // a no-access narrative instead of making a tool call, force a minimal read-only probe.
2681    asked_for_project_context && assistant_claimed_no_access
2682}
2683
2684fn parse_tool_invocation(input: &str) -> Option<(String, serde_json::Value)> {
2685    let raw = input.trim();
2686    if !raw.starts_with("/tool ") {
2687        return None;
2688    }
2689    let rest = raw.trim_start_matches("/tool ").trim();
2690    let mut split = rest.splitn(2, ' ');
2691    let tool = normalize_tool_name(split.next()?.trim());
2692    let args = split
2693        .next()
2694        .and_then(|v| serde_json::from_str::<serde_json::Value>(v).ok())
2695        .unwrap_or_else(|| json!({}));
2696    Some((tool, args))
2697}
2698
2699fn parse_tool_invocations_from_response(input: &str) -> Vec<(String, serde_json::Value)> {
2700    let trimmed = input.trim();
2701    if trimmed.is_empty() {
2702        return Vec::new();
2703    }
2704
2705    if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(trimmed) {
2706        if let Some(found) = extract_tool_call_from_value(&parsed) {
2707            return vec![found];
2708        }
2709    }
2710
2711    if let Some(block) = extract_first_json_object(trimmed) {
2712        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&block) {
2713            if let Some(found) = extract_tool_call_from_value(&parsed) {
2714                return vec![found];
2715            }
2716        }
2717    }
2718
2719    parse_function_style_tool_calls(trimmed)
2720}
2721
2722#[cfg(test)]
2723fn parse_tool_invocation_from_response(input: &str) -> Option<(String, serde_json::Value)> {
2724    parse_tool_invocations_from_response(input)
2725        .into_iter()
2726        .next()
2727}
2728
2729fn parse_function_style_tool_calls(input: &str) -> Vec<(String, Value)> {
2730    let mut calls = Vec::new();
2731    let lower = input.to_lowercase();
2732    let names = [
2733        "todo_write",
2734        "todowrite",
2735        "update_todo_list",
2736        "update_todos",
2737    ];
2738    let mut cursor = 0usize;
2739
2740    while cursor < lower.len() {
2741        let mut best: Option<(usize, &str)> = None;
2742        for name in names {
2743            let needle = format!("{name}(");
2744            if let Some(rel_idx) = lower[cursor..].find(&needle) {
2745                let idx = cursor + rel_idx;
2746                if best.as_ref().is_none_or(|(best_idx, _)| idx < *best_idx) {
2747                    best = Some((idx, name));
2748                }
2749            }
2750        }
2751
2752        let Some((tool_start, tool_name)) = best else {
2753            break;
2754        };
2755
2756        let open_paren = tool_start + tool_name.len();
2757        if let Some(close_paren) = find_matching_paren(input, open_paren) {
2758            if let Some(args_text) = input.get(open_paren + 1..close_paren) {
2759                let args = parse_function_style_args(args_text.trim());
2760                calls.push((normalize_tool_name(tool_name), Value::Object(args)));
2761            }
2762            cursor = close_paren.saturating_add(1);
2763        } else {
2764            cursor = tool_start.saturating_add(tool_name.len());
2765        }
2766    }
2767
2768    calls
2769}
2770
2771fn find_matching_paren(input: &str, open_paren: usize) -> Option<usize> {
2772    if input.as_bytes().get(open_paren).copied()? != b'(' {
2773        return None;
2774    }
2775
2776    let mut depth = 0usize;
2777    let mut in_single = false;
2778    let mut in_double = false;
2779    let mut escaped = false;
2780
2781    for (offset, ch) in input.get(open_paren..)?.char_indices() {
2782        if escaped {
2783            escaped = false;
2784            continue;
2785        }
2786        if ch == '\\' && (in_single || in_double) {
2787            escaped = true;
2788            continue;
2789        }
2790        if ch == '\'' && !in_double {
2791            in_single = !in_single;
2792            continue;
2793        }
2794        if ch == '"' && !in_single {
2795            in_double = !in_double;
2796            continue;
2797        }
2798        if in_single || in_double {
2799            continue;
2800        }
2801
2802        match ch {
2803            '(' => depth += 1,
2804            ')' => {
2805                depth = depth.saturating_sub(1);
2806                if depth == 0 {
2807                    return Some(open_paren + offset);
2808                }
2809            }
2810            _ => {}
2811        }
2812    }
2813
2814    None
2815}
2816
2817fn parse_function_style_args(input: &str) -> Map<String, Value> {
2818    let mut args = Map::new();
2819    if input.trim().is_empty() {
2820        return args;
2821    }
2822
2823    let mut parts = Vec::<String>::new();
2824    let mut current = String::new();
2825    let mut in_single = false;
2826    let mut in_double = false;
2827    let mut escaped = false;
2828    let mut depth_paren = 0usize;
2829    let mut depth_bracket = 0usize;
2830    let mut depth_brace = 0usize;
2831
2832    for ch in input.chars() {
2833        if escaped {
2834            current.push(ch);
2835            escaped = false;
2836            continue;
2837        }
2838        if ch == '\\' && (in_single || in_double) {
2839            current.push(ch);
2840            escaped = true;
2841            continue;
2842        }
2843        if ch == '\'' && !in_double {
2844            in_single = !in_single;
2845            current.push(ch);
2846            continue;
2847        }
2848        if ch == '"' && !in_single {
2849            in_double = !in_double;
2850            current.push(ch);
2851            continue;
2852        }
2853        if in_single || in_double {
2854            current.push(ch);
2855            continue;
2856        }
2857
2858        match ch {
2859            '(' => depth_paren += 1,
2860            ')' => depth_paren = depth_paren.saturating_sub(1),
2861            '[' => depth_bracket += 1,
2862            ']' => depth_bracket = depth_bracket.saturating_sub(1),
2863            '{' => depth_brace += 1,
2864            '}' => depth_brace = depth_brace.saturating_sub(1),
2865            ',' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => {
2866                let part = current.trim();
2867                if !part.is_empty() {
2868                    parts.push(part.to_string());
2869                }
2870                current.clear();
2871                continue;
2872            }
2873            _ => {}
2874        }
2875        current.push(ch);
2876    }
2877    let tail = current.trim();
2878    if !tail.is_empty() {
2879        parts.push(tail.to_string());
2880    }
2881
2882    for part in parts {
2883        let Some((raw_key, raw_value)) = part
2884            .split_once('=')
2885            .or_else(|| part.split_once(':'))
2886            .map(|(k, v)| (k.trim(), v.trim()))
2887        else {
2888            continue;
2889        };
2890        let key = raw_key.trim_matches(|c| c == '"' || c == '\'' || c == '`');
2891        if key.is_empty() {
2892            continue;
2893        }
2894        let value = parse_scalar_like_value(raw_value);
2895        args.insert(key.to_string(), value);
2896    }
2897
2898    args
2899}
2900
2901fn parse_scalar_like_value(raw: &str) -> Value {
2902    let trimmed = raw.trim();
2903    if trimmed.is_empty() {
2904        return Value::Null;
2905    }
2906
2907    if (trimmed.starts_with('"') && trimmed.ends_with('"'))
2908        || (trimmed.starts_with('\'') && trimmed.ends_with('\''))
2909    {
2910        return Value::String(trimmed[1..trimmed.len().saturating_sub(1)].to_string());
2911    }
2912
2913    if trimmed.eq_ignore_ascii_case("true") {
2914        return Value::Bool(true);
2915    }
2916    if trimmed.eq_ignore_ascii_case("false") {
2917        return Value::Bool(false);
2918    }
2919    if trimmed.eq_ignore_ascii_case("null") {
2920        return Value::Null;
2921    }
2922
2923    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
2924        return v;
2925    }
2926    if let Ok(v) = trimmed.parse::<i64>() {
2927        return Value::Number(Number::from(v));
2928    }
2929    if let Ok(v) = trimmed.parse::<f64>() {
2930        if let Some(n) = Number::from_f64(v) {
2931            return Value::Number(n);
2932        }
2933    }
2934
2935    Value::String(trimmed.to_string())
2936}
2937
2938fn normalize_todo_write_args(args: Value, completion: &str) -> Value {
2939    if is_todo_status_update_args(&args) {
2940        return args;
2941    }
2942
2943    let mut obj = match args {
2944        Value::Object(map) => map,
2945        Value::Array(items) => {
2946            return json!({ "todos": normalize_todo_arg_items(items) });
2947        }
2948        Value::String(text) => {
2949            let derived = extract_todo_candidates_from_text(&text);
2950            if !derived.is_empty() {
2951                return json!({ "todos": derived });
2952            }
2953            return json!({});
2954        }
2955        _ => return json!({}),
2956    };
2957
2958    if obj
2959        .get("todos")
2960        .and_then(|v| v.as_array())
2961        .map(|arr| !arr.is_empty())
2962        .unwrap_or(false)
2963    {
2964        return Value::Object(obj);
2965    }
2966
2967    for alias in ["tasks", "items", "list", "checklist"] {
2968        if let Some(items) = obj.get(alias).and_then(|v| v.as_array()) {
2969            let normalized = normalize_todo_arg_items(items.clone());
2970            if !normalized.is_empty() {
2971                obj.insert("todos".to_string(), Value::Array(normalized));
2972                return Value::Object(obj);
2973            }
2974        }
2975    }
2976
2977    let derived = extract_todo_candidates_from_text(completion);
2978    if !derived.is_empty() {
2979        obj.insert("todos".to_string(), Value::Array(derived));
2980    }
2981    Value::Object(obj)
2982}
2983
2984fn normalize_todo_arg_items(items: Vec<Value>) -> Vec<Value> {
2985    items
2986        .into_iter()
2987        .filter_map(|item| match item {
2988            Value::String(text) => {
2989                let content = text.trim();
2990                if content.is_empty() {
2991                    None
2992                } else {
2993                    Some(json!({"content": content}))
2994                }
2995            }
2996            Value::Object(mut obj) => {
2997                if !obj.contains_key("content") {
2998                    if let Some(text) = obj.get("text").cloned() {
2999                        obj.insert("content".to_string(), text);
3000                    } else if let Some(title) = obj.get("title").cloned() {
3001                        obj.insert("content".to_string(), title);
3002                    } else if let Some(name) = obj.get("name").cloned() {
3003                        obj.insert("content".to_string(), name);
3004                    }
3005                }
3006                let content = obj
3007                    .get("content")
3008                    .and_then(|v| v.as_str())
3009                    .map(str::trim)
3010                    .unwrap_or("");
3011                if content.is_empty() {
3012                    None
3013                } else {
3014                    Some(Value::Object(obj))
3015                }
3016            }
3017            _ => None,
3018        })
3019        .collect()
3020}
3021
3022fn is_todo_status_update_args(args: &Value) -> bool {
3023    let Some(obj) = args.as_object() else {
3024        return false;
3025    };
3026    let has_status = obj
3027        .get("status")
3028        .and_then(|v| v.as_str())
3029        .map(|s| !s.trim().is_empty())
3030        .unwrap_or(false);
3031    let has_target =
3032        obj.get("task_id").is_some() || obj.get("todo_id").is_some() || obj.get("id").is_some();
3033    has_status && has_target
3034}
3035
3036fn is_empty_todo_write_args(args: &Value) -> bool {
3037    if is_todo_status_update_args(args) {
3038        return false;
3039    }
3040    let Some(obj) = args.as_object() else {
3041        return true;
3042    };
3043    !obj.get("todos")
3044        .and_then(|v| v.as_array())
3045        .map(|arr| !arr.is_empty())
3046        .unwrap_or(false)
3047}
3048
3049fn parse_streamed_tool_args(tool_name: &str, raw_args: &str) -> Value {
3050    let trimmed = raw_args.trim();
3051    if trimmed.is_empty() {
3052        return json!({});
3053    }
3054
3055    if let Ok(parsed) = serde_json::from_str::<Value>(trimmed) {
3056        return normalize_streamed_tool_args(tool_name, parsed, trimmed);
3057    }
3058
3059    // Some providers emit non-JSON argument text (for example: raw query strings
3060    // or key=value fragments). Recover the common forms instead of dropping to {}.
3061    let kv_args = parse_function_style_args(trimmed);
3062    if !kv_args.is_empty() {
3063        return normalize_streamed_tool_args(tool_name, Value::Object(kv_args), trimmed);
3064    }
3065
3066    if normalize_tool_name(tool_name) == "websearch" {
3067        if let Some(query) = sanitize_websearch_query_candidate(trimmed) {
3068            return json!({ "query": query });
3069        }
3070        return json!({});
3071    }
3072
3073    json!({})
3074}
3075
3076fn normalize_streamed_tool_args(tool_name: &str, parsed: Value, raw: &str) -> Value {
3077    let normalized_tool = normalize_tool_name(tool_name);
3078    if normalized_tool != "websearch" {
3079        return parsed;
3080    }
3081
3082    match parsed {
3083        Value::Object(mut obj) => {
3084            if !has_websearch_query(&obj) && !raw.trim().is_empty() {
3085                if let Some(query) = sanitize_websearch_query_candidate(raw) {
3086                    obj.insert("query".to_string(), Value::String(query));
3087                }
3088            }
3089            Value::Object(obj)
3090        }
3091        Value::String(s) => match sanitize_websearch_query_candidate(&s) {
3092            Some(query) => json!({ "query": query }),
3093            None => json!({}),
3094        },
3095        other => other,
3096    }
3097}
3098
3099fn has_websearch_query(obj: &Map<String, Value>) -> bool {
3100    const QUERY_KEYS: [&str; 5] = ["query", "q", "search_query", "searchQuery", "keywords"];
3101    QUERY_KEYS.iter().any(|key| {
3102        obj.get(*key)
3103            .and_then(|v| v.as_str())
3104            .map(|s| !s.trim().is_empty())
3105            .unwrap_or(false)
3106    })
3107}
3108
3109fn extract_tool_call_from_value(value: &Value) -> Option<(String, Value)> {
3110    if let Some(obj) = value.as_object() {
3111        if let Some(tool) = obj.get("tool").and_then(|v| v.as_str()) {
3112            return Some((
3113                normalize_tool_name(tool),
3114                obj.get("args").cloned().unwrap_or_else(|| json!({})),
3115            ));
3116        }
3117
3118        if let Some(tool) = obj.get("name").and_then(|v| v.as_str()) {
3119            let args = obj
3120                .get("args")
3121                .cloned()
3122                .or_else(|| obj.get("arguments").cloned())
3123                .unwrap_or_else(|| json!({}));
3124            let normalized_tool = normalize_tool_name(tool);
3125            let args = if let Some(raw) = args.as_str() {
3126                parse_streamed_tool_args(&normalized_tool, raw)
3127            } else {
3128                args
3129            };
3130            return Some((normalized_tool, args));
3131        }
3132
3133        for key in [
3134            "tool_call",
3135            "toolCall",
3136            "call",
3137            "function_call",
3138            "functionCall",
3139        ] {
3140            if let Some(nested) = obj.get(key) {
3141                if let Some(found) = extract_tool_call_from_value(nested) {
3142                    return Some(found);
3143                }
3144            }
3145        }
3146    }
3147
3148    if let Some(items) = value.as_array() {
3149        for item in items {
3150            if let Some(found) = extract_tool_call_from_value(item) {
3151                return Some(found);
3152            }
3153        }
3154    }
3155
3156    None
3157}
3158
3159fn extract_first_json_object(input: &str) -> Option<String> {
3160    let mut start = None;
3161    let mut depth = 0usize;
3162    for (idx, ch) in input.char_indices() {
3163        if ch == '{' {
3164            if start.is_none() {
3165                start = Some(idx);
3166            }
3167            depth += 1;
3168        } else if ch == '}' {
3169            if depth == 0 {
3170                continue;
3171            }
3172            depth -= 1;
3173            if depth == 0 {
3174                let begin = start?;
3175                let block = input.get(begin..=idx)?;
3176                return Some(block.to_string());
3177            }
3178        }
3179    }
3180    None
3181}
3182
3183fn extract_todo_candidates_from_text(input: &str) -> Vec<Value> {
3184    let mut seen = HashSet::<String>::new();
3185    let mut todos = Vec::new();
3186
3187    for raw_line in input.lines() {
3188        let mut line = raw_line.trim();
3189        let mut structured_line = false;
3190        if line.is_empty() {
3191            continue;
3192        }
3193        if line.starts_with("```") {
3194            continue;
3195        }
3196        if line.ends_with(':') {
3197            continue;
3198        }
3199        if let Some(rest) = line
3200            .strip_prefix("- [ ]")
3201            .or_else(|| line.strip_prefix("* [ ]"))
3202            .or_else(|| line.strip_prefix("- [x]"))
3203            .or_else(|| line.strip_prefix("* [x]"))
3204        {
3205            line = rest.trim();
3206            structured_line = true;
3207        } else if let Some(rest) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) {
3208            line = rest.trim();
3209            structured_line = true;
3210        } else {
3211            let bytes = line.as_bytes();
3212            let mut i = 0usize;
3213            while i < bytes.len() && bytes[i].is_ascii_digit() {
3214                i += 1;
3215            }
3216            if i > 0 && i + 1 < bytes.len() && (bytes[i] == b'.' || bytes[i] == b')') {
3217                line = line[i + 1..].trim();
3218                structured_line = true;
3219            }
3220        }
3221        if !structured_line {
3222            continue;
3223        }
3224
3225        let content = line.trim_matches(|c: char| c.is_whitespace() || c == '-' || c == '*');
3226        if content.len() < 5 || content.len() > 180 {
3227            continue;
3228        }
3229        let key = content.to_lowercase();
3230        if seen.contains(&key) {
3231            continue;
3232        }
3233        seen.insert(key);
3234        todos.push(json!({ "content": content }));
3235        if todos.len() >= 25 {
3236            break;
3237        }
3238    }
3239
3240    todos
3241}
3242
3243async fn emit_plan_todo_fallback(
3244    storage: std::sync::Arc<Storage>,
3245    bus: &EventBus,
3246    session_id: &str,
3247    message_id: &str,
3248    completion: &str,
3249) {
3250    let todos = extract_todo_candidates_from_text(completion);
3251    if todos.is_empty() {
3252        return;
3253    }
3254
3255    let invoke_part = WireMessagePart::tool_invocation(
3256        session_id,
3257        message_id,
3258        "todo_write",
3259        json!({"todos": todos.clone()}),
3260    );
3261    let call_id = invoke_part.id.clone();
3262    bus.publish(EngineEvent::new(
3263        "message.part.updated",
3264        json!({"part": invoke_part}),
3265    ));
3266
3267    if storage.set_todos(session_id, todos).await.is_err() {
3268        let mut failed_part =
3269            WireMessagePart::tool_result(session_id, message_id, "todo_write", json!(null));
3270        failed_part.id = call_id;
3271        failed_part.state = Some("failed".to_string());
3272        failed_part.error = Some("failed to persist plan todos".to_string());
3273        bus.publish(EngineEvent::new(
3274            "message.part.updated",
3275            json!({"part": failed_part}),
3276        ));
3277        return;
3278    }
3279
3280    let normalized = storage.get_todos(session_id).await;
3281    let mut result_part = WireMessagePart::tool_result(
3282        session_id,
3283        message_id,
3284        "todo_write",
3285        json!({ "todos": normalized }),
3286    );
3287    result_part.id = call_id;
3288    bus.publish(EngineEvent::new(
3289        "message.part.updated",
3290        json!({"part": result_part}),
3291    ));
3292    bus.publish(EngineEvent::new(
3293        "todo.updated",
3294        json!({
3295            "sessionID": session_id,
3296            "todos": normalized
3297        }),
3298    ));
3299}
3300
3301async fn emit_plan_question_fallback(
3302    storage: std::sync::Arc<Storage>,
3303    bus: &EventBus,
3304    session_id: &str,
3305    message_id: &str,
3306    completion: &str,
3307) {
3308    let trimmed = completion.trim();
3309    if trimmed.is_empty() {
3310        return;
3311    }
3312
3313    let hints = extract_todo_candidates_from_text(trimmed)
3314        .into_iter()
3315        .take(6)
3316        .filter_map(|v| {
3317            v.get("content")
3318                .and_then(|c| c.as_str())
3319                .map(ToString::to_string)
3320        })
3321        .collect::<Vec<_>>();
3322
3323    let mut options = hints
3324        .iter()
3325        .map(|label| json!({"label": label, "description": "Use this as a starting task"}))
3326        .collect::<Vec<_>>();
3327    if options.is_empty() {
3328        options = vec![
3329            json!({"label":"Define scope", "description":"Clarify the intended outcome"}),
3330            json!({"label":"Provide constraints", "description":"Budget, timeline, and constraints"}),
3331            json!({"label":"Draft a starter list", "description":"Generate a first-pass task list"}),
3332        ];
3333    }
3334
3335    let question_payload = vec![json!({
3336        "header":"Planning Input",
3337        "question":"I couldn't produce a concrete task list yet. Which tasks should I include first?",
3338        "options": options,
3339        "multiple": true,
3340        "custom": true
3341    })];
3342
3343    let request = storage
3344        .add_question_request(session_id, message_id, question_payload.clone())
3345        .await
3346        .ok();
3347    bus.publish(EngineEvent::new(
3348        "question.asked",
3349        json!({
3350            "id": request
3351                .as_ref()
3352                .map(|req| req.id.clone())
3353                .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3354            "sessionID": session_id,
3355            "messageID": message_id,
3356            "questions": question_payload,
3357            "tool": request.and_then(|req| {
3358                req.tool.map(|tool| {
3359                    json!({
3360                        "callID": tool.call_id,
3361                        "messageID": tool.message_id
3362                    })
3363                })
3364            })
3365        }),
3366    ));
3367}
3368
3369async fn load_chat_history(storage: std::sync::Arc<Storage>, session_id: &str) -> Vec<ChatMessage> {
3370    let Some(session) = storage.get_session(session_id).await else {
3371        return Vec::new();
3372    };
3373    let messages = session
3374        .messages
3375        .into_iter()
3376        .map(|m| {
3377            let role = format!("{:?}", m.role).to_lowercase();
3378            let content = m
3379                .parts
3380                .into_iter()
3381                .map(|part| match part {
3382                    MessagePart::Text { text } => text,
3383                    MessagePart::Reasoning { text } => text,
3384                    MessagePart::ToolInvocation { tool, result, .. } => {
3385                        format!("Tool {tool} => {}", result.unwrap_or_else(|| json!({})))
3386                    }
3387                })
3388                .collect::<Vec<_>>()
3389                .join("\n");
3390            ChatMessage { role, content }
3391        })
3392        .collect::<Vec<_>>();
3393    compact_chat_history(messages)
3394}
3395
3396struct ToolSideEventContext<'a> {
3397    session_id: &'a str,
3398    message_id: &'a str,
3399    tool: &'a str,
3400    args: &'a serde_json::Value,
3401    metadata: &'a serde_json::Value,
3402    workspace_root: Option<&'a str>,
3403    effective_cwd: Option<&'a str>,
3404}
3405
3406async fn emit_tool_side_events(
3407    storage: std::sync::Arc<Storage>,
3408    bus: &EventBus,
3409    ctx: ToolSideEventContext<'_>,
3410) {
3411    let ToolSideEventContext {
3412        session_id,
3413        message_id,
3414        tool,
3415        args,
3416        metadata,
3417        workspace_root,
3418        effective_cwd,
3419    } = ctx;
3420    if tool == "todo_write" {
3421        let todos_from_metadata = metadata
3422            .get("todos")
3423            .and_then(|v| v.as_array())
3424            .cloned()
3425            .unwrap_or_default();
3426
3427        if !todos_from_metadata.is_empty() {
3428            let _ = storage.set_todos(session_id, todos_from_metadata).await;
3429        } else {
3430            let current = storage.get_todos(session_id).await;
3431            if let Some(updated) = apply_todo_updates_from_args(current, args) {
3432                let _ = storage.set_todos(session_id, updated).await;
3433            }
3434        }
3435
3436        let normalized = storage.get_todos(session_id).await;
3437        bus.publish(EngineEvent::new(
3438            "todo.updated",
3439            json!({
3440                "sessionID": session_id,
3441                "todos": normalized,
3442                "workspaceRoot": workspace_root,
3443                "effectiveCwd": effective_cwd
3444            }),
3445        ));
3446    }
3447    if tool == "question" {
3448        let questions = metadata
3449            .get("questions")
3450            .and_then(|v| v.as_array())
3451            .cloned()
3452            .unwrap_or_default();
3453        if questions.is_empty() {
3454            tracing::warn!(
3455                "question tool produced empty questions payload; skipping question.asked event session_id={} message_id={}",
3456                session_id,
3457                message_id
3458            );
3459        } else {
3460            let request = storage
3461                .add_question_request(session_id, message_id, questions.clone())
3462                .await
3463                .ok();
3464            bus.publish(EngineEvent::new(
3465                "question.asked",
3466                json!({
3467                    "id": request
3468                        .as_ref()
3469                        .map(|req| req.id.clone())
3470                        .unwrap_or_else(|| format!("q-{}", uuid::Uuid::new_v4())),
3471                    "sessionID": session_id,
3472                    "messageID": message_id,
3473                    "questions": questions,
3474                    "tool": request.and_then(|req| {
3475                        req.tool.map(|tool| {
3476                            json!({
3477                                "callID": tool.call_id,
3478                                "messageID": tool.message_id
3479                            })
3480                        })
3481                    }),
3482                    "workspaceRoot": workspace_root,
3483                    "effectiveCwd": effective_cwd
3484                }),
3485            ));
3486        }
3487    }
3488    if let Some(events) = metadata.get("events").and_then(|v| v.as_array()) {
3489        for event in events {
3490            let Some(event_type) = event.get("type").and_then(|v| v.as_str()) else {
3491                continue;
3492            };
3493            if !event_type.starts_with("agent_team.") {
3494                continue;
3495            }
3496            let mut properties = event
3497                .get("properties")
3498                .and_then(|v| v.as_object())
3499                .cloned()
3500                .unwrap_or_default();
3501            properties
3502                .entry("sessionID".to_string())
3503                .or_insert(json!(session_id));
3504            properties
3505                .entry("messageID".to_string())
3506                .or_insert(json!(message_id));
3507            properties
3508                .entry("workspaceRoot".to_string())
3509                .or_insert(json!(workspace_root));
3510            properties
3511                .entry("effectiveCwd".to_string())
3512                .or_insert(json!(effective_cwd));
3513            bus.publish(EngineEvent::new(event_type, Value::Object(properties)));
3514        }
3515    }
3516}
3517
3518fn apply_todo_updates_from_args(current: Vec<Value>, args: &Value) -> Option<Vec<Value>> {
3519    let obj = args.as_object()?;
3520    let mut todos = current;
3521    let mut changed = false;
3522
3523    if let Some(items) = obj.get("todos").and_then(|v| v.as_array()) {
3524        for item in items {
3525            let Some(item_obj) = item.as_object() else {
3526                continue;
3527            };
3528            let status = item_obj
3529                .get("status")
3530                .and_then(|v| v.as_str())
3531                .map(normalize_todo_status);
3532            let target = item_obj
3533                .get("task_id")
3534                .or_else(|| item_obj.get("todo_id"))
3535                .or_else(|| item_obj.get("id"));
3536
3537            if let (Some(status), Some(target)) = (status, target) {
3538                changed |= apply_single_todo_status_update(&mut todos, target, &status);
3539            }
3540        }
3541    }
3542
3543    let status = obj
3544        .get("status")
3545        .and_then(|v| v.as_str())
3546        .map(normalize_todo_status);
3547    let target = obj
3548        .get("task_id")
3549        .or_else(|| obj.get("todo_id"))
3550        .or_else(|| obj.get("id"));
3551    if let (Some(status), Some(target)) = (status, target) {
3552        changed |= apply_single_todo_status_update(&mut todos, target, &status);
3553    }
3554
3555    if changed {
3556        Some(todos)
3557    } else {
3558        None
3559    }
3560}
3561
3562fn apply_single_todo_status_update(todos: &mut [Value], target: &Value, status: &str) -> bool {
3563    let idx_from_value = match target {
3564        Value::Number(n) => n.as_u64().map(|v| v.saturating_sub(1) as usize),
3565        Value::String(s) => {
3566            let trimmed = s.trim();
3567            trimmed
3568                .parse::<usize>()
3569                .ok()
3570                .map(|v| v.saturating_sub(1))
3571                .or_else(|| {
3572                    let digits = trimmed
3573                        .chars()
3574                        .rev()
3575                        .take_while(|c| c.is_ascii_digit())
3576                        .collect::<String>()
3577                        .chars()
3578                        .rev()
3579                        .collect::<String>();
3580                    digits.parse::<usize>().ok().map(|v| v.saturating_sub(1))
3581                })
3582        }
3583        _ => None,
3584    };
3585
3586    if let Some(idx) = idx_from_value {
3587        if idx < todos.len() {
3588            if let Some(obj) = todos[idx].as_object_mut() {
3589                obj.insert("status".to_string(), Value::String(status.to_string()));
3590                return true;
3591            }
3592        }
3593    }
3594
3595    let id_target = target.as_str().map(|s| s.trim()).filter(|s| !s.is_empty());
3596    if let Some(id_target) = id_target {
3597        for todo in todos.iter_mut() {
3598            if let Some(obj) = todo.as_object_mut() {
3599                if obj.get("id").and_then(|v| v.as_str()) == Some(id_target) {
3600                    obj.insert("status".to_string(), Value::String(status.to_string()));
3601                    return true;
3602                }
3603            }
3604        }
3605    }
3606
3607    false
3608}
3609
3610fn normalize_todo_status(raw: &str) -> String {
3611    match raw.trim().to_lowercase().as_str() {
3612        "in_progress" | "inprogress" | "running" | "working" => "in_progress".to_string(),
3613        "done" | "complete" | "completed" => "completed".to_string(),
3614        "cancelled" | "canceled" | "aborted" | "skipped" => "cancelled".to_string(),
3615        "open" | "todo" | "pending" => "pending".to_string(),
3616        other => other.to_string(),
3617    }
3618}
3619
3620fn compact_chat_history(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
3621    const MAX_CONTEXT_CHARS: usize = 80_000;
3622    const KEEP_RECENT_MESSAGES: usize = 40;
3623
3624    if messages.len() <= KEEP_RECENT_MESSAGES {
3625        let total_chars = messages.iter().map(|m| m.content.len()).sum::<usize>();
3626        if total_chars <= MAX_CONTEXT_CHARS {
3627            return messages;
3628        }
3629    }
3630
3631    let mut kept = messages;
3632    let mut dropped_count = 0usize;
3633    let mut total_chars = kept.iter().map(|m| m.content.len()).sum::<usize>();
3634
3635    while kept.len() > KEEP_RECENT_MESSAGES || total_chars > MAX_CONTEXT_CHARS {
3636        if kept.is_empty() {
3637            break;
3638        }
3639        let removed = kept.remove(0);
3640        total_chars = total_chars.saturating_sub(removed.content.len());
3641        dropped_count += 1;
3642    }
3643
3644    if dropped_count > 0 {
3645        kept.insert(
3646            0,
3647            ChatMessage {
3648                role: "system".to_string(),
3649                content: format!(
3650                    "[history compacted: omitted {} older messages to fit context window]",
3651                    dropped_count
3652                ),
3653            },
3654        );
3655    }
3656    kept
3657}
3658
3659#[cfg(test)]
3660mod tests {
3661    use super::*;
3662    use crate::{EventBus, Storage};
3663    use uuid::Uuid;
3664
3665    #[tokio::test]
3666    async fn todo_updated_event_is_normalized() {
3667        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3668        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3669        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3670        let session_id = session.id.clone();
3671        storage.save_session(session).await.expect("save session");
3672
3673        let bus = EventBus::new();
3674        let mut rx = bus.subscribe();
3675        emit_tool_side_events(
3676            storage.clone(),
3677            &bus,
3678            ToolSideEventContext {
3679                session_id: &session_id,
3680                message_id: "m1",
3681                tool: "todo_write",
3682                args: &json!({"todos":[{"content":"ship parity"}]}),
3683                metadata: &json!({"todos":[{"content":"ship parity"}]}),
3684                workspace_root: Some("."),
3685                effective_cwd: Some("."),
3686            },
3687        )
3688        .await;
3689
3690        let event = rx.recv().await.expect("event");
3691        assert_eq!(event.event_type, "todo.updated");
3692        let todos = event
3693            .properties
3694            .get("todos")
3695            .and_then(|v| v.as_array())
3696            .cloned()
3697            .unwrap_or_default();
3698        assert_eq!(todos.len(), 1);
3699        assert!(todos[0].get("id").and_then(|v| v.as_str()).is_some());
3700        assert_eq!(
3701            todos[0].get("content").and_then(|v| v.as_str()),
3702            Some("ship parity")
3703        );
3704        assert!(todos[0].get("status").and_then(|v| v.as_str()).is_some());
3705    }
3706
3707    #[tokio::test]
3708    async fn question_asked_event_contains_tool_reference() {
3709        let base = std::env::temp_dir().join(format!("engine-loop-test-{}", Uuid::new_v4()));
3710        let storage = std::sync::Arc::new(Storage::new(&base).await.expect("storage"));
3711        let session = tandem_types::Session::new(Some("s".to_string()), Some(".".to_string()));
3712        let session_id = session.id.clone();
3713        storage.save_session(session).await.expect("save session");
3714
3715        let bus = EventBus::new();
3716        let mut rx = bus.subscribe();
3717        emit_tool_side_events(
3718            storage,
3719            &bus,
3720            ToolSideEventContext {
3721                session_id: &session_id,
3722                message_id: "msg-1",
3723                tool: "question",
3724                args: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3725                metadata: &json!({"questions":[{"header":"Topic","question":"Pick one","options":[{"label":"A","description":"d"}]}]}),
3726                workspace_root: Some("."),
3727                effective_cwd: Some("."),
3728            },
3729        )
3730        .await;
3731
3732        let event = rx.recv().await.expect("event");
3733        assert_eq!(event.event_type, "question.asked");
3734        assert_eq!(
3735            event
3736                .properties
3737                .get("sessionID")
3738                .and_then(|v| v.as_str())
3739                .unwrap_or(""),
3740            session_id
3741        );
3742        let tool = event
3743            .properties
3744            .get("tool")
3745            .cloned()
3746            .unwrap_or_else(|| json!({}));
3747        assert!(tool.get("callID").and_then(|v| v.as_str()).is_some());
3748        assert_eq!(
3749            tool.get("messageID").and_then(|v| v.as_str()),
3750            Some("msg-1")
3751        );
3752    }
3753
3754    #[test]
3755    fn compact_chat_history_keeps_recent_and_inserts_summary() {
3756        let mut messages = Vec::new();
3757        for i in 0..60 {
3758            messages.push(ChatMessage {
3759                role: "user".to_string(),
3760                content: format!("message-{i}"),
3761            });
3762        }
3763        let compacted = compact_chat_history(messages);
3764        assert!(compacted.len() <= 41);
3765        assert_eq!(compacted[0].role, "system");
3766        assert!(compacted[0].content.contains("history compacted"));
3767        assert!(compacted.iter().any(|m| m.content.contains("message-59")));
3768    }
3769
3770    #[test]
3771    fn extracts_todos_from_checklist_and_numbered_lines() {
3772        let input = r#"
3773Plan:
3774- [ ] Audit current implementation
3775- [ ] Add planner fallback
37761. Add regression test coverage
3777"#;
3778        let todos = extract_todo_candidates_from_text(input);
3779        assert_eq!(todos.len(), 3);
3780        assert_eq!(
3781            todos[0].get("content").and_then(|v| v.as_str()),
3782            Some("Audit current implementation")
3783        );
3784    }
3785
3786    #[test]
3787    fn does_not_extract_todos_from_plain_prose_lines() {
3788        let input = r#"
3789I need more information to proceed.
3790Can you tell me the event size and budget?
3791Once I have that, I can provide a detailed plan.
3792"#;
3793        let todos = extract_todo_candidates_from_text(input);
3794        assert!(todos.is_empty());
3795    }
3796
3797    #[test]
3798    fn parses_wrapped_tool_call_from_markdown_response() {
3799        let input = r#"
3800Here is the tool call:
3801```json
3802{"tool_call":{"name":"todo_write","arguments":{"todos":[{"content":"a"}]}}}
3803```
3804"#;
3805        let parsed = parse_tool_invocation_from_response(input).expect("tool call");
3806        assert_eq!(parsed.0, "todo_write");
3807        assert!(parsed.1.get("todos").is_some());
3808    }
3809
3810    #[test]
3811    fn parses_function_style_todowrite_call() {
3812        let input = r#"Status: Completed
3813Call: todowrite(task_id=2, status="completed")"#;
3814        let parsed = parse_tool_invocation_from_response(input).expect("function-style tool call");
3815        assert_eq!(parsed.0, "todo_write");
3816        assert_eq!(parsed.1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3817        assert_eq!(
3818            parsed.1.get("status").and_then(|v| v.as_str()),
3819            Some("completed")
3820        );
3821    }
3822
3823    #[test]
3824    fn parses_multiple_function_style_todowrite_calls() {
3825        let input = r#"
3826Call: todowrite(task_id=2, status="completed")
3827Call: todowrite(task_id=3, status="in_progress")
3828"#;
3829        let parsed = parse_tool_invocations_from_response(input);
3830        assert_eq!(parsed.len(), 2);
3831        assert_eq!(parsed[0].0, "todo_write");
3832        assert_eq!(parsed[0].1.get("task_id").and_then(|v| v.as_i64()), Some(2));
3833        assert_eq!(
3834            parsed[0].1.get("status").and_then(|v| v.as_str()),
3835            Some("completed")
3836        );
3837        assert_eq!(parsed[1].1.get("task_id").and_then(|v| v.as_i64()), Some(3));
3838        assert_eq!(
3839            parsed[1].1.get("status").and_then(|v| v.as_str()),
3840            Some("in_progress")
3841        );
3842    }
3843
3844    #[test]
3845    fn applies_todo_status_update_from_task_id_args() {
3846        let current = vec![
3847            json!({"id":"todo-1","content":"a","status":"pending"}),
3848            json!({"id":"todo-2","content":"b","status":"pending"}),
3849            json!({"id":"todo-3","content":"c","status":"pending"}),
3850        ];
3851        let updated =
3852            apply_todo_updates_from_args(current, &json!({"task_id":2, "status":"completed"}))
3853                .expect("status update");
3854        assert_eq!(
3855            updated[1].get("status").and_then(|v| v.as_str()),
3856            Some("completed")
3857        );
3858    }
3859
3860    #[test]
3861    fn normalizes_todo_write_tasks_alias() {
3862        let normalized = normalize_todo_write_args(
3863            json!({"tasks":[{"title":"Book venue"},{"name":"Send invites"}]}),
3864            "",
3865        );
3866        let todos = normalized
3867            .get("todos")
3868            .and_then(|v| v.as_array())
3869            .cloned()
3870            .unwrap_or_default();
3871        assert_eq!(todos.len(), 2);
3872        assert_eq!(
3873            todos[0].get("content").and_then(|v| v.as_str()),
3874            Some("Book venue")
3875        );
3876        assert_eq!(
3877            todos[1].get("content").and_then(|v| v.as_str()),
3878            Some("Send invites")
3879        );
3880    }
3881
3882    #[test]
3883    fn normalizes_todo_write_from_completion_when_args_empty() {
3884        let completion = "Plan:\n1. Secure venue\n2. Create playlist\n3. Send invites";
3885        let normalized = normalize_todo_write_args(json!({}), completion);
3886        let todos = normalized
3887            .get("todos")
3888            .and_then(|v| v.as_array())
3889            .cloned()
3890            .unwrap_or_default();
3891        assert_eq!(todos.len(), 3);
3892        assert!(!is_empty_todo_write_args(&normalized));
3893    }
3894
3895    #[test]
3896    fn empty_todo_write_args_allows_status_updates() {
3897        let args = json!({"task_id": 2, "status":"completed"});
3898        assert!(!is_empty_todo_write_args(&args));
3899    }
3900
3901    #[test]
3902    fn streamed_websearch_args_fallback_to_query_string() {
3903        let parsed = parse_streamed_tool_args("websearch", "meaning of life");
3904        assert_eq!(
3905            parsed.get("query").and_then(|v| v.as_str()),
3906            Some("meaning of life")
3907        );
3908    }
3909
3910    #[test]
3911    fn streamed_websearch_stringified_json_args_are_unwrapped() {
3912        let parsed = parse_streamed_tool_args("websearch", r#""donkey gestation period""#);
3913        assert_eq!(
3914            parsed.get("query").and_then(|v| v.as_str()),
3915            Some("donkey gestation period")
3916        );
3917    }
3918
3919    #[test]
3920    fn streamed_websearch_args_strip_arg_key_value_wrappers() {
3921        let parsed = parse_streamed_tool_args(
3922            "websearch",
3923            "query</arg_key><arg_value>taj card what is it benefits how to apply</arg_value>",
3924        );
3925        assert_eq!(
3926            parsed.get("query").and_then(|v| v.as_str()),
3927            Some("taj card what is it benefits how to apply")
3928        );
3929    }
3930
3931    #[test]
3932    fn normalize_tool_args_websearch_infers_from_user_text() {
3933        let normalized =
3934            normalize_tool_args("websearch", json!({}), "web search meaning of life", "");
3935        assert_eq!(
3936            normalized.args.get("query").and_then(|v| v.as_str()),
3937            Some("meaning of life")
3938        );
3939        assert_eq!(normalized.args_source, "inferred_from_user");
3940        assert_eq!(normalized.args_integrity, "recovered");
3941    }
3942
3943    #[test]
3944    fn normalize_tool_args_websearch_keeps_existing_query() {
3945        let normalized = normalize_tool_args(
3946            "websearch",
3947            json!({"query":"already set"}),
3948            "web search should not override",
3949            "",
3950        );
3951        assert_eq!(
3952            normalized.args.get("query").and_then(|v| v.as_str()),
3953            Some("already set")
3954        );
3955        assert_eq!(normalized.args_source, "provider_json");
3956        assert_eq!(normalized.args_integrity, "ok");
3957    }
3958
3959    #[test]
3960    fn normalize_tool_args_websearch_fails_when_unrecoverable() {
3961        let normalized = normalize_tool_args("websearch", json!({}), "search", "");
3962        assert!(normalized.query.is_none());
3963        assert!(normalized.missing_terminal);
3964        assert_eq!(normalized.args_source, "missing");
3965        assert_eq!(normalized.args_integrity, "empty");
3966    }
3967
3968    #[test]
3969    fn normalize_tool_args_webfetch_infers_url_from_user_prompt() {
3970        let normalized = normalize_tool_args(
3971            "webfetch",
3972            json!({}),
3973            "Please fetch `https://tandem.frumu.ai/docs/` in markdown mode",
3974            "",
3975        );
3976        assert!(!normalized.missing_terminal);
3977        assert_eq!(
3978            normalized.args.get("url").and_then(|v| v.as_str()),
3979            Some("https://tandem.frumu.ai/docs/")
3980        );
3981        assert_eq!(normalized.args_source, "inferred_from_user");
3982        assert_eq!(normalized.args_integrity, "recovered");
3983    }
3984
3985    #[test]
3986    fn normalize_tool_args_webfetch_recovers_nested_url_alias() {
3987        let normalized = normalize_tool_args(
3988            "webfetch",
3989            json!({"args":{"uri":"https://example.com/page"}}),
3990            "",
3991            "",
3992        );
3993        assert!(!normalized.missing_terminal);
3994        assert_eq!(
3995            normalized.args.get("url").and_then(|v| v.as_str()),
3996            Some("https://example.com/page")
3997        );
3998        assert_eq!(normalized.args_source, "provider_json");
3999    }
4000
4001    #[test]
4002    fn normalize_tool_args_webfetch_fails_when_url_unrecoverable() {
4003        let normalized = normalize_tool_args("webfetch", json!({}), "fetch the site", "");
4004        assert!(normalized.missing_terminal);
4005        assert_eq!(
4006            normalized.missing_terminal_reason.as_deref(),
4007            Some("WEBFETCH_URL_MISSING")
4008        );
4009    }
4010
4011    #[test]
4012    fn normalize_tool_args_write_requires_path() {
4013        let normalized = normalize_tool_args("write", json!({}), "", "");
4014        assert!(normalized.missing_terminal);
4015        assert_eq!(
4016            normalized.missing_terminal_reason.as_deref(),
4017            Some("FILE_PATH_MISSING")
4018        );
4019    }
4020
4021    #[test]
4022    fn normalize_tool_args_write_recovers_alias_path_key() {
4023        let normalized = normalize_tool_args(
4024            "write",
4025            json!({"filePath":"docs/CONCEPT.md","content":"hello"}),
4026            "",
4027            "",
4028        );
4029        assert!(!normalized.missing_terminal);
4030        assert_eq!(
4031            normalized.args.get("path").and_then(|v| v.as_str()),
4032            Some("docs/CONCEPT.md")
4033        );
4034        assert_eq!(
4035            normalized.args.get("content").and_then(|v| v.as_str()),
4036            Some("hello")
4037        );
4038    }
4039
4040    #[test]
4041    fn normalize_tool_args_read_infers_path_from_user_prompt() {
4042        let normalized = normalize_tool_args(
4043            "read",
4044            json!({}),
4045            "Please inspect `FEATURE_LIST.md` and summarize key sections.",
4046            "",
4047        );
4048        assert!(!normalized.missing_terminal);
4049        assert_eq!(
4050            normalized.args.get("path").and_then(|v| v.as_str()),
4051            Some("FEATURE_LIST.md")
4052        );
4053        assert_eq!(normalized.args_source, "inferred_from_user");
4054        assert_eq!(normalized.args_integrity, "recovered");
4055    }
4056
4057    #[test]
4058    fn normalize_tool_args_read_does_not_infer_path_from_assistant_context() {
4059        let normalized = normalize_tool_args(
4060            "read",
4061            json!({}),
4062            "generic instruction",
4063            "I will read src-tauri/src/orchestrator/engine.rs first.",
4064        );
4065        assert!(normalized.missing_terminal);
4066        assert_eq!(
4067            normalized.missing_terminal_reason.as_deref(),
4068            Some("FILE_PATH_MISSING")
4069        );
4070    }
4071
4072    #[test]
4073    fn normalize_tool_args_write_recovers_path_from_nested_array_payload() {
4074        let normalized = normalize_tool_args(
4075            "write",
4076            json!({"args":[{"file_path":"docs/CONCEPT.md"}],"content":"hello"}),
4077            "",
4078            "",
4079        );
4080        assert!(!normalized.missing_terminal);
4081        assert_eq!(
4082            normalized.args.get("path").and_then(|v| v.as_str()),
4083            Some("docs/CONCEPT.md")
4084        );
4085    }
4086
4087    #[test]
4088    fn normalize_tool_args_write_recovers_content_alias() {
4089        let normalized = normalize_tool_args(
4090            "write",
4091            json!({"path":"docs/FEATURES.md","body":"feature notes"}),
4092            "",
4093            "",
4094        );
4095        assert!(!normalized.missing_terminal);
4096        assert_eq!(
4097            normalized.args.get("content").and_then(|v| v.as_str()),
4098            Some("feature notes")
4099        );
4100    }
4101
4102    #[test]
4103    fn normalize_tool_args_write_fails_when_content_missing() {
4104        let normalized = normalize_tool_args("write", json!({"path":"docs/FEATURES.md"}), "", "");
4105        assert!(normalized.missing_terminal);
4106        assert_eq!(
4107            normalized.missing_terminal_reason.as_deref(),
4108            Some("WRITE_CONTENT_MISSING")
4109        );
4110    }
4111
4112    #[test]
4113    fn normalize_tool_args_write_recovers_raw_nested_string_content() {
4114        let normalized = normalize_tool_args(
4115            "write",
4116            json!({"path":"docs/FEATURES.md","args":"Line 1\nLine 2"}),
4117            "",
4118            "",
4119        );
4120        assert!(!normalized.missing_terminal);
4121        assert_eq!(
4122            normalized.args.get("path").and_then(|v| v.as_str()),
4123            Some("docs/FEATURES.md")
4124        );
4125        assert_eq!(
4126            normalized.args.get("content").and_then(|v| v.as_str()),
4127            Some("Line 1\nLine 2")
4128        );
4129    }
4130
4131    #[test]
4132    fn normalize_tool_args_write_does_not_treat_path_as_content() {
4133        let normalized = normalize_tool_args("write", json!("docs/FEATURES.md"), "", "");
4134        assert!(normalized.missing_terminal);
4135        assert_eq!(
4136            normalized.missing_terminal_reason.as_deref(),
4137            Some("WRITE_CONTENT_MISSING")
4138        );
4139    }
4140
4141    #[test]
4142    fn normalize_tool_args_read_infers_path_from_bold_markdown() {
4143        let normalized = normalize_tool_args(
4144            "read",
4145            json!({}),
4146            "Please read **FEATURE_LIST.md** and summarize.",
4147            "",
4148        );
4149        assert!(!normalized.missing_terminal);
4150        assert_eq!(
4151            normalized.args.get("path").and_then(|v| v.as_str()),
4152            Some("FEATURE_LIST.md")
4153        );
4154    }
4155
4156    #[test]
4157    fn normalize_tool_args_shell_infers_command_from_user_prompt() {
4158        let normalized = normalize_tool_args("bash", json!({}), "Run `rg -n \"TODO\" .`", "");
4159        assert!(!normalized.missing_terminal);
4160        assert_eq!(
4161            normalized.args.get("command").and_then(|v| v.as_str()),
4162            Some("rg -n \"TODO\" .")
4163        );
4164        assert_eq!(normalized.args_source, "inferred_from_user");
4165        assert_eq!(normalized.args_integrity, "recovered");
4166    }
4167
4168    #[test]
4169    fn normalize_tool_args_read_rejects_root_only_path() {
4170        let normalized = normalize_tool_args("read", json!({"path":"/"}), "", "");
4171        assert!(normalized.missing_terminal);
4172        assert_eq!(
4173            normalized.missing_terminal_reason.as_deref(),
4174            Some("FILE_PATH_MISSING")
4175        );
4176    }
4177
4178    #[test]
4179    fn normalize_tool_args_read_recovers_when_provider_path_is_root_only() {
4180        let normalized =
4181            normalize_tool_args("read", json!({"path":"/"}), "Please open `CONCEPT.md`", "");
4182        assert!(!normalized.missing_terminal);
4183        assert_eq!(
4184            normalized.args.get("path").and_then(|v| v.as_str()),
4185            Some("CONCEPT.md")
4186        );
4187        assert_eq!(normalized.args_source, "inferred_from_user");
4188        assert_eq!(normalized.args_integrity, "recovered");
4189    }
4190
4191    #[test]
4192    fn normalize_tool_args_read_rejects_tool_call_markup_path() {
4193        let normalized = normalize_tool_args(
4194            "read",
4195            json!({
4196                "path":"<tool_call>\n<function=glob>\n<parameter=pattern>**/*</parameter>\n</function>\n</tool_call>"
4197            }),
4198            "",
4199            "",
4200        );
4201        assert!(normalized.missing_terminal);
4202        assert_eq!(
4203            normalized.missing_terminal_reason.as_deref(),
4204            Some("FILE_PATH_MISSING")
4205        );
4206    }
4207
4208    #[test]
4209    fn normalize_tool_args_read_rejects_glob_pattern_path() {
4210        let normalized = normalize_tool_args("read", json!({"path":"**/*"}), "", "");
4211        assert!(normalized.missing_terminal);
4212        assert_eq!(
4213            normalized.missing_terminal_reason.as_deref(),
4214            Some("FILE_PATH_MISSING")
4215        );
4216    }
4217
4218    #[test]
4219    fn normalize_tool_args_read_rejects_placeholder_path() {
4220        let normalized = normalize_tool_args("read", json!({"path":"files/directories"}), "", "");
4221        assert!(normalized.missing_terminal);
4222        assert_eq!(
4223            normalized.missing_terminal_reason.as_deref(),
4224            Some("FILE_PATH_MISSING")
4225        );
4226    }
4227
4228    #[test]
4229    fn normalize_tool_args_read_rejects_tool_policy_placeholder_path() {
4230        let normalized = normalize_tool_args("read", json!({"path":"tool/policy"}), "", "");
4231        assert!(normalized.missing_terminal);
4232        assert_eq!(
4233            normalized.missing_terminal_reason.as_deref(),
4234            Some("FILE_PATH_MISSING")
4235        );
4236    }
4237
4238    #[test]
4239    fn normalize_tool_args_read_recovers_pdf_path_from_user_text() {
4240        let normalized = normalize_tool_args(
4241            "read",
4242            json!({"path":"tool/policy"}),
4243            "Read `T1011U kitöltési útmutató.pdf` and summarize.",
4244            "",
4245        );
4246        assert!(!normalized.missing_terminal);
4247        assert_eq!(
4248            normalized.args.get("path").and_then(|v| v.as_str()),
4249            Some("T1011U kitöltési útmutató.pdf")
4250        );
4251        assert_eq!(normalized.args_source, "inferred_from_user");
4252        assert_eq!(normalized.args_integrity, "recovered");
4253    }
4254
4255    #[test]
4256    fn normalize_tool_name_strips_default_api_namespace() {
4257        assert_eq!(normalize_tool_name("default_api:read"), "read");
4258        assert_eq!(normalize_tool_name("functions.shell"), "bash");
4259    }
4260
4261    #[test]
4262    fn batch_helpers_use_name_when_tool_is_wrapper() {
4263        let args = json!({
4264            "tool_calls":[
4265                {"tool":"default_api","name":"read","args":{"path":"CONCEPT.md"}},
4266                {"tool":"default_api:glob","args":{"pattern":"*.md"}}
4267            ]
4268        });
4269        let calls = extract_batch_calls(&args);
4270        assert_eq!(calls.len(), 2);
4271        assert_eq!(calls[0].0, "read");
4272        assert_eq!(calls[1].0, "glob");
4273        assert!(is_read_only_batch_call(&args));
4274        let sig = batch_tool_signature(&args).unwrap_or_default();
4275        assert!(sig.contains("read:"));
4276        assert!(sig.contains("glob:"));
4277    }
4278
4279    #[test]
4280    fn batch_helpers_resolve_nested_function_name() {
4281        let args = json!({
4282            "tool_calls":[
4283                {"tool":"default_api","function":{"name":"read"},"args":{"path":"CONCEPT.md"}}
4284            ]
4285        });
4286        let calls = extract_batch_calls(&args);
4287        assert_eq!(calls.len(), 1);
4288        assert_eq!(calls[0].0, "read");
4289        assert!(is_read_only_batch_call(&args));
4290    }
4291
4292    #[test]
4293    fn batch_output_classifier_detects_non_productive_unknown_results() {
4294        let output = r#"
4295[
4296  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}},
4297  {"tool":"default_api","output":"Unknown tool: default_api","metadata":{}}
4298]
4299"#;
4300        assert!(is_non_productive_batch_output(output));
4301    }
4302
4303    #[test]
4304    fn runtime_prompt_includes_execution_environment_block() {
4305        let prompt = tandem_runtime_system_prompt(&HostRuntimeContext {
4306            os: HostOs::Windows,
4307            arch: "x86_64".to_string(),
4308            shell_family: ShellFamily::Powershell,
4309            path_style: PathStyle::Windows,
4310        });
4311        assert!(prompt.contains("[Execution Environment]"));
4312        assert!(prompt.contains("Host OS: windows"));
4313        assert!(prompt.contains("Shell: powershell"));
4314        assert!(prompt.contains("Path style: windows"));
4315    }
4316}