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";
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}