Skip to main content

vtcode_core/tools/tool_intent/
mod.rs

1//! Tool intent classification: mutating vs read-only, parallel-safe, and
2//! action extraction for unified tool surfaces.
3
4mod mutation;
5pub use mutation::{is_docs_only_write, is_plan_artifact_write, tmp_plans_dir};
6
7pub mod actions;
8pub mod activity;
9pub mod classify;
10pub mod readonly;
11pub mod types;
12
13pub use actions::{
14    action_qualified_policy_name, command_session_action, command_session_action_in, command_session_action_is,
15    file_operation_action, file_operation_action_in, file_operation_action_is, mcp_action, mcp_action_is,
16};
17pub use activity::{
18    GENERIC_VERIFIER_DESCRIPTION, ShellActivity, VERIFIER_SHELL_FORM_NOTE, classify_shell_activity,
19    default_verifier_for_workspace, exec_mutation_should_incur_debt, rewrite_truncation_only_verifier,
20    shell_args_as_executed, shell_command_contains_verifier, shell_command_is_admitted_verification_attempt,
21    shell_command_is_output_search, shell_command_is_standalone_grep_search, verification_recovery_directive,
22    verifier_reference,
23};
24pub use classify::{
25    builtin_tool_behavior, canonical_command_session_tool_name, classify_tool_intent, is_command_run_tool,
26    is_command_run_tool_call, is_command_session_tool, is_command_tool, is_edited_file_conflict_guarded_call,
27    is_exec_session_cleanup_call, is_parallel_safe_call, is_parallel_safe_call_with_intent, is_turn_budget_exempt_call,
28    planning_allowed_actions, remap_file_operation_command_args_to_command_session, should_use_spool_reference_only,
29};
30pub use readonly::{is_readonly_command_session_command, is_spool_file_read_command};
31pub use types::{ToolBehavior, ToolIntent, ToolIntentClassifier, ToolMutationModel, ToolSurfaceKind};
32
33#[cfg(test)]
34mod tests {
35    use super::{
36        canonical_command_session_tool_name, classify_tool_intent, file_operation_action, is_command_run_tool_call,
37        is_command_session_tool, is_edited_file_conflict_guarded_call, is_parallel_safe_call,
38        is_parallel_safe_call_with_intent, remap_file_operation_command_args_to_command_session,
39        should_use_spool_reference_only,
40    };
41    use crate::config::constants::tools;
42    use serde_json::json;
43
44    #[test]
45    fn file_operation_read_is_retry_safe() {
46        let intent = classify_tool_intent(tools::UNIFIED_FILE, &json!({"action": "read", "path": "README.md"}));
47        assert!(!intent.mutating);
48        assert!(intent.readonly_unified_action);
49        assert!(intent.retry_safe);
50    }
51
52    #[test]
53    fn command_session_poll_is_retry_safe() {
54        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": 1}));
55        assert!(!intent.mutating);
56        assert!(intent.readonly_unified_action);
57        assert!(intent.retry_safe);
58    }
59
60    #[test]
61    fn turn_budget_exempt_covers_wait_and_inspect_only() {
62        use super::is_turn_budget_exempt_call;
63
64        // Exec-family wait/inspect calls are exempt.
65        assert!(is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "wait", "session_id": "run-1"})));
66        assert!(is_turn_budget_exempt_call(
67            tools::UNIFIED_EXEC,
68            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
69        ));
70        assert!(is_turn_budget_exempt_call(tools::WRITE_STDIN, &json!({"action": "wait", "session_id": "run-1"})));
71        assert!(is_turn_budget_exempt_call(tools::EXEC_COMMAND, &json!({"action": "wait", "session_id": "run-1"})));
72
73        // Work-producing exec calls are NOT exempt.
74        assert!(!is_turn_budget_exempt_call(
75            tools::UNIFIED_EXEC,
76            &json!({"action": "run", "command": "./scripts/check-dev.sh"})
77        ));
78        assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
79        assert!(!is_turn_budget_exempt_call(
80            tools::UNIFIED_EXEC,
81            &json!({"action": "continue", "session_id": "run-1", "input": "y"})
82        ));
83        assert!(!is_turn_budget_exempt_call(tools::UNIFIED_EXEC, &json!({"action": "list"})));
84
85        // Non-exec tools are never exempt, even with matching action strings.
86        assert!(!is_turn_budget_exempt_call(tools::READ_FILE, &json!({"action": "wait", "path": "x.txt"})));
87        assert!(!is_turn_budget_exempt_call(tools::APPLY_PATCH, &json!({"action": "inspect", "input": "..."})));
88        assert!(!is_turn_budget_exempt_call(tools::UNIFIED_FILE, &json!({"action": "read", "path": "x.txt"})));
89
90        // Alias surface names normalize to the canonical exec surface.
91        assert!(is_turn_budget_exempt_call(tools::RUN_PTY_CMD, &json!({"action": "wait", "session_id": "run-1"})));
92    }
93
94    #[test]
95    fn skill_loading_is_mutating_and_requires_confirmation() {
96        let intent = classify_tool_intent(tools::LOAD_SKILL, &json!({"name": "example"}));
97        assert!(intent.mutating);
98        assert!(intent.destructive);
99        assert!(!intent.retry_safe);
100    }
101
102    #[test]
103    fn command_session_inspect_is_retry_safe() {
104        let intent = classify_tool_intent(
105            tools::UNIFIED_EXEC,
106            &json!({"action": "inspect", "spool_path": ".vtcode/context/tool_outputs/run-1.txt"}),
107        );
108        assert!(!intent.mutating);
109        assert!(intent.readonly_unified_action);
110        assert!(intent.retry_safe);
111    }
112
113    #[test]
114    fn command_session_continue_without_input_is_retry_safe() {
115        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "continue", "session_id": "run-1"}));
116        assert!(!intent.mutating);
117        assert!(intent.readonly_unified_action);
118        assert!(intent.retry_safe);
119    }
120
121    #[test]
122    fn command_session_continue_with_input_is_mutating_and_destructive() {
123        let intent = classify_tool_intent(
124            tools::UNIFIED_EXEC,
125            &json!({"action": "continue", "session_id": "run-1", "input": "q"}),
126        );
127        assert!(intent.mutating);
128        assert!(intent.destructive);
129        assert!(!intent.readonly_unified_action);
130        assert!(!intent.retry_safe);
131    }
132
133    #[test]
134    fn command_session_continue_with_empty_input_stays_retry_safe() {
135        let intent = classify_tool_intent(
136            tools::UNIFIED_EXEC,
137            &json!({"action": "continue", "session_id": "run-1", "input": ""}),
138        );
139        assert!(!intent.mutating);
140        assert!(intent.readonly_unified_action);
141        assert!(intent.retry_safe);
142    }
143
144    #[test]
145    fn command_session_run_is_mutating_and_destructive() {
146        let intent =
147            classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rm -rf /tmp/test"}));
148        assert!(intent.mutating);
149        assert!(intent.destructive);
150        assert!(!intent.retry_safe);
151    }
152
153    #[test]
154    fn command_session_run_echo_is_read_only() {
155        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "echo hi"}));
156        assert!(!intent.mutating);
157        assert!(intent.readonly_unified_action);
158        assert!(intent.retry_safe);
159    }
160
161    #[test]
162    fn command_session_run_diff_is_read_only() {
163        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "diff a.rs b.rs"}));
164        assert!(!intent.mutating);
165        assert!(intent.readonly_unified_action);
166        assert!(intent.retry_safe);
167    }
168
169    #[test]
170    fn command_session_run_find_is_read_only() {
171        let intent = classify_tool_intent(
172            tools::UNIFIED_EXEC,
173            &json!({"action": "run", "command": "find . -type f -name '*.rs' -not -path '*/target/*'"}),
174        );
175        assert!(!intent.mutating);
176        assert!(intent.readonly_unified_action);
177        assert!(intent.retry_safe);
178    }
179
180    #[test]
181    fn command_session_run_grep_wc_head_are_read_only() {
182        for cmd in [
183            "grep -rn 'todo' src",
184            "wc -l src/main.rs",
185            "head -50 src/lib.rs",
186            "tail -20 src/lib.rs",
187            "sort src/words.txt | uniq",
188            "ast-grep -p 'foo($A)' -l rs",
189        ] {
190            let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
191            assert!(!intent.mutating, "expected '{cmd}' to be read-only");
192            assert!(intent.readonly_unified_action, "expected '{cmd}' to be readonly_unified_action");
193        }
194    }
195
196    #[test]
197    fn command_session_run_with_redirection_is_mutating() {
198        for cmd in [
199            "cat a.txt > b.txt",
200            "grep x src > out.txt",
201            "diff a b | wc -l > count.txt",
202            "echo $(date) > log.txt",
203        ] {
204            let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
205            assert!(intent.mutating, "expected '{cmd}' to be mutating because it contains redirection/substitution");
206        }
207    }
208
209    #[test]
210    fn command_session_run_find_with_destructive_flags_is_mutating() {
211        for cmd in [
212            "find . -type f -delete",
213            "find . -name '*.tmp' -exec rm {} \\;",
214            "find . -name '*.tmp' -exec chmod 600 {} \\;",
215        ] {
216            let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
217            assert!(intent.mutating, "expected '{cmd}' to be mutating because it has destructive find flags");
218        }
219    }
220
221    #[test]
222    fn command_session_run_pipelines_with_unsafe_segments_are_mutating() {
223        for cmd in ["cat a.txt | tee b.txt", "python script.py | cat", "grep x src | rm -rf"] {
224            let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": cmd}));
225            assert!(intent.mutating, "expected '{cmd}' to be mutating because a pipeline segment is unsafe");
226        }
227    }
228
229    #[test]
230    fn command_session_run_allowlisted_is_read_only() {
231        let intent =
232            classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "rg planning_active src"}));
233        assert!(!intent.mutating);
234        assert!(intent.readonly_unified_action);
235        assert!(intent.retry_safe);
236    }
237
238    #[test]
239    fn command_session_run_dry_run_is_read_only() {
240        let intent =
241            classify_tool_intent(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "npm install --dry-run"}));
242        assert!(!intent.mutating);
243        assert!(intent.readonly_unified_action);
244        assert!(intent.retry_safe);
245    }
246
247    #[test]
248    fn parallel_safe_calls_reject_control_and_exec_paths() {
249        assert!(is_parallel_safe_call(tools::READ_FILE, &json!({"path": "README.md"})));
250        assert!(!is_parallel_safe_call(tools::LIST_PTY_SESSIONS, &json!({})));
251        assert!(!is_parallel_safe_call(tools::REQUEST_USER_INPUT, &json!({"questions": []})));
252        assert!(!is_parallel_safe_call(tools::UNIFIED_EXEC, &json!({"action": "inspect", "session_id": "run-1"})));
253        assert!(is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "sed -n '1,40p' README.md"})));
254        assert!(!is_parallel_safe_call(tools::EXEC_COMMAND, &json!({"cmd": "cat README.md && cat Cargo.toml"})));
255        assert!(!is_parallel_safe_call(
256            tools::EXEC_COMMAND,
257            &json!({"cmd": "sed -n '1,40p' README.md", "tty": true})
258        ));
259    }
260
261    #[test]
262    fn parallel_safe_with_intent_matches_classifying_variant() {
263        // The with-intent variant must agree with the classifying variant for
264        // every branch: command-run payloads, non-command builtin behaviors,
265        // and unknown tools that fall back to plain intent classification.
266        for (tool_name, args) in [
267            (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rg -n 'todo' src"})),
268            (tools::UNIFIED_EXEC, json!({"action": "run", "command": "rm -rf /tmp/test"})),
269            (tools::UNIFIED_EXEC, json!({"action": "poll", "session_id": "run-1"})),
270            (tools::EXEC_COMMAND, json!({"cmd": "sed -n '1,40p' README.md"})),
271            (tools::READ_FILE, json!({"path": "README.md"})),
272            (tools::WRITE_FILE, json!({"path": "README.md", "content": "x"})),
273            (tools::APPLY_PATCH, json!({"input": "*** Begin Patch\n*** End Patch\n"})),
274            ("totally_unknown_tool", json!({"path": "README.md"})),
275        ] {
276            let with_intent =
277                is_parallel_safe_call_with_intent(tool_name, &args, &classify_tool_intent(tool_name, &args));
278            assert_eq!(
279                with_intent,
280                is_parallel_safe_call(tool_name, &args),
281                "variants disagree for {tool_name} with {args}"
282            );
283        }
284    }
285
286    #[test]
287    fn command_session_cmd_alias_infers_run() {
288        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"cmd": "rm -rf /tmp/test"}));
289        assert!(intent.mutating);
290        assert!(intent.destructive);
291        assert!(!intent.readonly_unified_action);
292    }
293
294    #[test]
295    fn command_session_chars_alias_infers_write() {
296        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "chars": "status\n"}));
297        assert!(intent.mutating);
298        assert!(intent.destructive);
299        assert!(!intent.readonly_unified_action);
300    }
301
302    #[test]
303    fn write_stdin_empty_chars_is_read_only_and_retry_safe() {
304        let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": ""}));
305
306        assert!(!intent.mutating);
307        assert!(!intent.destructive);
308        assert!(!intent.readonly_unified_action);
309        assert!(intent.retry_safe);
310    }
311
312    #[test]
313    fn write_stdin_non_empty_chars_is_mutating() {
314        let intent = classify_tool_intent(tools::WRITE_STDIN, &json!({"session_id": "abc123", "chars": "  status\n"}));
315
316        assert!(intent.mutating);
317        assert!(intent.destructive);
318        assert!(!intent.readonly_unified_action);
319        assert!(!intent.retry_safe);
320    }
321
322    #[test]
323    fn command_session_text_alias_infers_write() {
324        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"session_id": "abc123", "text": "status\n"}));
325        assert!(intent.mutating);
326        assert!(intent.destructive);
327        assert!(!intent.readonly_unified_action);
328    }
329
330    #[test]
331    fn command_session_spool_path_alias_infers_inspect() {
332        let intent =
333            classify_tool_intent(tools::UNIFIED_EXEC, &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"}));
334        assert!(!intent.mutating);
335        assert!(!intent.destructive);
336        assert!(intent.readonly_unified_action);
337    }
338
339    #[test]
340    fn command_session_compact_session_alias_infers_poll() {
341        let intent = classify_tool_intent(tools::UNIFIED_EXEC, &json!({"s": "run-1"}));
342        assert!(!intent.mutating);
343        assert!(!intent.destructive);
344        assert!(intent.readonly_unified_action);
345    }
346
347    #[test]
348    fn file_operation_unknown_args_require_action() {
349        let args = json!({
350            "unexpected": true
351        });
352        let action = file_operation_action(&args);
353        assert_eq!(action, None);
354    }
355
356    #[test]
357    fn file_operation_compact_path_alias_infers_read() {
358        let args = json!({
359            "p": "README.md"
360        });
361        let action = file_operation_action(&args);
362        assert_eq!(action, Some("read"));
363    }
364
365    #[test]
366    fn remap_file_operation_command_args_maps_command_payload_to_command_session() {
367        let remapped = remap_file_operation_command_args_to_command_session(&json!({
368            "command": "cargo check",
369            "cwd": ".",
370            "timeout_ms": 1000
371        }))
372        .expect("command payload should remap");
373
374        assert_eq!(remapped["action"], "run");
375        assert_eq!(remapped["command"], "cargo check");
376        assert_eq!(remapped["cwd"], ".");
377        assert_eq!(remapped["timeout_ms"], 1000);
378    }
379
380    #[test]
381    fn remap_file_operation_command_args_accepts_exec_action_aliases() {
382        let remapped = remap_file_operation_command_args_to_command_session(&json!({
383            "action": "shell",
384            "cmd": "echo ok"
385        }))
386        .expect("shell action alias should remap");
387
388        assert_eq!(remapped["action"], "run");
389        assert_eq!(remapped["command"], "echo ok");
390    }
391
392    #[test]
393    fn remap_file_operation_command_args_rejects_non_command_actions() {
394        let remapped = remap_file_operation_command_args_to_command_session(&json!({
395            "action": "read",
396            "command": "echo ok"
397        }));
398
399        assert_eq!(remapped, None);
400    }
401
402    #[test]
403    fn edited_file_conflict_guard_accepts_supported_mutations() {
404        assert!(is_edited_file_conflict_guarded_call(
405            tools::WRITE_FILE,
406            &json!({"path": "README.md", "content": "agent"})
407        ));
408        assert!(is_edited_file_conflict_guarded_call(
409            tools::CREATE_FILE,
410            &json!({"path": "README.md", "content": "agent"})
411        ));
412        assert!(is_edited_file_conflict_guarded_call(
413            tools::EDIT_FILE,
414            &json!({"path": "README.md", "old_str": "a", "new_str": "b"})
415        ));
416        assert!(is_edited_file_conflict_guarded_call(
417            tools::APPLY_PATCH,
418            &json!({"patch": "*** Begin Patch\n*** End Patch\n"})
419        ));
420        assert!(is_edited_file_conflict_guarded_call(
421            tools::UNIFIED_FILE,
422            &json!({"action": "write", "path": "README.md", "content": "agent"})
423        ));
424        assert!(is_edited_file_conflict_guarded_call(
425            tools::UNIFIED_FILE,
426            &json!({"action": "create", "path": "README.md", "content": "agent"})
427        ));
428    }
429
430    #[test]
431    fn edited_file_conflict_guard_rejects_non_guarded_calls() {
432        assert!(!is_edited_file_conflict_guarded_call(tools::READ_FILE, &json!({"path": "README.md"})));
433        assert!(!is_edited_file_conflict_guarded_call(tools::GREP_FILE, &json!({"pattern": "needle", "path": "."})));
434        assert!(!is_edited_file_conflict_guarded_call(tools::LIST_FILES, &json!({"path": "."})));
435        assert!(!is_edited_file_conflict_guarded_call(
436            tools::UNIFIED_FILE,
437            &json!({"action": "read", "path": "README.md"})
438        ));
439        assert!(!is_edited_file_conflict_guarded_call(
440            tools::UNIFIED_FILE,
441            &json!({"action": "delete", "path": "README.md"})
442        ));
443        assert!(!is_edited_file_conflict_guarded_call(
444            tools::UNIFIED_EXEC,
445            &json!({"action": "run", "command": "git status"})
446        ));
447    }
448
449    #[test]
450    fn canonical_command_session_tool_name_normalizes_exec_aliases() {
451        for alias in [
452            tools::UNIFIED_EXEC,
453            tools::RUN_PTY_CMD,
454            tools::SEND_PTY_INPUT,
455            tools::READ_PTY_SESSION,
456            tools::LIST_PTY_SESSIONS,
457            tools::CLOSE_PTY_SESSION,
458            tools::EXECUTE_CODE,
459            tools::EXEC_PTY_CMD,
460            tools::EXEC_COMMAND,
461            tools::WRITE_STDIN,
462            tools::SHELL,
463            "bash",
464            "exec",
465            "container.exec",
466        ] {
467            assert_eq!(canonical_command_session_tool_name(alias), Some(tools::UNIFIED_EXEC));
468            assert!(is_command_session_tool(alias));
469        }
470        assert!(!is_command_session_tool(tools::READ_FILE));
471        assert!(!is_command_session_tool(""));
472    }
473
474    #[test]
475    fn spool_reference_only_detects_exec_aliases() {
476        assert!(should_use_spool_reference_only(
477            Some(tools::RUN_PTY_CMD),
478            &json!({"spool_path": ".vtcode/context/tool_outputs/run-1.txt"})
479        ));
480    }
481
482    #[test]
483    fn spool_reference_only_detects_exec_payload_without_tool_name() {
484        assert!(should_use_spool_reference_only(
485            None,
486            &json!({
487                "command": "cargo check",
488                "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
489                "exit_code": 0
490            })
491        ));
492    }
493
494    #[test]
495    fn spool_reference_only_skips_loop_recovery_payloads() {
496        assert!(!should_use_spool_reference_only(
497            Some(tools::UNIFIED_EXEC),
498            &json!({
499                "spool_path": ".vtcode/context/tool_outputs/run-1.txt",
500                "exit_code": 0,
501                "loop_detected": true
502            })
503        ));
504    }
505
506    #[test]
507    fn is_command_run_tool_call_only_accepts_run_actions() {
508        assert!(is_command_run_tool_call(tools::RUN_PTY_CMD, &json!({"command": "cargo check"})));
509        assert!(is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "run", "command": "cargo check"})));
510        assert!(is_command_run_tool_call(tools::EXEC_COMMAND, &json!({"cmd": "cargo check"})));
511        assert!(!is_command_run_tool_call(tools::UNIFIED_EXEC, &json!({"action": "poll", "session_id": "run-1"})));
512        assert!(!is_command_run_tool_call(tools::WRITE_STDIN, &json!({"session_id": "run-1", "chars": "q"})));
513    }
514}