vtcode_core/tools/tool_intent/
classify.rs1use 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
15pub 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 _ => 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
37pub 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#[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#[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#[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
178pub 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
200pub 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}