Skip to main content

oxios_kernel/tools/
program_tool.rs

1//! Program-defined tool with automatic exec routing.
2//!
3//! Each `[[tools]]` entry in `program.toml` becomes a `ProgramTool` registered
4//! in the ToolRegistry at Tier 3. When executed, the tool routes to `ExecTool`
5//! for command execution. All execution goes through ExecTool.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
11use serde_json::{json, Value};
12use tokio::sync::oneshot;
13
14use super::exec_tool::ExecTool;
15use crate::program::{ProgramHostRequirements, ToolDef};
16use crate::KernelHandle;
17
18/// A tool defined by a Program, with automatic execution routing.
19///
20/// All program tools route through `ExecTool` which provides the
21/// execution environment.
22pub struct ProgramTool {
23    /// Full namespaced name: `"program:{program_name}:{tool_name}"`
24    full_name: String,
25    /// Tool description for the LLM (stored as owned String for `&str` return)
26    description: String,
27    /// Binary to execute (first word of command)
28    binary: String,
29    /// Default arguments from the command definition
30    default_args: Vec<String>,
31    /// Execution delegates — actual execution is delegated to Tier 2 tools
32    exec_tool: Arc<ExecTool>,
33}
34
35impl ProgramTool {
36    /// Create a placeholder ProgramTool from a KernelHandle.
37    ///
38    /// This is used by the `OxiosKernelBridge` during agent build to register
39    /// the program tool slot. Actual program tools with concrete definitions
40    /// are created via `from_definition()` and registered via CSpace.
41    ///
42    /// This placeholder's `name()` returns `"program"` so the LLM can call
43    /// `program` with arguments like `{"name": "tool-name", "args": [...]}`.
44    pub fn from_kernel(kernel: &KernelHandle) -> Self {
45        let exec = Arc::new(ExecTool::from_kernel(kernel));
46        Self {
47            full_name: "program".to_string(),
48            description: "Run installable program tools. Pass {name: tool-name, args: [...]}"
49                .to_string(),
50            binary: "".to_string(),
51            default_args: Vec::new(),
52            exec_tool: exec,
53        }
54    }
55
56    /// Create a ProgramTool from a program's tool definition.
57    ///
58    /// All program tools route through `ExecTool` which provides the
59    /// execution environment.
60    pub fn from_definition(
61        program_name: &str,
62        tool_def: &ToolDef,
63        _host_requirements: &ProgramHostRequirements,
64        exec: Arc<ExecTool>,
65    ) -> Self {
66        // Parse command: first word is binary, rest are default args
67        let parts: Vec<&str> = tool_def.command.split_whitespace().collect();
68        let binary = parts.first().unwrap_or(&"").to_string();
69        let default_args = parts.iter().skip(1).map(|s| s.to_string()).collect();
70
71        Self {
72            full_name: format!("program:{}:{}", program_name, tool_def.name),
73            description: tool_def.description.clone(),
74            binary,
75            default_args,
76            exec_tool: exec,
77        }
78    }
79}
80
81impl std::fmt::Debug for ProgramTool {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("ProgramTool")
84            .field("full_name", &self.full_name)
85            .field("binary", &self.binary)
86            .finish()
87    }
88}
89
90#[async_trait]
91impl AgentTool for ProgramTool {
92    fn name(&self) -> &str {
93        &self.full_name
94    }
95
96    fn label(&self) -> &str {
97        &self.full_name
98    }
99
100    fn description(&self) -> &str {
101        &self.description
102    }
103
104    fn parameters_schema(&self) -> Value {
105        json!({
106            "type": "object",
107            "properties": {
108                "args": {
109                    "type": "array",
110                    "items": { "type": "string" },
111                    "description": "Additional arguments to pass to the command"
112                }
113            }
114        })
115    }
116
117    async fn execute(
118        &self,
119        _tool_call_id: &str,
120        params: Value,
121        signal: Option<oneshot::Receiver<()>>,
122        _ctx: &ToolContext,
123    ) -> Result<AgentToolResult, String> {
124        // Extract user-provided args
125        let user_args: Vec<String> = params
126            .get("args")
127            .and_then(|v| v.as_array())
128            .map(|arr| {
129                arr.iter()
130                    .filter_map(|v| v.as_str().map(String::from))
131                    .collect()
132            })
133            .unwrap_or_default();
134
135        // Build full command: binary + default_args + user_args
136        let all_args: Vec<String> = self
137            .default_args
138            .iter()
139            .chain(user_args.iter())
140            .cloned()
141            .collect();
142
143        // Route to exec_tool
144        let exec_params = json!({
145            "binary": self.binary,
146            "args": all_args,
147        });
148        let ctx = oxi_sdk::ToolContext::default();
149        self.exec_tool
150            .execute(&format!("pg:{}", self.full_name), exec_params, signal, &ctx)
151            .await
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    /// Verify that ProgramTool correctly parses command definitions.
160    #[test]
161    fn test_command_parsing() {
162        let tool_def = ToolDef {
163            name: "create_pr".to_string(),
164            description: "Create a PR".to_string(),
165            arguments: vec![],
166            command: "gh pr create".to_string(),
167        };
168        let host_reqs = ProgramHostRequirements::default();
169
170        let exec_config = Arc::new(crate::config::ExecConfig::default());
171        let exec_access = Arc::new(parking_lot::Mutex::new(
172            crate::access_manager::AccessManager::new(),
173        ));
174        let exec = Arc::new(ExecTool::new(exec_config, exec_access));
175
176        let tool = ProgramTool::from_definition("github", &tool_def, &host_reqs, exec);
177
178        assert_eq!(tool.full_name, "program:github:create_pr");
179        assert_eq!(tool.binary, "gh");
180        assert_eq!(tool.default_args, vec!["pr", "create"]);
181    }
182
183    /// Verify that single-word commands work correctly.
184    #[test]
185    fn test_single_word_command() {
186        let tool_def = ToolDef {
187            name: "status".to_string(),
188            description: "Show git status".to_string(),
189            arguments: vec![],
190            command: "git".to_string(),
191        };
192        let host_reqs = ProgramHostRequirements::default();
193
194        let exec_config = Arc::new(crate::config::ExecConfig::default());
195        let exec_access = Arc::new(parking_lot::Mutex::new(
196            crate::access_manager::AccessManager::new(),
197        ));
198        let exec = Arc::new(ExecTool::new(exec_config, exec_access));
199
200        let tool = ProgramTool::from_definition("git-tools", &tool_def, &host_reqs, exec);
201
202        assert_eq!(tool.full_name, "program:git-tools:status");
203        assert_eq!(tool.binary, "git");
204        assert!(tool.default_args.is_empty());
205    }
206
207    /// Verify that commands with flags work correctly.
208    #[test]
209    fn test_command_with_flags() {
210        let tool_def = ToolDef {
211            name: "fetch".to_string(),
212            description: "Fetch from remote".to_string(),
213            arguments: vec![],
214            command: "git fetch --all --prune".to_string(),
215        };
216        let host_reqs = ProgramHostRequirements::default();
217
218        let exec_config = Arc::new(crate::config::ExecConfig::default());
219        let exec_access = Arc::new(parking_lot::Mutex::new(
220            crate::access_manager::AccessManager::new(),
221        ));
222        let exec = Arc::new(ExecTool::new(exec_config, exec_access));
223
224        let tool = ProgramTool::from_definition("git-tools", &tool_def, &host_reqs, exec);
225
226        assert_eq!(tool.binary, "git");
227        assert_eq!(tool.default_args, vec!["fetch", "--all", "--prune"]);
228    }
229}