Skip to main content

vv_agent/runtime/
tool_planner.rs

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    // Invalid existing denial metadata must remain visible so downstream boundaries fail closed.
78    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}