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