1use 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";
20const DENIED_SIDE_EFFECTS_METADATA_KEY: &str = "_vv_agent_denied_side_effects";
21const DENIED_CAPABILITY_TAGS_METADATA_KEY: &str = "_vv_agent_denied_capability_tags";
22const DENY_TERMINAL_TOOLS_METADATA_KEY: &str = "_vv_agent_deny_terminal_tools";
23const DENIED_COST_DIMENSIONS_METADATA_KEY: &str = "_vv_agent_denied_cost_dimensions";
24
25pub(crate) fn project_tool_policy(task: &mut AgentTask, policy: &ToolPolicy) {
26 match policy.allowed_tools.as_ref() {
27 Some(allowed_tools) => {
28 task.metadata.insert(
29 ALLOWED_TOOLS_METADATA_KEY.to_string(),
30 Value::Array(allowed_tools.iter().cloned().map(Value::String).collect()),
31 );
32 }
33 None => {
34 task.metadata.remove(ALLOWED_TOOLS_METADATA_KEY);
35 }
36 }
37 if policy.disallowed_tools.is_empty() {
38 task.metadata.remove(DISALLOWED_TOOLS_METADATA_KEY);
39 } else {
40 task.metadata.insert(
41 DISALLOWED_TOOLS_METADATA_KEY.to_string(),
42 Value::Array(
43 policy
44 .disallowed_tools
45 .iter()
46 .cloned()
47 .map(Value::String)
48 .collect(),
49 ),
50 );
51 }
52 match policy.approval {
53 crate::tools::ApprovalPolicy::Default => {
54 task.metadata.remove(TOOL_POLICY_APPROVAL_METADATA_KEY);
55 }
56 approval => {
57 let value = match approval {
58 crate::tools::ApprovalPolicy::Never => "never",
59 crate::tools::ApprovalPolicy::Always => "always",
60 crate::tools::ApprovalPolicy::OnRequest => "on_request",
61 crate::tools::ApprovalPolicy::Default => unreachable!(),
62 };
63 task.metadata.insert(
64 TOOL_POLICY_APPROVAL_METADATA_KEY.to_string(),
65 Value::String(value.to_string()),
66 );
67 }
68 }
69 if policy.can_use_tool.is_some() {
70 task.metadata.insert(
71 TOOL_POLICY_CAN_USE_TOOL_METADATA_KEY.to_string(),
72 Value::Bool(true),
73 );
74 } else {
75 task.metadata.remove(TOOL_POLICY_CAN_USE_TOOL_METADATA_KEY);
76 }
77 let _ = merge_projected_metadata_denials(task, policy);
79}
80
81pub(crate) fn merge_projected_metadata_denials(
82 task: &mut AgentTask,
83 policy: &ToolPolicy,
84) -> Result<ToolPolicy, String> {
85 let mut effective_policy = projected_metadata_denials(task)?;
86 let policy = policy.normalized().map_err(|error| error.to_string())?;
87 effective_policy.extend_metadata_denials(&policy);
88 write_projected_metadata_denials(task, &effective_policy);
89 Ok(effective_policy)
90}
91
92fn write_projected_metadata_denials(task: &mut AgentTask, policy: &ToolPolicy) {
93 project_metadata_denial_list(
94 task,
95 DENIED_SIDE_EFFECTS_METADATA_KEY,
96 policy
97 .denied_side_effects
98 .iter()
99 .map(|value| Value::String(value.as_str().to_string()))
100 .collect(),
101 );
102 project_metadata_denial_list(
103 task,
104 DENIED_CAPABILITY_TAGS_METADATA_KEY,
105 policy
106 .denied_capability_tags
107 .iter()
108 .cloned()
109 .map(Value::String)
110 .collect(),
111 );
112 if policy.deny_terminal_tools {
113 task.metadata.insert(
114 DENY_TERMINAL_TOOLS_METADATA_KEY.to_string(),
115 Value::Bool(true),
116 );
117 } else {
118 task.metadata.remove(DENY_TERMINAL_TOOLS_METADATA_KEY);
119 }
120 project_metadata_denial_list(
121 task,
122 DENIED_COST_DIMENSIONS_METADATA_KEY,
123 policy
124 .denied_cost_dimensions
125 .iter()
126 .cloned()
127 .map(Value::String)
128 .collect(),
129 );
130}
131
132fn project_metadata_denial_list(task: &mut AgentTask, key: &str, values: Vec<Value>) {
133 if values.is_empty() {
134 task.metadata.remove(key);
135 } else {
136 task.metadata.insert(key.to_string(), Value::Array(values));
137 }
138}
139
140pub(crate) fn projected_metadata_denials(task: &AgentTask) -> Result<ToolPolicy, String> {
141 let denied_side_effects = task
142 .metadata
143 .get(DENIED_SIDE_EFFECTS_METADATA_KEY)
144 .cloned()
145 .map(serde_json::from_value)
146 .transpose()
147 .map_err(|error| format!("invalid projected denied_side_effects: {error}"))?
148 .unwrap_or_default();
149 let denied_capability_tags = projected_string_list(task, DENIED_CAPABILITY_TAGS_METADATA_KEY)?;
150 let deny_terminal_tools = match task.metadata.get(DENY_TERMINAL_TOOLS_METADATA_KEY) {
151 Some(Value::Bool(value)) => *value,
152 Some(_) => return Err("invalid projected deny_terminal_tools".to_string()),
153 None => false,
154 };
155 let denied_cost_dimensions = projected_string_list(task, DENIED_COST_DIMENSIONS_METADATA_KEY)?;
156 ToolPolicy {
157 denied_side_effects,
158 denied_capability_tags,
159 deny_terminal_tools,
160 denied_cost_dimensions,
161 ..ToolPolicy::default()
162 }
163 .normalized()
164 .map_err(|error| error.to_string())
165}
166
167fn projected_string_list(task: &AgentTask, key: &str) -> Result<Vec<String>, String> {
168 task.metadata
169 .get(key)
170 .cloned()
171 .map(serde_json::from_value)
172 .transpose()
173 .map_err(|error| format!("invalid projected {key}: {error}"))
174 .map(Option::unwrap_or_default)
175}
176
177pub fn plan_tool_names(task: &AgentTask, memory_usage_percentage: Option<u32>) -> Vec<String> {
178 let _ = memory_usage_percentage;
179 let mut names = vec![TASK_FINISH_TOOL_NAME.to_string()];
180 if task.allow_interruption {
181 names.push(ASK_USER_TOOL_NAME.to_string());
182 }
183 if task.use_workspace {
184 names.extend(WORKSPACE_TOOLS.into_iter().map(str::to_string));
185 }
186 if task.agent_type.as_deref() == Some("computer") {
187 names.push(BASH_TOOL_NAME.to_string());
188 names.push(CHECK_BACKGROUND_COMMAND_TOOL_NAME.to_string());
189 }
190 if task.sub_agents_enabled() {
191 names.push(CREATE_SUB_TASK_TOOL_NAME.to_string());
192 names.push(SUB_TASK_STATUS_TOOL_NAME.to_string());
193 }
194 if task
195 .metadata
196 .get("available_skills")
197 .is_some_and(is_json_truthy)
198 {
199 names.push(ACTIVATE_SKILL_TOOL_NAME.to_string());
200 }
201 if task.native_multimodal {
202 names.push(READ_IMAGE_TOOL_NAME.to_string());
203 }
204 names.extend(task.extra_tool_names.clone());
205 if !task.exclude_tools.is_empty() {
206 names.retain(|name| !task.exclude_tools.contains(name));
207 }
208 if let Some(disallowed_tools) = metadata_tool_names(task, DISALLOWED_TOOLS_METADATA_KEY) {
209 names.retain(|name| !disallowed_tools.contains(name.as_str()));
210 }
211 if let Some(allowed_tools) = metadata_tool_names(task, ALLOWED_TOOLS_METADATA_KEY) {
212 names.retain(|name| allowed_tools.contains(name.as_str()));
213 }
214
215 let mut deduped = Vec::new();
216 for name in names {
217 if !deduped.contains(&name) {
218 deduped.push(name);
219 }
220 }
221 deduped
222}
223
224pub fn plan_tool_schemas(
225 registry: &ToolRegistry,
226 task: &AgentTask,
227 memory_usage_percentage: Option<u32>,
228) -> Vec<Value> {
229 plan_tool_schemas_with_policy(registry, task, memory_usage_percentage, None)
230}
231
232pub(crate) fn plan_tool_schemas_with_policy(
233 registry: &ToolRegistry,
234 task: &AgentTask,
235 memory_usage_percentage: Option<u32>,
236 policy: Option<&ToolPolicy>,
237) -> Vec<Value> {
238 let names = plan_tool_names(task, memory_usage_percentage);
239 let available_names = names
240 .into_iter()
241 .filter(|name| {
242 registry.has_schema(name)
243 && registry.get(name).is_ok_and(|spec| {
244 policy.is_none_or(|policy| {
245 policy
246 .metadata_denial_source(spec.tool_metadata.as_ref())
247 .is_none()
248 })
249 })
250 })
251 .collect::<Vec<_>>();
252 let schemas = registry
253 .list_openai_schemas(Some(&available_names))
254 .expect("planned tool names were pre-filtered to registered schemas");
255 patch_dynamic_tool_schema_hints(task, schemas)
256}
257
258pub fn freeze_dynamic_tool_schema_hints(task: &mut AgentTask) {
259 if task.agent_type.as_deref() == Some("computer")
260 || task.extra_tool_names.iter().any(|name| name == "bash")
261 {
262 let hint = build_bash_runtime_hint(task);
263 task.metadata.insert(
264 BASH_RUNTIME_HINT_METADATA_KEY.to_string(),
265 Value::String(hint),
266 );
267 }
268}
269
270pub fn patch_dynamic_tool_schema_hints(task: &AgentTask, tool_schemas: Vec<Value>) -> Vec<Value> {
271 let mut bash_hint = None::<String>;
272 tool_schemas
273 .into_iter()
274 .map(|mut schema| {
275 if schema["function"]["name"].as_str() != Some("bash") {
276 return schema;
277 }
278 let hint = bash_hint.get_or_insert_with(|| build_bash_runtime_hint(task));
279 let base_description = schema["function"]["description"]
280 .as_str()
281 .unwrap_or_default()
282 .trim_end()
283 .to_string();
284 schema["function"]["description"] =
285 Value::String(format!("{base_description}\n\n{hint}").trim().to_string());
286 schema
287 })
288 .collect()
289}
290
291fn build_bash_runtime_hint(task: &AgentTask) -> String {
292 if let Some(cached) = task
293 .metadata
294 .get(BASH_RUNTIME_HINT_METADATA_KEY)
295 .and_then(Value::as_str)
296 .map(str::trim)
297 .filter(|value| !value.is_empty())
298 {
299 return cached.to_string();
300 }
301 let shell = match task.metadata.get("bash_shell") {
302 None | Some(Value::Null) => None,
303 Some(Value::String(value)) => {
304 let value = value.trim();
305 (!value.is_empty()).then_some(value)
306 }
307 Some(_) => {
308 return invalid_shell_hint("`bash_shell` must be a string shell name");
309 }
310 };
311 let windows_shell_priority =
312 match normalize_windows_shell_priority(task.metadata.get("windows_shell_priority")) {
313 Ok(priority) => priority,
314 Err(error) => return invalid_shell_hint(error),
315 };
316 match resolve_shell_invocation(shell, windows_shell_priority.as_deref()) {
317 Ok(resolved) => format!(
318 "Runtime shell hint: commands run via `{}` using prefix `{}`.",
319 resolved.kind,
320 resolved.prefix.join(" ")
321 ),
322 Err(error) => invalid_shell_hint(error),
323 }
324}
325
326fn invalid_shell_hint(error: impl std::fmt::Display) -> String {
327 let message = error.to_string();
328 let message = message.trim_end_matches('.');
329 format!("Runtime shell hint: invalid shell config. {message}.")
330}
331
332fn is_json_truthy(value: &Value) -> bool {
333 match value {
334 Value::Null => false,
335 Value::Bool(value) => *value,
336 Value::Number(value) => value
337 .as_i64()
338 .map(|number| number != 0)
339 .or_else(|| value.as_u64().map(|number| number != 0))
340 .or_else(|| value.as_f64().map(|number| number != 0.0))
341 .unwrap_or(true),
342 Value::String(value) => !value.is_empty(),
343 Value::Array(value) => !value.is_empty(),
344 Value::Object(value) => !value.is_empty(),
345 }
346}
347
348fn metadata_tool_names<'a>(task: &'a AgentTask, key: &str) -> Option<BTreeSet<&'a str>> {
349 task.metadata
350 .get(key)
351 .and_then(Value::as_array)
352 .map(|names| {
353 names
354 .iter()
355 .filter_map(Value::as_str)
356 .collect::<BTreeSet<_>>()
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use serde_json::json;
363
364 use super::*;
365 use crate::tools::ToolSideEffect;
366
367 #[test]
368 fn projecting_tool_policy_only_adds_metadata_denials() {
369 let mut task = AgentTask::new("metadata-denials", "model", "system", "prompt");
370 task.metadata.insert(
371 DENIED_SIDE_EFFECTS_METADATA_KEY.to_string(),
372 json!(["execute"]),
373 );
374 task.metadata.insert(
375 DENIED_CAPABILITY_TAGS_METADATA_KEY.to_string(),
376 json!(["process.spawn"]),
377 );
378 task.metadata.insert(
379 DENY_TERMINAL_TOOLS_METADATA_KEY.to_string(),
380 Value::Bool(true),
381 );
382 task.metadata.insert(
383 DENIED_COST_DIMENSIONS_METADATA_KEY.to_string(),
384 json!(["cpu.second"]),
385 );
386 let policy = ToolPolicy {
387 denied_side_effects: vec![ToolSideEffect::Network],
388 denied_capability_tags: vec!["filesystem.write".to_string()],
389 denied_cost_dimensions: vec!["io.byte".to_string()],
390 ..ToolPolicy::default()
391 };
392
393 project_tool_policy(&mut task, &policy);
394 project_tool_policy(&mut task, &ToolPolicy::default());
395
396 let projected = projected_metadata_denials(&task).expect("projected denials");
397 assert_eq!(
398 projected.denied_side_effects,
399 [ToolSideEffect::Execute, ToolSideEffect::Network]
400 );
401 assert_eq!(
402 projected.denied_capability_tags,
403 ["filesystem.write", "process.spawn"]
404 );
405 assert!(projected.deny_terminal_tools);
406 assert_eq!(projected.denied_cost_dimensions, ["cpu.second", "io.byte"]);
407 }
408}