1use async_trait::async_trait;
63use serde::{Deserialize, Serialize};
64use std::fmt::Display;
65use std::path::PathBuf;
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum ToolCategory {
71 Read,
73 #[default]
75 Write,
76 ReadWrite,
78}
79
80impl ToolCategory {
81 pub fn label(self) -> &'static str {
82 match self {
83 Self::Read => "READ",
84 Self::Write => "WRITE",
85 Self::ReadWrite => "READ/WRITE",
86 }
87 }
88
89 pub fn guidance(self) -> &'static str {
90 match self {
91 Self::Read => "Inspects or verifies state without persistent side effects.",
92 Self::Write => {
93 "Mutates persistent state. Use sparingly and avoid repeated calls in one turn."
94 }
95 Self::ReadWrite => {
96 "Can read and mutate state. Use carefully and avoid repeated calls in one turn."
97 }
98 }
99 }
100
101 pub fn is_write_like(self) -> bool {
102 !matches!(self, Self::Read)
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ToolSpec {
109 pub name: String,
110 pub description: String,
111 pub parameters: serde_json::Value,
112 #[serde(default)]
113 pub category: ToolCategory,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ToolCall {
119 pub id: String,
120 pub name: String,
121 pub arguments: String,
122}
123
124impl Display for ToolCall {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 write!(f, "name={} arguments={}", self.name, self.arguments)
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ToolResultMessage {
133 pub tool_call_id: String,
134 pub content: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ToolResult {
140 pub success: bool,
141 pub output: String,
142 pub error: Option<String>,
143}
144
145#[async_trait]
147pub trait Tool: Send + Sync {
148 fn name(&self) -> &str;
150
151 fn description(&self) -> &str;
153
154 fn parameters_schema(&self) -> serde_json::Value;
156
157 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
159
160 fn category(&self) -> ToolCategory {
162 ToolCategory::Write
163 }
164
165 fn is_terminal(&self) -> bool {
167 false
168 }
169
170 fn spec(&self) -> ToolSpec {
172 let category = self.category();
173 ToolSpec {
174 name: self.name().to_string(),
175 description: format!(
176 "[{}] {} {}",
177 category.label(),
178 category.guidance(),
179 self.description()
180 ),
181 parameters: self.parameters_schema(),
182 category,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "lowercase")]
190pub enum ToolAutonomy {
191 ReadOnly,
192 #[default]
193 Supervised,
194 Full,
195}
196
197#[derive(Debug, Clone)]
201pub struct ToolSecurity {
202 pub autonomy: ToolAutonomy,
203 pub workspace_dir: PathBuf,
204 pub forwarded_env_names: Vec<String>,
205}
206
207impl Default for ToolSecurity {
208 fn default() -> Self {
209 let home = std::env::var("HOME")
210 .map(PathBuf::from)
211 .unwrap_or_else(|_| PathBuf::from("."));
212 Self {
213 autonomy: ToolAutonomy::Supervised,
214 workspace_dir: home.join(".nenjo").join("workspace"),
215 forwarded_env_names: Vec::new(),
216 }
217 }
218}
219
220impl ToolSecurity {
221 pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
222 Self {
223 workspace_dir,
224 ..Default::default()
225 }
226 }
227}
228
229pub fn sanitize_tool_name(name: &str) -> String {
235 name.chars()
236 .map(|c| {
237 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
238 c
239 } else {
240 '_'
241 }
242 })
243 .collect()
244}
245
246pub fn sanitize_tool_name_lenient(name: &str) -> String {
249 name.chars()
250 .map(|c| {
251 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
252 c
253 } else {
254 '_'
255 }
256 })
257 .collect()
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 struct DummyTool;
265
266 #[async_trait]
267 impl Tool for DummyTool {
268 fn name(&self) -> &str {
269 "dummy"
270 }
271
272 fn description(&self) -> &str {
273 "A test tool"
274 }
275
276 fn parameters_schema(&self) -> serde_json::Value {
277 serde_json::json!({
278 "type": "object",
279 "properties": { "value": { "type": "string" } }
280 })
281 }
282
283 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
284 Ok(ToolResult {
285 success: true,
286 output: args["value"].as_str().unwrap_or_default().to_string(),
287 error: None,
288 })
289 }
290 }
291
292 #[test]
293 fn spec_uses_tool_metadata() {
294 let spec = DummyTool.spec();
295 assert_eq!(spec.name, "dummy");
296 assert_eq!(spec.category, ToolCategory::Write);
297 }
298
299 #[tokio::test]
300 async fn execute_returns_output() {
301 let result = DummyTool
302 .execute(serde_json::json!({"value": "hello"}))
303 .await
304 .unwrap();
305 assert!(result.success);
306 assert_eq!(result.output, "hello");
307 }
308
309 #[test]
310 fn tool_result_roundtrip() {
311 let result = ToolResult {
312 success: false,
313 output: String::new(),
314 error: Some("boom".into()),
315 };
316 let json = serde_json::to_string(&result).unwrap();
317 let parsed: ToolResult = serde_json::from_str(&json).unwrap();
318 assert_eq!(parsed.error.as_deref(), Some("boom"));
319 }
320
321 #[test]
322 fn sanitize_tool_name_replaces_dots_and_slashes() {
323 assert_eq!(
324 sanitize_tool_name("app.nenjo.platform/tasks"),
325 "app_nenjo_platform_tasks"
326 );
327 }
328
329 #[test]
330 fn sanitize_tool_name_preserves_valid_chars() {
331 assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
332 }
333
334 #[test]
335 fn sanitize_tool_name_lenient_preserves_dots() {
336 assert_eq!(
337 sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
338 "app.nenjo.platform_tasks"
339 );
340 }
341}