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