Skip to main content

vtcode_core/tools/tool_intent/
classify.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;
6use crate::tools::tool_intent::actions::{
7    action_matches_any, command_session_action_in, command_session_action_is, file_operation_action,
8    file_operation_action_is,
9};
10use crate::tools::tool_intent::readonly::{
11    is_parallel_safe_command_session_command, is_readonly_command_session_command,
12};
13use crate::tools::tool_intent::types::{ToolBehavior, ToolIntent, ToolMutationModel};
14
15/// Returns the subset of actions that are allowed for a multi-action tool
16/// when planning mode is active. Returns `None` for tools that are not
17/// multi-action or have no action-level restrictions.
18pub fn planning_allowed_actions(tool_name: &str) -> Option<&'static [&'static str]> {
19    let canonical = canonical_tool_name(tool_name);
20    match canonical {
21        tools::UNIFIED_FILE => Some(&["read"]),
22        tools::UNIFIED_EXEC => Some(&["run", "poll", "wait", "list", "inspect", "continue"]),
23        // `code`, `write`, and `close` are always mutating and excluded.
24        _ => None,
25    }
26}
27
28pub fn builtin_tool_behavior(tool_name: &str) -> Option<ToolBehavior> {
29    let canonical = canonical_tool_name(tool_name);
30    builtin_tool_behavior_canonical(canonical)
31}
32
33pub fn is_parallel_safe_call(tool_name: &str, args: &Value) -> bool {
34    is_parallel_safe_call_with_intent(tool_name, args, &classify_tool_intent(tool_name, args))
35}
36
37/// [`is_parallel_safe_call`] for a call whose [`ToolIntent`] was already
38/// computed on the same args. The non-command branches reuse `intent.mutating`
39/// (identical to re-classifying: `builtin_tool_behavior.classify(args)` IS
40/// `classify_tool_intent`), so preflight paths avoid re-classifying the same
41/// args — a second tree-sitter parse for command payloads. The command-run
42/// branch still re-parses: parallel safety for command sessions is a separate
43/// predicate from the read-only intent.
44pub fn is_parallel_safe_call_with_intent(tool_name: &str, args: &Value, intent: &ToolIntent) -> bool {
45    let canonical = canonical_tool_name(tool_name);
46    if matches!(canonical, tools::EXEC_COMMAND | tools::UNIFIED_EXEC) && is_command_run_tool_call(canonical, args) {
47        return is_parallel_safe_command_session_command(args);
48    }
49    if let Some(behavior) = builtin_tool_behavior_canonical(canonical) {
50        return behavior.supports_parallel_calls && !intent.mutating;
51    }
52
53    !intent.mutating
54}
55
56pub fn classify_tool_intent(tool_name: &str, args: &Value) -> ToolIntent {
57    let canonical = canonical_tool_name(tool_name);
58    builtin_tool_behavior_canonical(canonical)
59        .map(|behavior| behavior.classify(args))
60        .unwrap_or_else(ToolIntent::mutating)
61}
62
63pub fn is_edited_file_conflict_guarded_call(tool_name: &str, args: &Value) -> bool {
64    let canonical = canonical_tool_name(tool_name);
65    match canonical {
66        tools::WRITE_FILE | tools::CREATE_FILE | tools::EDIT_FILE | tools::APPLY_PATCH => true,
67        tools::UNIFIED_FILE => file_operation_action(args)
68            .map(is_edited_file_conflict_guarded_file_operation_action)
69            .unwrap_or(false),
70        _ => false,
71    }
72}
73
74fn is_edited_file_conflict_guarded_file_operation_action(action: &str) -> bool {
75    action_matches_any(Some(action), &["write", "create", "edit", "patch"])
76}
77
78pub fn canonical_command_session_tool_name(tool_name: &str) -> Option<&'static str> {
79    match tool_name {
80        tools::UNIFIED_EXEC
81        | tools::RUN_PTY_CMD
82        | tools::SEND_PTY_INPUT
83        | tools::CREATE_PTY_SESSION
84        | tools::READ_PTY_SESSION
85        | tools::LIST_PTY_SESSIONS
86        | tools::CLOSE_PTY_SESSION
87        | tools::EXECUTE_CODE
88        | tools::EXEC_PTY_CMD
89        | tools::EXEC_COMMAND
90        | tools::WRITE_STDIN
91        | tools::SHELL
92        | "bash"
93        | "exec"
94        | "container.exec" => Some(tools::UNIFIED_EXEC),
95        _ => None,
96    }
97}
98
99/// Returns `true` if `tool_name` is any alias of the unified command-session tool.
100#[must_use]
101pub fn is_command_session_tool(tool_name: &str) -> bool {
102    canonical_command_session_tool_name(tool_name).is_some()
103}
104
105pub fn should_use_spool_reference_only(tool_name: Option<&str>, output: &Value) -> bool {
106    let Some(obj) = output.as_object() else {
107        return false;
108    };
109
110    let has_spool_path = obj
111        .get("spool_path")
112        .and_then(Value::as_str)
113        .is_some_and(|path| !path.trim().is_empty());
114    if !has_spool_path {
115        return false;
116    }
117
118    if obj.get("loop_detected").and_then(Value::as_bool) == Some(true) {
119        return false;
120    }
121
122    if tool_name.is_some_and(is_command_session_tool) {
123        return true;
124    }
125
126    if obj
127        .get("content_type")
128        .and_then(Value::as_str)
129        .is_some_and(|content_type| content_type == "exec_inspect")
130    {
131        return true;
132    }
133
134    if tool_name.is_some_and(|name| {
135        name == tools::CODE_SEARCH
136            || name == tools::UNIFIED_SEARCH
137            || name == "code_search"
138            || name == "search_dispatch_internal"
139    }) {
140        return true;
141    }
142
143    ["command", "id", "session_id", "process_id", "is_exited", "exit_code"]
144        .iter()
145        .any(|key| obj.contains_key(*key))
146}
147
148/// Returns `true` if `tool_name` refers to a command/PTY execution tool.
149///
150/// This includes PTY session tools and all unified exec aliases.
151#[must_use]
152pub fn is_command_tool(tool_name: &str) -> bool {
153    tool_name == tools::CREATE_PTY_SESSION || tool_name == tools::SEND_PTY_INPUT || is_command_session_tool(tool_name)
154}
155
156/// Returns `true` if `tool_name` is a direct command-run tool whose output is
157/// rendered inline as a terminal panel and whose raw process output (including
158/// ANSI) should be preserved rather than stripped/line-styled.
159///
160/// This is the stable set shared by the TUI display gates (terminal-panel
161/// dispatch, ANSI preservation, and command-text summary). New command-run
162/// tools MUST be added here so the display stays in sync; the args-aware
163/// [`is_command_run_tool_call`] covers call-site routing.
164#[must_use]
165pub fn is_command_run_tool(tool_name: &str) -> bool {
166    matches!(tool_name, tools::RUN_PTY_CMD | tools::EXEC_COMMAND)
167}
168
169pub fn is_command_run_tool_call(tool_name: &str, args: &Value) -> bool {
170    match tool_name {
171        tools::RUN_PTY_CMD | tools::CREATE_PTY_SESSION | tools::SHELL | "bash" => true,
172        tools::EXEC_COMMAND => crate::tools::command_args::command_text(args).ok().flatten().is_some(),
173        tools::UNIFIED_EXEC | tools::EXEC_PTY_CMD | "exec" | "container.exec" => command_session_action_is(args, "run"),
174        _ => false,
175    }
176}
177
178/// Returns `true` for control-plane exec calls that should be exempt from the
179/// per-turn tool-call budget.
180///
181/// These calls coordinate long-running commands instead of producing new
182/// execution work:
183/// - blocking `wait` on an exec session (no model round-trips while waiting),
184/// - bounded `inspect` of a running session or a spooled output file.
185///
186/// A long build (e.g. `./scripts/check-dev.sh`) must not consume the budget
187/// its follow-up verification work needs, so waiting on it and reading its
188/// results should not charge the same ledger as fresh mutations. The
189/// exemption is still bounded by `MAX_CONTROL_PLANE_TOOL_CALLS_PER_TURN` and
190/// ordinary loop/rate guards keep applying.
191pub fn is_turn_budget_exempt_call(tool_name: &str, args: &Value) -> bool {
192    if canonical_command_session_tool_name(tool_name).is_none() {
193        return false;
194    }
195    command_session_action_is(args, "wait")
196        || command_session_action_is(args, "inspect")
197        || is_exec_session_cleanup_call(tool_name, args)
198}
199
200/// Cleanup stops or releases an owned execution session, never starts work.
201pub fn is_exec_session_cleanup_call(tool_name: &str, args: &Value) -> bool {
202    tool_name == tools::WRITE_STDIN
203        && matches!(
204            crate::tools::command_args::write_stdin_dispatch(args),
205            Ok(crate::tools::command_args::WriteStdinDispatch::Terminate
206                | crate::tools::command_args::WriteStdinDispatch::Close)
207        )
208}
209
210pub fn remap_file_operation_command_args_to_command_session(args: &Value) -> Option<Value> {
211    let obj = args.as_object()?;
212    let command = obj
213        .get("command")
214        .or_else(|| obj.get("cmd"))
215        .or_else(|| obj.get("raw_command"))
216        .and_then(Value::as_str)
217        .map(str::trim)
218        .filter(|value| !value.is_empty())?;
219    let action = obj.get("action").and_then(Value::as_str).map(str::trim);
220    if let Some(action) = action
221        && !action.is_empty()
222        && !action_matches_any(Some(action), &["run", "exec", "execute", "shell"])
223    {
224        return None;
225    }
226
227    let mut mapped = serde_json::Map::new();
228    mapped.insert("action".to_string(), Value::String("run".to_string()));
229    mapped.insert("command".to_string(), Value::String(command.to_string()));
230
231    for key in [
232        "args",
233        "cwd",
234        "workdir",
235        "env",
236        "timeout_ms",
237        "yield_time_ms",
238        "login",
239        "shell",
240        "tty",
241        "sandbox_permissions",
242        "justification",
243        "prefix_rule",
244    ] {
245        if let Some(value) = obj.get(key) {
246            mapped.insert(key.to_string(), value.clone());
247        }
248    }
249
250    Some(Value::Object(mapped))
251}
252
253fn file_operation_intent(args: &Value) -> ToolIntent {
254    if file_operation_action_is(args, "read") {
255        ToolIntent::read_only_unified_action()
256    } else {
257        ToolIntent::mutating()
258    }
259}
260
261fn command_session_intent(args: &Value) -> ToolIntent {
262    let has_exec_input = command_session_has_input(args);
263    let readonly_unified_action = if command_session_action_is(args, "run") {
264        is_readonly_command_session_command(args)
265    } else {
266        command_session_action_in(args, &["poll", "wait", "list", "inspect"])
267            || (command_session_action_is(args, "continue") && !has_exec_input)
268    };
269
270    if readonly_unified_action {
271        ToolIntent::read_only_unified_action()
272    } else {
273        ToolIntent::mutating()
274    }
275}
276
277fn exec_command_intent(args: &Value) -> ToolIntent {
278    if is_readonly_command_session_command(args) {
279        ToolIntent::read_only_unified_action()
280    } else {
281        ToolIntent::mutating()
282    }
283}
284
285fn write_stdin_intent(args: &Value) -> ToolIntent {
286    match crate::tools::command_args::write_stdin_dispatch(args) {
287        Ok(crate::tools::command_args::WriteStdinDispatch::Poll) => ToolIntent::read_only(),
288        Ok(
289            crate::tools::command_args::WriteStdinDispatch::Wait
290            | crate::tools::command_args::WriteStdinDispatch::Inspect,
291        ) => ToolIntent::read_only(),
292        Ok(
293            crate::tools::command_args::WriteStdinDispatch::Write
294            | crate::tools::command_args::WriteStdinDispatch::Terminate
295            | crate::tools::command_args::WriteStdinDispatch::Close,
296        )
297        | Err(_) => ToolIntent::mutating(),
298    }
299}
300
301fn memory_tool_intent(args: &Value) -> ToolIntent {
302    let command = args.get("command").and_then(Value::as_str).map(str::trim).unwrap_or_default();
303    if command.eq_ignore_ascii_case("view") {
304        ToolIntent::read_only()
305    } else {
306        ToolIntent::mutating()
307    }
308}
309
310fn builtin_tool_behavior_canonical(tool: &str) -> Option<ToolBehavior> {
311    match tool {
312        tools::CODE_SEARCH => Some(ToolBehavior::function(ToolMutationModel::ReadOnly, true, false)),
313        tools::UNIFIED_EXEC => {
314            Some(ToolBehavior::function(ToolMutationModel::ByArgs(command_session_intent), false, true))
315        }
316        tools::EXEC_COMMAND => {
317            Some(ToolBehavior::function(ToolMutationModel::ByArgs(exec_command_intent), false, true))
318        }
319        tools::WRITE_STDIN => Some(ToolBehavior::function(ToolMutationModel::ByArgs(write_stdin_intent), false, true)),
320        tools::UNIFIED_FILE => {
321            Some(ToolBehavior::function(ToolMutationModel::ByArgs(file_operation_intent), false, false))
322        }
323        tools::APPLY_PATCH => Some(ToolBehavior::apply_patch(ToolMutationModel::Mutating, false, true)),
324        tools::REQUEST_USER_INPUT
325        | tools::MEMORY
326        | tools::START_PLANNING
327        | tools::LIST_SKILLS
328        | tools::LOAD_SKILL_RESOURCE
329        | tools::TASK_TRACKER
330        | tools::RECORD_DECISION
331        | tools::GET_ERRORS
332        | tools::SEARCH_TOOLS
333        | tools::MCP_SEARCH_TOOLS
334        | tools::MCP_GET_TOOL_DETAILS
335        | tools::MCP_LIST_SERVERS
336        | tools::THINK => Some(ToolBehavior::function(
337            if tool == tools::MEMORY {
338                ToolMutationModel::ByArgs(memory_tool_intent)
339            } else {
340                ToolMutationModel::ReadOnly
341            },
342            false,
343            false,
344        )),
345        tools::LOAD_SKILL => Some(ToolBehavior::function(ToolMutationModel::Mutating, false, true)),
346        tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => {
347            Some(ToolBehavior::function(ToolMutationModel::ReadOnly, true, false))
348        }
349        tools::WEB_FETCH | tools::FETCH_URL | tools::WEB_SEARCH | tools::DEFUDDLE_FETCH => {
350            Some(ToolBehavior::function(ToolMutationModel::ReadOnly, false, false))
351        }
352        tools::WRITE_FILE | tools::EDIT_FILE | tools::DELETE_FILE | tools::CREATE_FILE => {
353            Some(ToolBehavior::function(ToolMutationModel::Mutating, false, true))
354        }
355        tools::MCP_CONNECT_SERVER | tools::MCP_DISCONNECT_SERVER => {
356            Some(ToolBehavior::function(ToolMutationModel::Mutating, false, false))
357        }
358        tools::RUN_PTY_CMD
359        | tools::SEND_PTY_INPUT
360        | tools::CREATE_PTY_SESSION
361        | tools::READ_PTY_SESSION
362        | tools::LIST_PTY_SESSIONS
363        | tools::CLOSE_PTY_SESSION
364        | tools::EXECUTE_CODE
365        | tools::SHELL => Some(ToolBehavior::function(ToolMutationModel::Mutating, false, true)),
366        _ => None,
367    }
368}
369
370fn command_session_has_input(args: &Value) -> bool {
371    interactive_input_text(args).is_some()
372}