1use async_trait::async_trait;
63use serde::{Deserialize, Serialize};
64use std::fmt::Display;
65use std::path::PathBuf;
66
67pub mod async_ops;
68
69pub use async_ops::{
70 AsyncOperationKind, AsyncOperationSignalKind, AsyncOperationStatus,
71 INSPECT_OPERATIONS_TOOL_NAME, InspectOperationsArgs, SEND_OPERATION_INPUT_TOOL_NAME,
72 STOP_OPERATIONS_TOOL_NAME, SendOperationInputArgs, StopOperationsArgs,
73 WAIT_OPERATIONS_TOOL_NAME, WaitOperationsArgs, inspect_operations_parameters_schema,
74 send_operation_input_parameters_schema, stop_operations_parameters_schema,
75 wait_operations_parameters_schema,
76};
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum ToolCategory {
82 Read,
84 #[default]
86 Write,
87 ReadWrite,
89}
90
91impl ToolCategory {
92 pub fn label(self) -> &'static str {
93 match self {
94 Self::Read => "READ",
95 Self::Write => "WRITE",
96 Self::ReadWrite => "READ/WRITE",
97 }
98 }
99
100 pub fn guidance(self) -> &'static str {
101 match self {
102 Self::Read => "Inspects or verifies state without persistent side effects.",
103 Self::Write => {
104 "Mutates persistent state. Use sparingly and avoid repeated calls in one turn."
105 }
106 Self::ReadWrite => {
107 "Can read and mutate state. Use carefully and avoid repeated calls in one turn."
108 }
109 }
110 }
111
112 pub fn is_write_like(self) -> bool {
113 !matches!(self, Self::Read)
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ToolSpec {
120 pub name: String,
121 pub description: String,
122 pub parameters: serde_json::Value,
123 #[serde(default)]
124 pub category: ToolCategory,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ToolCall {
130 pub id: String,
131 pub name: String,
132 pub arguments: String,
133}
134
135impl Display for ToolCall {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 write!(f, "name={} arguments={}", self.name, self.arguments)
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolResultMessage {
144 pub tool_call_id: String,
145 pub content: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ToolResult {
151 pub success: bool,
152 pub output: String,
153 pub error: Option<String>,
154}
155
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum ToolOrigin {
164 #[default]
166 Host,
167 Mcp,
169 Platform,
171 Harness,
173}
174
175#[async_trait]
177pub trait Tool: Send + Sync {
178 fn name(&self) -> &str;
180
181 fn description(&self) -> &str;
183
184 fn parameters_schema(&self) -> serde_json::Value;
186
187 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
189
190 fn category(&self) -> ToolCategory {
192 ToolCategory::Write
193 }
194
195 fn origin(&self) -> ToolOrigin {
197 ToolOrigin::Host
198 }
199
200 fn is_terminal(&self) -> bool {
202 false
203 }
204
205 fn spec(&self) -> ToolSpec {
207 let category = self.category();
208 ToolSpec {
209 name: self.name().to_string(),
210 description: format!(
211 "[{}] {} {}",
212 category.label(),
213 category.guidance(),
214 self.description()
215 ),
216 parameters: self.parameters_schema(),
217 category,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum ToolAutonomy {
226 ReadOnly,
227 #[default]
228 Supervised,
229 Full,
230}
231
232#[derive(Debug, Clone)]
236pub struct ToolSecurity {
237 pub autonomy: ToolAutonomy,
238 pub workspace_dir: PathBuf,
239 pub forwarded_env_names: Vec<String>,
240}
241
242impl Default for ToolSecurity {
243 fn default() -> Self {
244 let home = std::env::var("HOME")
245 .map(PathBuf::from)
246 .unwrap_or_else(|_| PathBuf::from("."));
247 Self {
248 autonomy: ToolAutonomy::Supervised,
249 workspace_dir: home.join(".nenjo").join("workspace"),
250 forwarded_env_names: Vec::new(),
251 }
252 }
253}
254
255impl ToolSecurity {
256 pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
257 Self {
258 workspace_dir,
259 ..Default::default()
260 }
261 }
262}
263
264pub fn sanitize_tool_name(name: &str) -> String {
270 name.chars()
271 .map(|c| {
272 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
273 c
274 } else {
275 '_'
276 }
277 })
278 .collect()
279}
280
281pub fn sanitize_tool_name_lenient(name: &str) -> String {
284 name.chars()
285 .map(|c| {
286 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
287 c
288 } else {
289 '_'
290 }
291 })
292 .collect()
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 struct DummyTool;
300
301 #[async_trait]
302 impl Tool for DummyTool {
303 fn name(&self) -> &str {
304 "dummy"
305 }
306
307 fn description(&self) -> &str {
308 "A test tool"
309 }
310
311 fn parameters_schema(&self) -> serde_json::Value {
312 serde_json::json!({
313 "type": "object",
314 "properties": { "value": { "type": "string" } }
315 })
316 }
317
318 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
319 Ok(ToolResult {
320 success: true,
321 output: args["value"].as_str().unwrap_or_default().to_string(),
322 error: None,
323 })
324 }
325 }
326
327 #[test]
328 fn spec_uses_tool_metadata() {
329 let spec = DummyTool.spec();
330 assert_eq!(spec.name, "dummy");
331 assert_eq!(spec.category, ToolCategory::Write);
332 }
333
334 #[tokio::test]
335 async fn execute_returns_output() {
336 let result = DummyTool
337 .execute(serde_json::json!({"value": "hello"}))
338 .await
339 .unwrap();
340 assert!(result.success);
341 assert_eq!(result.output, "hello");
342 }
343
344 #[test]
345 fn tool_result_roundtrip() {
346 let result = ToolResult {
347 success: false,
348 output: String::new(),
349 error: Some("boom".into()),
350 };
351 let json = serde_json::to_string(&result).unwrap();
352 let parsed: ToolResult = serde_json::from_str(&json).unwrap();
353 assert_eq!(parsed.error.as_deref(), Some("boom"));
354 }
355
356 #[test]
357 fn sanitize_tool_name_replaces_dots_and_slashes() {
358 assert_eq!(
359 sanitize_tool_name("app.nenjo.platform/tasks"),
360 "app_nenjo_platform_tasks"
361 );
362 }
363
364 #[test]
365 fn sanitize_tool_name_preserves_valid_chars() {
366 assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
367 }
368
369 #[test]
370 fn sanitize_tool_name_lenient_preserves_dots() {
371 assert_eq!(
372 sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
373 "app.nenjo.platform_tasks"
374 );
375 }
376}