Skip to main content

vtcode_core/tools/
tool_intent.rs

1use serde_json::Value;
2
3use crate::config::constants::tools;
4use crate::tools::command_args::interactive_input_text;
5use crate::tools::names::canonical_tool_name;
6
7pub type ToolIntentClassifier = fn(&Value) -> ToolIntent;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ToolSurfaceKind {
11    Function,
12    ApplyPatch,
13}
14
15#[derive(Debug, Clone, Copy)]
16pub enum ToolMutationModel {
17    ReadOnly,
18    Mutating,
19    ByArgs(ToolIntentClassifier),
20}
21
22impl ToolMutationModel {
23    pub fn classify(self, args: &Value) -> ToolIntent {
24        match self {
25            Self::ReadOnly => ToolIntent::read_only(),
26            Self::Mutating => ToolIntent::mutating(),
27            Self::ByArgs(classifier) => classifier(args),
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy)]
33pub struct ToolBehavior {
34    pub surface_kind: ToolSurfaceKind,
35    pub mutation_model: ToolMutationModel,
36    pub supports_parallel_calls: bool,
37    pub safe_mode_prompt: bool,
38}
39
40impl ToolBehavior {
41    pub const fn function(
42        mutation_model: ToolMutationModel,
43        supports_parallel_calls: bool,
44        safe_mode_prompt: bool,
45    ) -> Self {
46        Self {
47            surface_kind: ToolSurfaceKind::Function,
48            mutation_model,
49            supports_parallel_calls,
50            safe_mode_prompt,
51        }
52    }
53
54    pub const fn apply_patch(
55        mutation_model: ToolMutationModel,
56        supports_parallel_calls: bool,
57        safe_mode_prompt: bool,
58    ) -> Self {
59        Self {
60            surface_kind: ToolSurfaceKind::ApplyPatch,
61            mutation_model,
62            supports_parallel_calls,
63            safe_mode_prompt,
64        }
65    }
66
67    pub fn classify(self, args: &Value) -> ToolIntent {
68        self.mutation_model.classify(args)
69    }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub struct ToolIntent {
74    pub mutating: bool,
75    pub destructive: bool,
76    pub readonly_unified_action: bool,
77    pub retry_safe: bool,
78}
79
80impl ToolIntent {
81    pub const fn read_only() -> Self {
82        Self {
83            mutating: false,
84            destructive: false,
85            readonly_unified_action: false,
86            retry_safe: true,
87        }
88    }
89
90    pub const fn read_only_unified_action() -> Self {
91        Self {
92            mutating: false,
93            destructive: false,
94            readonly_unified_action: true,
95            retry_safe: true,
96        }
97    }
98
99    pub const fn mutating() -> Self {
100        Self {
101            mutating: true,
102            destructive: true,
103            readonly_unified_action: false,
104            retry_safe: false,
105        }
106    }
107}
108
109pub fn builtin_tool_behavior(tool_name: &str) -> Option<ToolBehavior> {
110    let canonical = canonical_tool_name(tool_name);
111    builtin_tool_behavior_canonical(canonical)
112}
113
114fn builtin_tool_behavior_canonical(tool: &str) -> Option<ToolBehavior> {
115    match tool {
116        tools::UNIFIED_SEARCH => Some(ToolBehavior::function(
117            ToolMutationModel::ReadOnly,
118            true,
119            false,
120        )),
121        tools::UNIFIED_EXEC => Some(ToolBehavior::function(
122            ToolMutationModel::ByArgs(unified_exec_intent),
123            false,
124            true,
125        )),
126        tools::UNIFIED_FILE => Some(ToolBehavior::function(
127            ToolMutationModel::ByArgs(unified_file_intent),
128            false,
129            false,
130        )),
131        tools::APPLY_PATCH => Some(ToolBehavior::apply_patch(
132            ToolMutationModel::Mutating,
133            false,
134            true,
135        )),
136        tools::REQUEST_USER_INPUT
137        | tools::MEMORY
138        | tools::START_PLANNING
139        | tools::FINISH_PLANNING
140        | tools::LIST_SKILLS
141        | tools::LOAD_SKILL
142        | tools::LOAD_SKILL_RESOURCE
143        | tools::TASK_TRACKER
144        | tools::GET_ERRORS
145        | tools::SEARCH_TOOLS
146        | tools::MCP_SEARCH_TOOLS
147        | tools::MCP_GET_TOOL_DETAILS
148        | tools::MCP_LIST_SERVERS
149        | tools::THINK => Some(ToolBehavior::function(
150            if tool == tools::MEMORY {
151                ToolMutationModel::ByArgs(memory_tool_intent)
152            } else {
153                ToolMutationModel::ReadOnly
154            },
155            false,
156            false,
157        )),
158        tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => Some(ToolBehavior::function(
159            ToolMutationModel::ReadOnly,
160            true,
161            false,
162        )),
163        tools::WRITE_FILE | tools::EDIT_FILE | tools::DELETE_FILE | tools::CREATE_FILE => Some(
164            ToolBehavior::function(ToolMutationModel::Mutating, false, true),
165        ),
166        tools::MCP_CONNECT_SERVER | tools::MCP_DISCONNECT_SERVER => Some(ToolBehavior::function(
167            ToolMutationModel::Mutating,
168            false,
169            false,
170        )),
171        tools::RUN_PTY_CMD
172        | tools::SEND_PTY_INPUT
173        | tools::CREATE_PTY_SESSION
174        | tools::READ_PTY_SESSION
175        | tools::LIST_PTY_SESSIONS
176        | tools::CLOSE_PTY_SESSION
177        | tools::EXECUTE_CODE
178        | tools::SHELL => Some(ToolBehavior::function(
179            ToolMutationModel::Mutating,
180            false,
181            true,
182        )),
183        _ => None,
184    }
185}
186
187pub fn is_parallel_safe_call(tool_name: &str, args: &Value) -> bool {
188    let canonical = canonical_tool_name(tool_name);
189    if let Some(behavior) = builtin_tool_behavior_canonical(canonical) {
190        return behavior.supports_parallel_calls && !behavior.classify(args).mutating;
191    }
192
193    !classify_tool_intent(canonical, args).mutating
194}
195
196pub fn classify_tool_intent(tool_name: &str, args: &Value) -> ToolIntent {
197    let canonical = canonical_tool_name(tool_name);
198    builtin_tool_behavior_canonical(canonical)
199        .map(|behavior| behavior.classify(args))
200        .unwrap_or_else(ToolIntent::mutating)
201}
202
203pub fn is_edited_file_conflict_guarded_call(tool_name: &str, args: &Value) -> bool {
204    let canonical = canonical_tool_name(tool_name);
205    match canonical {
206        tools::WRITE_FILE | tools::CREATE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => true,
207        tools::UNIFIED_FILE => unified_file_action(args)
208            .map(is_edited_file_conflict_guarded_unified_file_action)
209            .unwrap_or(false),
210        _ => false,
211    }
212}
213
214fn is_edited_file_conflict_guarded_unified_file_action(action: &str) -> bool {
215    action_matches_any(Some(action), &["write", "create", "edit", "patch"])
216}
217
218pub fn canonical_unified_exec_tool_name(tool_name: &str) -> Option<&'static str> {
219    match tool_name {
220        tools::UNIFIED_EXEC
221        | tools::RUN_PTY_CMD
222        | tools::SEND_PTY_INPUT
223        | tools::CREATE_PTY_SESSION
224        | tools::READ_PTY_SESSION
225        | tools::LIST_PTY_SESSIONS
226        | tools::CLOSE_PTY_SESSION
227        | tools::EXECUTE_CODE
228        | tools::EXEC_PTY_CMD
229        | tools::EXEC_COMMAND
230        | tools::WRITE_STDIN
231        | tools::SHELL
232        | "bash"
233        | "exec"
234        | "container.exec" => Some(tools::UNIFIED_EXEC),
235        _ => None,
236    }
237}
238
239pub fn should_use_spool_reference_only(tool_name: Option<&str>, output: &Value) -> bool {
240    let Some(obj) = output.as_object() else {
241        return false;
242    };
243
244    let has_spool_path = obj
245        .get("spool_path")
246        .and_then(Value::as_str)
247        .is_some_and(|path| !path.trim().is_empty());
248    if !has_spool_path {
249        return false;
250    }
251
252    if obj.get("loop_detected").and_then(Value::as_bool) == Some(true) {
253        return false;
254    }
255
256    if tool_name.is_some_and(|name| canonical_unified_exec_tool_name(name).is_some()) {
257        return true;
258    }
259
260    if tool_name.is_some_and(|name| canonical_tool_name(name) == tools::UNIFIED_SEARCH) {
261        return true;
262    }
263
264    if looks_like_unified_search_output(obj) {
265        return true;
266    }
267
268    if obj
269        .get("content_type")
270        .and_then(Value::as_str)
271        .is_some_and(|content_type| content_type == "exec_inspect")
272    {
273        return true;
274    }
275
276    [
277        "command",
278        "id",
279        "session_id",
280        "process_id",
281        "is_exited",
282        "exit_code",
283    ]
284    .iter()
285    .any(|key| obj.contains_key(*key))
286}
287
288fn looks_like_unified_search_output(obj: &serde_json::Map<String, Value>) -> bool {
289    ["matches", "results", "files", "entries"]
290        .iter()
291        .any(|key| obj.contains_key(*key))
292        || obj
293            .get("tool")
294            .or_else(|| obj.get("tool_name"))
295            .or_else(|| obj.get("name"))
296            .and_then(Value::as_str)
297            .is_some_and(|name| canonical_tool_name(name) == tools::UNIFIED_SEARCH)
298}
299
300pub fn is_command_run_tool_call(tool_name: &str, args: &Value) -> bool {
301    match tool_name {
302        tools::RUN_PTY_CMD | tools::CREATE_PTY_SESSION | tools::SHELL | "bash" => true,
303        tools::UNIFIED_EXEC
304        | tools::EXEC_PTY_CMD
305        | tools::EXEC_COMMAND
306        | "exec"
307        | "container.exec" => unified_exec_action_is(args, "run"),
308        _ => false,
309    }
310}
311
312pub fn remap_unified_file_command_args_to_unified_exec(args: &Value) -> Option<Value> {
313    let obj = args.as_object()?;
314    let command = obj
315        .get("command")
316        .or_else(|| obj.get("cmd"))
317        .or_else(|| obj.get("raw_command"))
318        .and_then(Value::as_str)
319        .map(str::trim)
320        .filter(|value| !value.is_empty())?;
321    let action = obj.get("action").and_then(Value::as_str).map(str::trim);
322    if let Some(action) = action
323        && !action.is_empty()
324        && !action_matches_any(Some(action), &["run", "exec", "execute", "shell"])
325    {
326        return None;
327    }
328
329    let mut mapped = serde_json::Map::new();
330    mapped.insert("action".to_string(), Value::String("run".to_string()));
331    mapped.insert("command".to_string(), Value::String(command.to_string()));
332
333    for key in [
334        "args",
335        "cwd",
336        "workdir",
337        "env",
338        "timeout_ms",
339        "yield_time_ms",
340        "login",
341        "shell",
342        "tty",
343        "sandbox_permissions",
344        "justification",
345        "prefix_rule",
346    ] {
347        if let Some(value) = obj.get(key) {
348            mapped.insert(key.to_string(), value.clone());
349        }
350    }
351
352    Some(Value::Object(mapped))
353}
354
355fn unified_file_intent(args: &Value) -> ToolIntent {
356    if unified_file_action_is(args, "read") {
357        ToolIntent::read_only_unified_action()
358    } else {
359        ToolIntent::mutating()
360    }
361}
362
363fn unified_exec_intent(args: &Value) -> ToolIntent {
364    let has_exec_input = unified_exec_has_input(args);
365    let readonly_unified_action = if unified_exec_action_is(args, "run") {
366        is_readonly_unified_exec_command(args)
367    } else {
368        unified_exec_action_in(args, &["poll", "list", "inspect"])
369            || (unified_exec_action_is(args, "continue") && !has_exec_input)
370    };
371
372    if readonly_unified_action {
373        ToolIntent::read_only_unified_action()
374    } else {
375        ToolIntent::mutating()
376    }
377}
378
379fn memory_tool_intent(args: &Value) -> ToolIntent {
380    let command = args
381        .get("command")
382        .and_then(Value::as_str)
383        .map(str::trim)
384        .unwrap_or_default();
385    if command.eq_ignore_ascii_case("view") {
386        ToolIntent::read_only()
387    } else {
388        ToolIntent::mutating()
389    }
390}
391
392fn is_readonly_unified_exec_command(args: &Value) -> bool {
393    let Ok(Some(parts)) = crate::tools::command_args::command_words(args) else {
394        return false;
395    };
396
397    if parts.iter().any(|part| part == "--dry-run") {
398        return true;
399    }
400
401    let Some(command) = parts.first().map(String::as_str) else {
402        return false;
403    };
404
405    match command {
406        "rg" | "ls" | "cat" => true,
407        "git" => matches!(parts.get(1).map(String::as_str), Some("status")),
408        "cargo" => matches!(parts.get(1).map(String::as_str), Some("check" | "test")),
409        "npm" | "pnpm" | "yarn" => match parts.get(1).map(String::as_str) {
410            Some("test") => true,
411            Some("run") => matches!(parts.get(2).map(String::as_str), Some("test")),
412            _ => false,
413        },
414        _ => false,
415    }
416}
417
418/// Determine the action for unified_file tool based on args.
419/// Returns the action string or a default if inference is possible.
420pub fn unified_file_action(args: &Value) -> Option<&str> {
421    fn looks_like_patch_text(text: &str) -> bool {
422        let trimmed = text.trim_start();
423        trimmed.starts_with("*** Begin Patch")
424            || trimmed.starts_with("*** Update File:")
425            || trimmed.starts_with("*** Add File:")
426            || trimmed.starts_with("*** Delete File:")
427    }
428
429    args.get("action").and_then(|v| v.as_str()).or_else(|| {
430        let has_read_path = args.get("path").is_some()
431            || args.get("file_path").is_some()
432            || args.get("filepath").is_some()
433            || args.get("target_path").is_some()
434            || args.get("file").is_some()
435            || args.get("p").is_some();
436        let patch_in_input = args
437            .get("input")
438            .and_then(|v| v.as_str())
439            .is_some_and(looks_like_patch_text);
440        let raw_patch = args.as_str().is_some_and(looks_like_patch_text);
441
442        if args.get("old_str").is_some() {
443            Some("edit")
444        } else if args.get("patch").is_some() || patch_in_input || raw_patch {
445            Some("patch")
446        } else if args.get("content").is_some() {
447            Some("write")
448        } else if args.get("destination").is_some() {
449            Some("move")
450        } else if has_read_path {
451            Some("read")
452        } else {
453            None
454        }
455    })
456}
457
458/// Determine the action for unified_exec tool based on args.
459/// Returns the action string or None if no inference is possible.
460pub fn unified_exec_action(args: &Value) -> Option<&str> {
461    args.get("action").and_then(|v| v.as_str()).or_else(|| {
462        // Check for standard command fields
463        if args.get("command").is_some()
464            || args.get("cmd").is_some()
465            || args.get("raw_command").is_some()
466            || crate::tools::command_args::has_indexed_command_parts(args)
467        {
468            Some("run")
469        } else if args.get("code").is_some() {
470            Some("code")
471        } else if args.get("input").is_some()
472            || args.get("chars").is_some()
473            || args.get("text").is_some()
474        {
475            Some("write")
476        } else if args.get("spool_path").is_some()
477            || args.get("query").is_some()
478            || args.get("head_lines").is_some()
479            || args.get("tail_lines").is_some()
480            || args.get("max_matches").is_some()
481            || args.get("literal").is_some()
482        {
483            Some("inspect")
484        } else if args.get("session_id").is_some() || args.get("s").is_some() {
485            Some("poll")
486        } else {
487            None
488        }
489    })
490}
491
492fn action_matches(action: Option<&str>, expected: &str) -> bool {
493    action.is_some_and(|candidate| candidate.eq_ignore_ascii_case(expected))
494}
495
496fn action_matches_any(action: Option<&str>, expected: &[&str]) -> bool {
497    action.is_some_and(|candidate| {
498        expected
499            .iter()
500            .any(|expected_action| candidate.eq_ignore_ascii_case(expected_action))
501    })
502}
503
504pub fn unified_file_action_is(args: &Value, expected: &str) -> bool {
505    action_matches(unified_file_action(args), expected)
506}
507
508pub fn unified_file_action_in(args: &Value, expected: &[&str]) -> bool {
509    action_matches_any(unified_file_action(args), expected)
510}
511
512pub fn unified_exec_action_is(args: &Value, expected: &str) -> bool {
513    action_matches(unified_exec_action(args), expected)
514}
515
516pub fn unified_exec_action_in(args: &Value, expected: &[&str]) -> bool {
517    action_matches_any(unified_exec_action(args), expected)
518}
519
520pub fn unified_search_action_is(args: &Value, expected: &str) -> bool {
521    action_matches(unified_search_action(args), expected)
522}
523
524pub fn unified_search_action_in(args: &Value, expected: &[&str]) -> bool {
525    action_matches_any(unified_search_action(args), expected)
526}
527
528fn unified_exec_has_input(args: &Value) -> bool {
529    interactive_input_text(args).is_some()
530}
531
532fn get_field_case_insensitive<'a>(
533    args: &'a serde_json::Map<String, Value>,
534    key: &str,
535) -> Option<&'a Value> {
536    args.get(key).or_else(|| {
537        args.iter()
538            .find(|(name, _)| name.eq_ignore_ascii_case(key))
539            .map(|(_, value)| value)
540    })
541}
542
543fn has_meaningful_search_field(args: &serde_json::Map<String, Value>, key: &str) -> bool {
544    match get_field_case_insensitive(args, key) {
545        Some(Value::Null) | None => false,
546        Some(Value::String(text)) => !text.trim().is_empty(),
547        Some(Value::Array(values)) => !values.is_empty(),
548        Some(_) => true,
549    }
550}
551
552fn looks_like_list_glob_pattern(args: &serde_json::Map<String, Value>) -> bool {
553    let pattern = get_field_case_insensitive(args, "pattern")
554        .or_else(|| get_field_case_insensitive(args, "query"))
555        .and_then(Value::as_str)
556        .map(str::trim)
557        .filter(|pattern| !pattern.is_empty());
558    let Some(pattern) = pattern else {
559        return false;
560    };
561
562    let has_glob_wildcards =
563        pattern.contains('*') || pattern.contains('?') || pattern.contains('[');
564    if !has_glob_wildcards {
565        return false;
566    }
567
568    pattern.contains('/')
569        || pattern.contains('\\')
570        || pattern.starts_with("*.")
571        || pattern.contains("*.")
572}
573
574fn unified_search_action_from_object(args: &serde_json::Map<String, Value>) -> Option<&str> {
575    get_field_case_insensitive(args, "action")
576        .and_then(|value| value.as_str())
577        .or_else(|| {
578            // Smart action inference based on parameters
579            let has_structural_workflow = get_field_case_insensitive(args, "workflow")
580                .and_then(Value::as_str)
581                .map(str::trim)
582                .is_some_and(|workflow| !workflow.is_empty());
583            let has_pattern = has_meaningful_search_field(args, "pattern")
584                || has_meaningful_search_field(args, "query");
585            let has_structural_hint = has_structural_workflow
586                || has_meaningful_search_field(args, "lang")
587                || has_meaningful_search_field(args, "selector")
588                || has_meaningful_search_field(args, "strictness")
589                || has_meaningful_search_field(args, "debug_query")
590                || has_meaningful_search_field(args, "globs")
591                || has_meaningful_search_field(args, "config_path")
592                || has_meaningful_search_field(args, "filter")
593                || has_meaningful_search_field(args, "skip_snapshot_tests");
594            let has_path = has_meaningful_search_field(args, "path");
595
596            if has_structural_workflow || (has_pattern && has_structural_hint) {
597                Some("structural")
598            } else if has_pattern && has_path && looks_like_list_glob_pattern(args) {
599                Some("list")
600            } else if has_pattern {
601                Some("grep")
602            } else if get_field_case_insensitive(args, "keyword").is_some() {
603                Some("tools")
604            } else if get_field_case_insensitive(args, "url").is_some() {
605                Some("web")
606            } else if get_field_case_insensitive(args, "sub_action").is_some()
607                || get_field_case_insensitive(args, "name").is_some()
608            {
609                Some("skill")
610            } else if get_field_case_insensitive(args, "scope").is_some() {
611                Some("errors")
612            } else if get_field_case_insensitive(args, "path").is_some() {
613                Some("list")
614            } else {
615                None
616            }
617        })
618}
619
620fn is_unified_search_arg_key(key: &str) -> bool {
621    matches!(
622        key.to_ascii_lowercase().as_str(),
623        "action"
624            | "pattern"
625            | "query"
626            | "path"
627            | "lang"
628            | "selector"
629            | "strictness"
630            | "debug_query"
631            | "workflow"
632            | "config_path"
633            | "filter"
634            | "globs"
635            | "context_lines"
636            | "max_results"
637            | "skip_snapshot_tests"
638            | "keyword"
639            | "url"
640            | "scope"
641            | "sub_action"
642            | "name"
643    )
644}
645
646fn object_has_unified_search_signal(args: &serde_json::Map<String, Value>) -> bool {
647    args.keys().any(|key| is_unified_search_arg_key(key))
648}
649
650fn parse_object_json_string(payload: &str) -> Option<serde_json::Map<String, Value>> {
651    let parsed = serde_json::from_str::<Value>(payload).ok()?;
652    parsed.as_object().cloned()
653}
654
655fn extract_unified_search_args_object(args: &Value) -> Option<serde_json::Map<String, Value>> {
656    match args {
657        Value::Object(args_obj) => {
658            if object_has_unified_search_signal(args_obj) {
659                return Some(args_obj.clone());
660            }
661
662            for wrapper in ["arguments", "args"] {
663                let Some(candidate) = get_field_case_insensitive(args_obj, wrapper) else {
664                    continue;
665                };
666                match candidate {
667                    Value::Object(inner_obj) => return Some(inner_obj.clone()),
668                    Value::String(inner_str) => {
669                        if let Some(parsed_obj) = parse_object_json_string(inner_str) {
670                            return Some(parsed_obj);
671                        }
672                    }
673                    _ => {}
674                }
675            }
676
677            Some(args_obj.clone())
678        }
679        Value::String(raw) => parse_object_json_string(raw),
680        _ => None,
681    }
682}
683
684/// Determine the action for unified_search tool based on args.
685/// Returns the action string or None if no inference is possible.
686pub fn unified_search_action(args: &Value) -> Option<&str> {
687    let args_obj = args.as_object()?;
688    unified_search_action_from_object(args_obj)
689}
690
691/// Normalize unified_search args so case/shape variants still pass schema checks.
692pub fn normalize_unified_search_args(args: &Value) -> Value {
693    let Some(args_obj) = extract_unified_search_args_object(args) else {
694        return args.clone();
695    };
696
697    let mut normalized = serde_json::Map::with_capacity(args_obj.len() + 1);
698    for (key, value) in &args_obj {
699        let canonical = if key.eq_ignore_ascii_case("action") {
700            "action"
701        } else if key.eq_ignore_ascii_case("pattern") {
702            "pattern"
703        } else if key.eq_ignore_ascii_case("query") {
704            "query"
705        } else if key.eq_ignore_ascii_case("path") {
706            "path"
707        } else if key.eq_ignore_ascii_case("lang") {
708            "lang"
709        } else if key.eq_ignore_ascii_case("selector") {
710            "selector"
711        } else if key.eq_ignore_ascii_case("strictness") {
712            "strictness"
713        } else if key.eq_ignore_ascii_case("debug_query") || key.eq_ignore_ascii_case("debug-query")
714        {
715            "debug_query"
716        } else if key.eq_ignore_ascii_case("workflow") {
717            "workflow"
718        } else if key.eq_ignore_ascii_case("config_path") || key.eq_ignore_ascii_case("config-path")
719        {
720            "config_path"
721        } else if key.eq_ignore_ascii_case("filter") {
722            "filter"
723        } else if key.eq_ignore_ascii_case("globs") {
724            "globs"
725        } else if key.eq_ignore_ascii_case("context_lines")
726            || key.eq_ignore_ascii_case("context-lines")
727        {
728            "context_lines"
729        } else if key.eq_ignore_ascii_case("max_results") || key.eq_ignore_ascii_case("max-results")
730        {
731            "max_results"
732        } else if key.eq_ignore_ascii_case("skip_snapshot_tests")
733            || key.eq_ignore_ascii_case("skip-snapshot-tests")
734        {
735            "skip_snapshot_tests"
736        } else if key.eq_ignore_ascii_case("keyword") {
737            "keyword"
738        } else if key.eq_ignore_ascii_case("url") {
739            "url"
740        } else if key.eq_ignore_ascii_case("scope") {
741            "scope"
742        } else if key.eq_ignore_ascii_case("sub_action") {
743            "sub_action"
744        } else if key.eq_ignore_ascii_case("name") {
745            "name"
746        } else if key.eq_ignore_ascii_case("name_pattern")
747            || key.eq_ignore_ascii_case("name-pattern")
748        {
749            "name_pattern"
750        } else {
751            key
752        };
753        normalized
754            .entry(canonical.to_string())
755            .or_insert_with(|| value.clone());
756    }
757
758    let inferred_action = unified_search_action_from_object(&normalized).map(|a| a.to_string());
759    if let Some(action) = inferred_action {
760        normalized
761            .entry("action".to_string())
762            .or_insert_with(|| Value::String(action));
763    }
764
765    let action = normalized
766        .get("action")
767        .and_then(Value::as_str)
768        .unwrap_or_default()
769        .to_string();
770    let keyword_alias = normalized
771        .get("keyword")
772        .and_then(Value::as_str)
773        .map(str::trim)
774        .filter(|value| !value.is_empty())
775        .map(str::to_string);
776    let pattern_alias = normalized
777        .get("pattern")
778        .and_then(Value::as_str)
779        .map(str::trim)
780        .filter(|value| !value.is_empty())
781        .map(str::to_string)
782        .or_else(|| {
783            normalized
784                .get("query")
785                .and_then(Value::as_str)
786                .map(str::trim)
787                .filter(|value| !value.is_empty())
788                .map(str::to_string)
789        })
790        .or_else(|| keyword_alias.clone());
791
792    if action.eq_ignore_ascii_case("grep")
793        && let Some(pattern) = pattern_alias.clone()
794    {
795        normalized
796            .entry("pattern".to_string())
797            .or_insert_with(|| Value::String(pattern));
798    }
799
800    if action.eq_ignore_ascii_case("list")
801        && !normalized.contains_key("pattern")
802        && !normalized.contains_key("name_pattern")
803    {
804        if let Some(keyword) = keyword_alias {
805            normalized
806                .entry("name_pattern".to_string())
807                .or_insert_with(|| Value::String(keyword));
808        } else if let Some(pattern) = pattern_alias {
809            normalized
810                .entry("pattern".to_string())
811                .or_insert_with(|| Value::String(pattern));
812        }
813    }
814
815    Value::Object(normalized)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::{
821        canonical_unified_exec_tool_name, classify_tool_intent, is_command_run_tool_call,
822        is_edited_file_conflict_guarded_call, is_parallel_safe_call, normalize_unified_search_args,
823        remap_unified_file_command_args_to_unified_exec, should_use_spool_reference_only,
824        unified_file_action,
825    };
826    use crate::config::constants::tools;
827    use serde_json::json;
828
829    #[test]
830    fn unified_file_read_is_retry_safe() {
831        let intent = classify_tool_intent(
832            tools::UNIFIED_FILE,
833            &json!({"action": "read", "path": "README.md"}),
834        );
835        assert!(!intent.mutating);
836        assert!(intent.readonly_unified_action);
837        assert!(intent.retry_safe);
838    }
839
840    #[test]
841    fn unified_exec_poll_is_retry_safe() {
842        let intent = classify_tool_intent(
843            tools::UNIFIED_EXEC,
844            &json!({"action": "poll", "session_id": 1}),
845        );
846        assert!(!intent.mutating);
847        assert!(intent.readonly_unified_action);
848        assert!(intent.retry_safe);
849    }
850
851    #[test]
852    fn unified_exec_inspect_is_retry_safe() {
853        let intent = classify_tool_intent(
854            tools::UNIFIED_EXEC,
855            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
856        );
857        assert!(!intent.mutating);
858        assert!(intent.readonly_unified_action);
859        assert!(intent.retry_safe);
860    }
861
862    #[test]
863    fn unified_exec_continue_without_input_is_retry_safe() {
864        let intent = classify_tool_intent(
865            tools::UNIFIED_EXEC,
866            &json!({"action": "continue", "session_id": "run-1"}),
867        );
868        assert!(!intent.mutating);
869        assert!(intent.readonly_unified_action);
870        assert!(intent.retry_safe);
871    }
872
873    #[test]
874    fn unified_exec_continue_with_input_is_mutating_and_destructive() {
875        let intent = classify_tool_intent(
876            tools::UNIFIED_EXEC,
877            &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
878        );
879        assert!(intent.mutating);
880        assert!(intent.destructive);
881        assert!(!intent.readonly_unified_action);
882        assert!(!intent.retry_safe);
883    }
884
885    #[test]
886    fn unified_exec_continue_with_empty_input_stays_retry_safe() {
887        let intent = classify_tool_intent(
888            tools::UNIFIED_EXEC,
889            &json!({"action": "continue", "session_id": "run-1", "input": ""}),
890        );
891        assert!(!intent.mutating);
892        assert!(intent.readonly_unified_action);
893        assert!(intent.retry_safe);
894    }
895
896    #[test]
897    fn unified_exec_run_is_mutating_and_destructive() {
898        let intent = classify_tool_intent(
899            tools::UNIFIED_EXEC,
900            &json!({"action": "run", "command": "echo hi"}),
901        );
902        assert!(intent.mutating);
903        assert!(intent.destructive);
904        assert!(!intent.retry_safe);
905    }
906
907    #[test]
908    fn unified_exec_run_allowlisted_is_read_only() {
909        let intent = classify_tool_intent(
910            tools::UNIFIED_EXEC,
911            &json!({"action": "run", "command": "rg planning_active src"}),
912        );
913        assert!(!intent.mutating);
914        assert!(intent.readonly_unified_action);
915        assert!(intent.retry_safe);
916    }
917
918    #[test]
919    fn unified_exec_run_dry_run_is_read_only() {
920        let intent = classify_tool_intent(
921            tools::UNIFIED_EXEC,
922            &json!({"action": "run", "command": "npm install --dry-run"}),
923        );
924        assert!(!intent.mutating);
925        assert!(intent.readonly_unified_action);
926        assert!(intent.retry_safe);
927    }
928
929    #[test]
930    fn parallel_safe_calls_reject_control_and_exec_paths() {
931        assert!(is_parallel_safe_call(
932            tools::READ_FILE,
933            &json!({"path": "README.md"})
934        ));
935        assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
936        assert!(!is_parallel_safe_call(
937            tools::REQUEST_USER_INPUT,
938            &json!({"questions": []})
939        ));
940        assert!(!is_parallel_safe_call(
941            tools::UNIFIED_EXEC,
942            &json!({"action": "inspect", "session_id": "run-1"})
943        ));
944    }
945
946    #[test]
947    fn unified_exec_cmd_alias_infers_run() {
948        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "echo hi"}));
949        assert!(intent.mutating);
950        assert!(intent.destructive);
951        assert!(!intent.readonly_unified_action);
952    }
953
954    #[test]
955    fn unified_exec_chars_alias_infers_write() {
956        let intent = classify_tool_intent(
957            tools::UNIFIED_EXEC,
958            &json!({"session_id": "abc123", "chars": "status\n"}),
959        );
960        assert!(intent.mutating);
961        assert!(intent.destructive);
962        assert!(!intent.readonly_unified_action);
963    }
964
965    #[test]
966    fn unified_exec_text_alias_infers_write() {
967        let intent = classify_tool_intent(
968            tools::UNIFIED_EXEC,
969            &json!({"session_id": "abc123", "text": "status\n"}),
970        );
971        assert!(intent.mutating);
972        assert!(intent.destructive);
973        assert!(!intent.readonly_unified_action);
974    }
975
976    #[test]
977    fn unified_exec_spool_path_alias_infers_inspect() {
978        let intent = classify_tool_intent(
979            tools::UNIFIED_EXEC,
980            &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
981        );
982        assert!(!intent.mutating);
983        assert!(!intent.destructive);
984        assert!(intent.readonly_unified_action);
985    }
986
987    #[test]
988    fn unified_exec_compact_session_alias_infers_poll() {
989        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
990        assert!(!intent.mutating);
991        assert!(!intent.destructive);
992        assert!(intent.readonly_unified_action);
993    }
994
995    #[test]
996    fn unified_file_input_patch_infers_patch() {
997        let args = json!({
998            "input": "*** Begin Patch\n*** End Patch\n"
999        });
1000        let action = unified_file_action(&args);
1001        assert_eq!(action, Some("patch"));
1002    }
1003
1004    #[test]
1005    fn unified_file_raw_patch_infers_patch() {
1006        let args = json!("*** Begin Patch\n*** Update File: src/main.rs\n*** End Patch\n");
1007        let action = unified_file_action(&args);
1008        assert_eq!(action, Some("patch"));
1009    }
1010
1011    #[test]
1012    fn unified_file_unknown_args_require_action() {
1013        let args = json!({
1014            "unexpected": true
1015        });
1016        let action = unified_file_action(&args);
1017        assert_eq!(action, None);
1018    }
1019
1020    #[test]
1021    fn unified_file_compact_path_alias_infers_read() {
1022        let args = json!({
1023            "p": "README.md"
1024        });
1025        let action = unified_file_action(&args);
1026        assert_eq!(action, Some("read"));
1027    }
1028
1029    #[test]
1030    fn remap_unified_file_command_args_maps_command_payload_to_unified_exec() {
1031        let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1032            "command": "cargo check",
1033            "cwd": ".",
1034            "timeout_ms": 1000
1035        }))
1036        .expect("command payload should remap");
1037
1038        assert_eq!(remapped["action"], "run");
1039        assert_eq!(remapped["command"], "cargo check");
1040        assert_eq!(remapped["cwd"], ".");
1041        assert_eq!(remapped["timeout_ms"], 1000);
1042    }
1043
1044    #[test]
1045    fn remap_unified_file_command_args_accepts_exec_action_aliases() {
1046        let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1047            "action": "shell",
1048            "cmd": "echo ok"
1049        }))
1050        .expect("shell action alias should remap");
1051
1052        assert_eq!(remapped["action"], "run");
1053        assert_eq!(remapped["command"], "echo ok");
1054    }
1055
1056    #[test]
1057    fn remap_unified_file_command_args_rejects_non_command_actions() {
1058        let remapped = remap_unified_file_command_args_to_unified_exec(&json!({
1059            "action": "read",
1060            "command": "echo ok"
1061        }));
1062
1063        assert_eq!(remapped, None);
1064    }
1065
1066    #[test]
1067    fn edited_file_conflict_guard_accepts_supported_mutations() {
1068        assert!(is_edited_file_conflict_guarded_call(
1069            tools::WRITE_FILE,
1070            &json!({"path": "README.md", "content": "agent"})
1071        ));
1072        assert!(is_edited_file_conflict_guarded_call(
1073            tools::CREATE_FILE,
1074            &json!({"path": "README.md", "content": "agent"})
1075        ));
1076        assert!(is_edited_file_conflict_guarded_call(
1077            tools::EDIT_FILE,
1078            &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
1079        ));
1080        assert!(is_edited_file_conflict_guarded_call(
1081            tools::APPLY_PATCH,
1082            &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1083        ));
1084        assert!(is_edited_file_conflict_guarded_call(
1085            tools::UNIFIED_FILE,
1086            &json!({"action": "write", "path": "README.md", "content": "agent"})
1087        ));
1088        assert!(is_edited_file_conflict_guarded_call(
1089            tools::UNIFIED_FILE,
1090            &json!({"action": "create", "path": "README.md", "content": "agent"})
1091        ));
1092        assert!(is_edited_file_conflict_guarded_call(
1093            tools::UNIFIED_FILE,
1094            &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
1095        ));
1096    }
1097
1098    #[test]
1099    fn edited_file_conflict_guard_rejects_non_guarded_calls() {
1100        assert!(!is_edited_file_conflict_guarded_call(
1101            tools::READ_FILE,
1102            &json!({"path": "README.md"})
1103        ));
1104        assert!(!is_edited_file_conflict_guarded_call(
1105            tools::GREP_FILE,
1106            &json!({"pattern": "needle", "path": "."})
1107        ));
1108        assert!(!is_edited_file_conflict_guarded_call(
1109            tools::LIST_FILES,
1110            &json!({"path": "."})
1111        ));
1112        assert!(!is_edited_file_conflict_guarded_call(
1113            tools::UNIFIED_FILE,
1114            &json!({"action": "read", "path": "README.md"})
1115        ));
1116        assert!(!is_edited_file_conflict_guarded_call(
1117            tools::UNIFIED_FILE,
1118            &json!({"action": "delete", "path": "README.md"})
1119        ));
1120        assert!(!is_edited_file_conflict_guarded_call(
1121            tools::UNIFIED_EXEC,
1122            &json!({"action": "run", "command": "git status"})
1123        ));
1124    }
1125
1126    #[test]
1127    fn normalize_unified_search_args_canonicalizes_case_and_infers_action() {
1128        let normalized = normalize_unified_search_args(&json!({
1129            "Pattern": "needle",
1130            "Path": "."
1131        }));
1132
1133        assert_eq!(normalized["pattern"], "needle");
1134        assert_eq!(normalized["path"], ".");
1135        assert_eq!(normalized["action"], "grep");
1136    }
1137
1138    #[test]
1139    fn normalize_unified_search_args_infers_list_for_glob_patterns() {
1140        let normalized = normalize_unified_search_args(&json!({
1141            "Pattern": "**/*.rs",
1142            "Path": "src"
1143        }));
1144
1145        assert_eq!(normalized["pattern"], "**/*.rs");
1146        assert_eq!(normalized["path"], "src");
1147        assert_eq!(normalized["action"], "list");
1148    }
1149
1150    #[test]
1151    fn normalize_unified_search_args_unwraps_arguments_object() {
1152        let normalized = normalize_unified_search_args(&json!({
1153            "arguments": {
1154                "Pattern": "needle",
1155                "Path": "."
1156            }
1157        }));
1158
1159        assert_eq!(normalized["pattern"], "needle");
1160        assert_eq!(normalized["path"], ".");
1161        assert_eq!(normalized["action"], "grep");
1162    }
1163
1164    #[test]
1165    fn normalize_unified_search_args_parses_arguments_json_string() {
1166        let normalized = normalize_unified_search_args(&json!({
1167            "args": "{\"Pattern\":\"needle\",\"Path\":\".\"}"
1168        }));
1169
1170        assert_eq!(normalized["pattern"], "needle");
1171        assert_eq!(normalized["path"], ".");
1172        assert_eq!(normalized["action"], "grep");
1173    }
1174
1175    #[test]
1176    fn normalize_unified_search_args_keeps_structural_action_explicit() {
1177        let normalized = normalize_unified_search_args(&json!({
1178            "Action": "structural",
1179            "Pattern": "fn $NAME() {}",
1180            "Lang": "rust",
1181            "Debug-Query": "ast",
1182            "Max-Results": 5
1183        }));
1184
1185        assert_eq!(normalized["action"], "structural");
1186        assert_eq!(normalized["pattern"], "fn $NAME() {}");
1187        assert_eq!(normalized["lang"], "rust");
1188        assert_eq!(normalized["debug_query"], "ast");
1189        assert_eq!(normalized["max_results"], 5);
1190    }
1191
1192    #[test]
1193    fn normalize_unified_search_args_infers_structural_from_pattern_and_lang() {
1194        let normalized = normalize_unified_search_args(&json!({
1195            "Pattern": "fn $NAME($$$ARGS) { $$$BODY }",
1196            "Lang": "rust",
1197            "Path": "."
1198        }));
1199
1200        assert_eq!(normalized["action"], "structural");
1201        assert_eq!(normalized["lang"], "rust");
1202        assert_eq!(normalized["path"], ".");
1203    }
1204
1205    #[test]
1206    fn normalize_unified_search_args_canonicalizes_structural_workflow_fields() {
1207        let normalized = normalize_unified_search_args(&json!({
1208            "Workflow": "scan",
1209            "Config-Path": "config/sgconfig.yml",
1210            "Filter": "rust/no-iterator-for-each",
1211            "Skip-Snapshot-Tests": true
1212        }));
1213
1214        assert_eq!(normalized["workflow"], "scan");
1215        assert_eq!(normalized["config_path"], "config/sgconfig.yml");
1216        assert_eq!(normalized["filter"], "rust/no-iterator-for-each");
1217        assert_eq!(normalized["skip_snapshot_tests"], true);
1218        assert_eq!(normalized["action"], "structural");
1219    }
1220
1221    #[test]
1222    fn normalize_unified_search_args_maps_keyword_to_pattern_for_grep() {
1223        let normalized = normalize_unified_search_args(&json!({
1224            "action": "grep",
1225            "keyword": "system prompt",
1226            "path": "src"
1227        }));
1228
1229        assert_eq!(normalized["action"], "grep");
1230        assert_eq!(normalized["pattern"], "system prompt");
1231        assert_eq!(normalized["path"], "src");
1232    }
1233
1234    #[test]
1235    fn normalize_unified_search_args_maps_keyword_to_name_pattern_for_list() {
1236        let normalized = normalize_unified_search_args(&json!({
1237            "action": "list",
1238            "keyword": "agent",
1239            "path": "vtcode-core/src",
1240            "mode": "file"
1241        }));
1242
1243        assert_eq!(normalized["action"], "list");
1244        assert_eq!(normalized["name_pattern"], "agent");
1245        assert_eq!(normalized["path"], "vtcode-core/src");
1246        assert_eq!(normalized["mode"], "file");
1247    }
1248
1249    #[test]
1250    fn normalize_unified_search_args_maps_query_to_pattern_for_grep() {
1251        let normalized = normalize_unified_search_args(&json!({
1252            "action": "grep",
1253            "query": "Result<",
1254            "path": "vtcode-core/src"
1255        }));
1256
1257        assert_eq!(normalized["action"], "grep");
1258        assert_eq!(normalized["pattern"], "Result<");
1259        assert_eq!(normalized["path"], "vtcode-core/src");
1260    }
1261
1262    #[test]
1263    fn legacy_search_aliases_are_readonly() {
1264        let grep_intent =
1265            classify_tool_intent(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."}));
1266        assert!(!grep_intent.mutating);
1267        assert!(!grep_intent.destructive);
1268
1269        let list_intent = classify_tool_intent(tools::LIST_FILES, &json!({"path": "."}));
1270        assert!(!list_intent.mutating);
1271        assert!(!list_intent.destructive);
1272    }
1273
1274    #[test]
1275    fn canonical_unified_exec_tool_name_normalizes_exec_aliases() {
1276        for alias in [
1277            tools::UNIFIED_EXEC,
1278            tools::RUN_PTY_CMD,
1279            tools::SEND_PTY_INPUT,
1280            tools::READ_PTY_SESSION,
1281            tools::LIST_PTY_SESSIONS,
1282            tools::CLOSE_PTY_SESSION,
1283            tools::EXECUTE_CODE,
1284            tools::EXEC_PTY_CMD,
1285            tools::EXEC_COMMAND,
1286            tools::WRITE_STDIN,
1287            tools::SHELL,
1288            "bash",
1289            "exec",
1290            "container.exec",
1291        ] {
1292            assert_eq!(
1293                canonical_unified_exec_tool_name(alias),
1294                Some(tools::UNIFIED_EXEC)
1295            );
1296        }
1297    }
1298
1299    #[test]
1300    fn spool_reference_only_detects_exec_aliases() {
1301        assert!(should_use_spool_reference_only(
1302            Some(tools::RUN_PTY_CMD),
1303            &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
1304        ));
1305    }
1306
1307    #[test]
1308    fn spool_reference_only_detects_unified_search_payloads() {
1309        assert!(should_use_spool_reference_only(
1310            Some(tools::UNIFIED_SEARCH),
1311            &json!({
1312                "spool_path": ".vtcode/context/tool_outputs/unified_search_1.txt",
1313                "matches": []
1314            })
1315        ));
1316    }
1317
1318    #[test]
1319    fn spool_reference_only_detects_unified_search_payload_without_tool_name() {
1320        assert!(should_use_spool_reference_only(
1321            None,
1322            &json!({
1323                "spool_path": ".vtcode/context/tool_outputs/unified_search_1.txt",
1324                "matches": []
1325            })
1326        ));
1327    }
1328
1329    #[test]
1330    fn spool_reference_only_detects_exec_payload_without_tool_name() {
1331        assert!(should_use_spool_reference_only(
1332            None,
1333            &json!({
1334                "command": "cargo check",
1335                "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1336                "exit_code": 0
1337            })
1338        ));
1339    }
1340
1341    #[test]
1342    fn spool_reference_only_skips_loop_recovery_payloads() {
1343        assert!(!should_use_spool_reference_only(
1344            Some(tools::UNIFIED_EXEC),
1345            &json!({
1346                "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
1347                "exit_code": 0,
1348                "loop_detected": true
1349            })
1350        ));
1351    }
1352
1353    #[test]
1354    fn is_command_run_tool_call_only_accepts_run_actions() {
1355        assert!(is_command_run_tool_call(
1356            tools::RUN_PTY_CMD,
1357            &json!({"command": "cargo check"})
1358        ));
1359        assert!(is_command_run_tool_call(
1360            tools::UNIFIED_EXEC,
1361            &json!({"action": "run", "command": "cargo check"})
1362        ));
1363        assert!(is_command_run_tool_call(
1364            tools::EXEC_COMMAND,
1365            &json!({"cmd": "cargo check"})
1366        ));
1367        assert!(!is_command_run_tool_call(
1368            tools::UNIFIED_EXEC,
1369            &json!({"action": "poll", "session_id": "run-1"})
1370        ));
1371        assert!(!is_command_run_tool_call(
1372            tools::WRITE_STDIN,
1373            &json!({"session_id": "run-1", "chars": "q"})
1374        ));
1375    }
1376}