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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ToolOrigin {
153 #[default]
155 Host,
156 Mcp,
158 Platform,
160 Harness,
162}
163
164#[async_trait]
166pub trait Tool: Send + Sync {
167 fn name(&self) -> &str;
169
170 fn description(&self) -> &str;
172
173 fn parameters_schema(&self) -> serde_json::Value;
175
176 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
178
179 fn category(&self) -> ToolCategory {
181 ToolCategory::Write
182 }
183
184 fn origin(&self) -> ToolOrigin {
186 ToolOrigin::Host
187 }
188
189 fn is_terminal(&self) -> bool {
191 false
192 }
193
194 fn spec(&self) -> ToolSpec {
196 let category = self.category();
197 ToolSpec {
198 name: self.name().to_string(),
199 description: format!(
200 "[{}] {} {}",
201 category.label(),
202 category.guidance(),
203 self.description()
204 ),
205 parameters: self.parameters_schema(),
206 category,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "lowercase")]
214pub enum ToolAutonomy {
215 ReadOnly,
216 #[default]
217 Supervised,
218 Full,
219}
220
221#[derive(Debug, Clone)]
225pub struct ToolSecurity {
226 pub autonomy: ToolAutonomy,
227 pub workspace_dir: PathBuf,
228 pub forwarded_env_names: Vec<String>,
229}
230
231impl Default for ToolSecurity {
232 fn default() -> Self {
233 let home = std::env::var("HOME")
234 .map(PathBuf::from)
235 .unwrap_or_else(|_| PathBuf::from("."));
236 Self {
237 autonomy: ToolAutonomy::Supervised,
238 workspace_dir: home.join(".nenjo").join("workspace"),
239 forwarded_env_names: Vec::new(),
240 }
241 }
242}
243
244impl ToolSecurity {
245 pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
246 Self {
247 workspace_dir,
248 ..Default::default()
249 }
250 }
251}
252
253pub fn sanitize_tool_name(name: &str) -> String {
259 name.chars()
260 .map(|c| {
261 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
262 c
263 } else {
264 '_'
265 }
266 })
267 .collect()
268}
269
270pub fn sanitize_tool_name_lenient(name: &str) -> String {
273 name.chars()
274 .map(|c| {
275 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
276 c
277 } else {
278 '_'
279 }
280 })
281 .collect()
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287
288 struct DummyTool;
289
290 #[async_trait]
291 impl Tool for DummyTool {
292 fn name(&self) -> &str {
293 "dummy"
294 }
295
296 fn description(&self) -> &str {
297 "A test tool"
298 }
299
300 fn parameters_schema(&self) -> serde_json::Value {
301 serde_json::json!({
302 "type": "object",
303 "properties": { "value": { "type": "string" } }
304 })
305 }
306
307 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
308 Ok(ToolResult {
309 success: true,
310 output: args["value"].as_str().unwrap_or_default().to_string(),
311 error: None,
312 })
313 }
314 }
315
316 #[test]
317 fn spec_uses_tool_metadata() {
318 let spec = DummyTool.spec();
319 assert_eq!(spec.name, "dummy");
320 assert_eq!(spec.category, ToolCategory::Write);
321 }
322
323 #[tokio::test]
324 async fn execute_returns_output() {
325 let result = DummyTool
326 .execute(serde_json::json!({"value": "hello"}))
327 .await
328 .unwrap();
329 assert!(result.success);
330 assert_eq!(result.output, "hello");
331 }
332
333 #[test]
334 fn tool_result_roundtrip() {
335 let result = ToolResult {
336 success: false,
337 output: String::new(),
338 error: Some("boom".into()),
339 };
340 let json = serde_json::to_string(&result).unwrap();
341 let parsed: ToolResult = serde_json::from_str(&json).unwrap();
342 assert_eq!(parsed.error.as_deref(), Some("boom"));
343 }
344
345 #[test]
346 fn sanitize_tool_name_replaces_dots_and_slashes() {
347 assert_eq!(
348 sanitize_tool_name("app.nenjo.platform/tasks"),
349 "app_nenjo_platform_tasks"
350 );
351 }
352
353 #[test]
354 fn sanitize_tool_name_preserves_valid_chars() {
355 assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
356 }
357
358 #[test]
359 fn sanitize_tool_name_lenient_preserves_dots() {
360 assert_eq!(
361 sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
362 "app.nenjo.platform_tasks"
363 );
364 }
365}