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