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}
205
206impl Default for ToolSecurity {
207 fn default() -> Self {
208 let home = std::env::var("HOME")
209 .map(PathBuf::from)
210 .unwrap_or_else(|_| PathBuf::from("."));
211 Self {
212 autonomy: ToolAutonomy::Supervised,
213 workspace_dir: home.join(".nenjo").join("workspace"),
214 }
215 }
216}
217
218impl ToolSecurity {
219 pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
220 Self {
221 workspace_dir,
222 ..Default::default()
223 }
224 }
225}
226
227pub fn sanitize_tool_name(name: &str) -> String {
233 name.chars()
234 .map(|c| {
235 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
236 c
237 } else {
238 '_'
239 }
240 })
241 .collect()
242}
243
244pub fn sanitize_tool_name_lenient(name: &str) -> String {
247 name.chars()
248 .map(|c| {
249 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
250 c
251 } else {
252 '_'
253 }
254 })
255 .collect()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 struct DummyTool;
263
264 #[async_trait]
265 impl Tool for DummyTool {
266 fn name(&self) -> &str {
267 "dummy"
268 }
269
270 fn description(&self) -> &str {
271 "A test tool"
272 }
273
274 fn parameters_schema(&self) -> serde_json::Value {
275 serde_json::json!({
276 "type": "object",
277 "properties": { "value": { "type": "string" } }
278 })
279 }
280
281 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
282 Ok(ToolResult {
283 success: true,
284 output: args["value"].as_str().unwrap_or_default().to_string(),
285 error: None,
286 })
287 }
288 }
289
290 #[test]
291 fn spec_uses_tool_metadata() {
292 let spec = DummyTool.spec();
293 assert_eq!(spec.name, "dummy");
294 assert_eq!(spec.category, ToolCategory::Write);
295 }
296
297 #[tokio::test]
298 async fn execute_returns_output() {
299 let result = DummyTool
300 .execute(serde_json::json!({"value": "hello"}))
301 .await
302 .unwrap();
303 assert!(result.success);
304 assert_eq!(result.output, "hello");
305 }
306
307 #[test]
308 fn tool_result_roundtrip() {
309 let result = ToolResult {
310 success: false,
311 output: String::new(),
312 error: Some("boom".into()),
313 };
314 let json = serde_json::to_string(&result).unwrap();
315 let parsed: ToolResult = serde_json::from_str(&json).unwrap();
316 assert_eq!(parsed.error.as_deref(), Some("boom"));
317 }
318
319 #[test]
320 fn sanitize_tool_name_replaces_dots_and_slashes() {
321 assert_eq!(
322 sanitize_tool_name("app.nenjo.platform/tasks"),
323 "app_nenjo_platform_tasks"
324 );
325 }
326
327 #[test]
328 fn sanitize_tool_name_preserves_valid_chars() {
329 assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
330 }
331
332 #[test]
333 fn sanitize_tool_name_lenient_preserves_dots() {
334 assert_eq!(
335 sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
336 "app.nenjo.platform_tasks"
337 );
338 }
339}