vv_agent/runtime/
tool_planner.rs1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use crate::constants::{
6 ACTIVATE_SKILL_TOOL_NAME, ASK_USER_TOOL_NAME, BASH_TOOL_NAME,
7 CHECK_BACKGROUND_COMMAND_TOOL_NAME, CREATE_SUB_TASK_TOOL_NAME, READ_IMAGE_TOOL_NAME,
8 SUB_TASK_STATUS_TOOL_NAME, TASK_FINISH_TOOL_NAME, WORKSPACE_TOOLS,
9};
10use crate::tools::{ToolPolicy, ToolRegistry};
11use crate::types::AgentTask;
12
13use super::shell::{normalize_windows_shell_priority, resolve_shell_invocation};
14
15const BASH_RUNTIME_HINT_METADATA_KEY: &str = "_vv_agent_bash_runtime_hint";
16const ALLOWED_TOOLS_METADATA_KEY: &str = "_vv_agent_allowed_tools";
17const DISALLOWED_TOOLS_METADATA_KEY: &str = "_vv_agent_disallowed_tools";
18const TOOL_POLICY_APPROVAL_METADATA_KEY: &str = "_vv_agent_tool_policy_approval";
19const TOOL_POLICY_CAN_USE_TOOL_METADATA_KEY: &str = "_vv_agent_tool_policy_can_use_tool";
20
21pub(crate) fn project_tool_policy(task: &mut AgentTask, policy: &ToolPolicy) {
22 match policy.allowed_tools.as_ref() {
23 Some(allowed_tools) => {
24 task.metadata.insert(
25 ALLOWED_TOOLS_METADATA_KEY.to_string(),
26 Value::Array(allowed_tools.iter().cloned().map(Value::String).collect()),
27 );
28 }
29 None => {
30 task.metadata.remove(ALLOWED_TOOLS_METADATA_KEY);
31 }
32 }
33 if policy.disallowed_tools.is_empty() {
34 task.metadata.remove(DISALLOWED_TOOLS_METADATA_KEY);
35 } else {
36 task.metadata.insert(
37 DISALLOWED_TOOLS_METADATA_KEY.to_string(),
38 Value::Array(
39 policy
40 .disallowed_tools
41 .iter()
42 .cloned()
43 .map(Value::String)
44 .collect(),
45 ),
46 );
47 }
48 match policy.approval {
49 crate::tools::ApprovalPolicy::Default => {
50 task.metadata.remove(TOOL_POLICY_APPROVAL_METADATA_KEY);
51 }
52 approval => {
53 let value = match approval {
54 crate::tools::ApprovalPolicy::Never => "never",
55 crate::tools::ApprovalPolicy::Always => "always",
56 crate::tools::ApprovalPolicy::OnRequest => "on_request",
57 crate::tools::ApprovalPolicy::Default => unreachable!(),
58 };
59 task.metadata.insert(
60 TOOL_POLICY_APPROVAL_METADATA_KEY.to_string(),
61 Value::String(value.to_string()),
62 );
63 }
64 }
65 if policy.can_use_tool.is_some() {
66 task.metadata.insert(
67 TOOL_POLICY_CAN_USE_TOOL_METADATA_KEY.to_string(),
68 Value::Bool(true),
69 );
70 } else {
71 task.metadata.remove(TOOL_POLICY_CAN_USE_TOOL_METADATA_KEY);
72 }
73}
74
75pub fn plan_tool_names(task: &AgentTask, memory_usage_percentage: Option<u32>) -> Vec<String> {
76 let _ = memory_usage_percentage;
77 let mut names = vec![TASK_FINISH_TOOL_NAME.to_string()];
78 if task.allow_interruption {
79 names.push(ASK_USER_TOOL_NAME.to_string());
80 }
81 if task.use_workspace {
82 names.extend(WORKSPACE_TOOLS.into_iter().map(str::to_string));
83 }
84 if task.agent_type.as_deref() == Some("computer") {
85 names.push(BASH_TOOL_NAME.to_string());
86 names.push(CHECK_BACKGROUND_COMMAND_TOOL_NAME.to_string());
87 }
88 if task.sub_agents_enabled() {
89 names.push(CREATE_SUB_TASK_TOOL_NAME.to_string());
90 names.push(SUB_TASK_STATUS_TOOL_NAME.to_string());
91 }
92 if task
93 .metadata
94 .get("available_skills")
95 .is_some_and(is_json_truthy)
96 {
97 names.push(ACTIVATE_SKILL_TOOL_NAME.to_string());
98 }
99 if task.native_multimodal {
100 names.push(READ_IMAGE_TOOL_NAME.to_string());
101 }
102 names.extend(task.extra_tool_names.clone());
103 if !task.exclude_tools.is_empty() {
104 names.retain(|name| !task.exclude_tools.contains(name));
105 }
106 if let Some(disallowed_tools) = metadata_tool_names(task, DISALLOWED_TOOLS_METADATA_KEY) {
107 names.retain(|name| !disallowed_tools.contains(name.as_str()));
108 }
109 if let Some(allowed_tools) = metadata_tool_names(task, ALLOWED_TOOLS_METADATA_KEY) {
110 names.retain(|name| allowed_tools.contains(name.as_str()));
111 }
112
113 let mut deduped = Vec::new();
114 for name in names {
115 if !deduped.contains(&name) {
116 deduped.push(name);
117 }
118 }
119 deduped
120}
121
122pub fn plan_tool_schemas(
123 registry: &ToolRegistry,
124 task: &AgentTask,
125 memory_usage_percentage: Option<u32>,
126) -> Vec<Value> {
127 let names = plan_tool_names(task, memory_usage_percentage);
128 let available_names = names
129 .into_iter()
130 .filter(|name| registry.has_tool(name) && registry.has_schema(name))
131 .collect::<Vec<_>>();
132 let schemas = registry
133 .list_openai_schemas(Some(&available_names))
134 .expect("planned tool names were pre-filtered to registered schemas");
135 patch_dynamic_tool_schema_hints(task, schemas)
136}
137
138pub fn freeze_dynamic_tool_schema_hints(task: &mut AgentTask) {
139 if task.agent_type.as_deref() == Some("computer")
140 || task.extra_tool_names.iter().any(|name| name == "bash")
141 {
142 let hint = build_bash_runtime_hint(task);
143 task.metadata.insert(
144 BASH_RUNTIME_HINT_METADATA_KEY.to_string(),
145 Value::String(hint),
146 );
147 }
148}
149
150pub fn patch_dynamic_tool_schema_hints(task: &AgentTask, tool_schemas: Vec<Value>) -> Vec<Value> {
151 let mut bash_hint = None::<String>;
152 tool_schemas
153 .into_iter()
154 .map(|mut schema| {
155 if schema["function"]["name"].as_str() != Some("bash") {
156 return schema;
157 }
158 let hint = bash_hint.get_or_insert_with(|| build_bash_runtime_hint(task));
159 let base_description = schema["function"]["description"]
160 .as_str()
161 .unwrap_or_default()
162 .trim_end()
163 .to_string();
164 schema["function"]["description"] =
165 Value::String(format!("{base_description}\n\n{hint}").trim().to_string());
166 schema
167 })
168 .collect()
169}
170
171fn build_bash_runtime_hint(task: &AgentTask) -> String {
172 if let Some(cached) = task
173 .metadata
174 .get(BASH_RUNTIME_HINT_METADATA_KEY)
175 .and_then(Value::as_str)
176 .map(str::trim)
177 .filter(|value| !value.is_empty())
178 {
179 return cached.to_string();
180 }
181 let shell = match task.metadata.get("bash_shell") {
182 None | Some(Value::Null) => None,
183 Some(Value::String(value)) => {
184 let value = value.trim();
185 (!value.is_empty()).then_some(value)
186 }
187 Some(_) => {
188 return invalid_shell_hint("`bash_shell` must be a string shell name");
189 }
190 };
191 let windows_shell_priority =
192 match normalize_windows_shell_priority(task.metadata.get("windows_shell_priority")) {
193 Ok(priority) => priority,
194 Err(error) => return invalid_shell_hint(error),
195 };
196 match resolve_shell_invocation(shell, windows_shell_priority.as_deref()) {
197 Ok(resolved) => format!(
198 "Runtime shell hint: commands run via `{}` using prefix `{}`.",
199 resolved.kind,
200 resolved.prefix.join(" ")
201 ),
202 Err(error) => invalid_shell_hint(error),
203 }
204}
205
206fn invalid_shell_hint(error: impl std::fmt::Display) -> String {
207 let message = error.to_string();
208 let message = message.trim_end_matches('.');
209 format!("Runtime shell hint: invalid shell config. {message}.")
210}
211
212fn is_json_truthy(value: &Value) -> bool {
213 match value {
214 Value::Null => false,
215 Value::Bool(value) => *value,
216 Value::Number(value) => value
217 .as_i64()
218 .map(|number| number != 0)
219 .or_else(|| value.as_u64().map(|number| number != 0))
220 .or_else(|| value.as_f64().map(|number| number != 0.0))
221 .unwrap_or(true),
222 Value::String(value) => !value.is_empty(),
223 Value::Array(value) => !value.is_empty(),
224 Value::Object(value) => !value.is_empty(),
225 }
226}
227
228fn metadata_tool_names<'a>(task: &'a AgentTask, key: &str) -> Option<BTreeSet<&'a str>> {
229 task.metadata
230 .get(key)
231 .and_then(Value::as_array)
232 .map(|names| {
233 names
234 .iter()
235 .filter_map(Value::as_str)
236 .collect::<BTreeSet<_>>()
237 })
238}