Skip to main content

nenjo_tool_api/
lib.rs

1//! Shared tool contracts for Nenjo agents, model providers, and runtimes.
2//!
3//! This crate owns the common tool API surface used across the Nenjo workspace.
4//! It is deliberately independent from the rest of the workspace so model
5//! integrations, SDK code, and worker runtimes can agree on tool schemas and
6//! execution results without depending on each other.
7//!
8//! The main entry points are:
9//!
10//! - [`Tool`], the async trait implemented by concrete tool runtimes.
11//! - [`ToolSpec`], the JSON-schema-backed metadata sent to model providers.
12//! - [`ToolCategory`], the side-effect classification used for guidance and
13//!   filtering.
14//! - [`ToolCall`], [`ToolResult`], and [`ToolResultMessage`], the request and
15//!   result payloads that flow through tool execution.
16//! - [`ToolAutonomy`] and [`ToolSecurity`], the SDK-level policy inputs used
17//!   when constructing tools.
18//!
19//! # Example
20//!
21//! ```rust
22//! use async_trait::async_trait;
23//! use serde_json::json;
24//! use nenjo_tool_api::{Tool, ToolCategory, ToolResult};
25//!
26//! struct EchoTool;
27//!
28//! #[async_trait]
29//! impl Tool for EchoTool {
30//!     fn name(&self) -> &str {
31//!         "echo"
32//!     }
33//!
34//!     fn description(&self) -> &str {
35//!         "Echoes a message back to the caller."
36//!     }
37//!
38//!     fn parameters_schema(&self) -> serde_json::Value {
39//!         json!({
40//!             "type": "object",
41//!             "properties": {
42//!                 "message": { "type": "string" }
43//!             },
44//!             "required": ["message"]
45//!         })
46//!     }
47//!
48//!     fn category(&self) -> ToolCategory {
49//!         ToolCategory::Read
50//!     }
51//!
52//!     async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
53//!         Ok(ToolResult {
54//!             success: true,
55//!             output: args["message"].as_str().unwrap_or_default().to_string(),
56//!             error: None,
57//!         })
58//!     }
59//! }
60//! ```
61
62use async_trait::async_trait;
63use serde::{Deserialize, Serialize};
64use std::fmt::Display;
65use std::path::PathBuf;
66
67/// Classifies a tool's side-effect profile for filtering and model guidance.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum ToolCategory {
71    /// Pure read/search with no persistent side effects.
72    Read,
73    /// Mutates files, state, or external systems.
74    #[default]
75    Write,
76    /// Both read and write sub-operations.
77    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/// Full specification of a tool for LLM registration.
107#[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/// A tool call requested by the LLM.
117#[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/// A tool result to feed back to the LLM.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ToolResultMessage {
133    pub tool_call_id: String,
134    pub content: String,
135}
136
137/// Result of a tool execution.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct ToolResult {
140    pub success: bool,
141    pub output: String,
142    pub error: Option<String>,
143}
144
145/// Core tool trait for agent capabilities.
146#[async_trait]
147pub trait Tool: Send + Sync {
148    /// Tool name used in LLM function calling.
149    fn name(&self) -> &str;
150
151    /// Human-readable description shown to the LLM.
152    fn description(&self) -> &str;
153
154    /// JSON Schema for the tool's parameters.
155    fn parameters_schema(&self) -> serde_json::Value;
156
157    /// Execute the tool with the given arguments.
158    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
159
160    /// Tool category for profile-based filtering.
161    fn category(&self) -> ToolCategory {
162        ToolCategory::Write
163    }
164
165    /// Whether calling this tool should immediately end the turn loop.
166    fn is_terminal(&self) -> bool {
167        false
168    }
169
170    /// Build the full spec for LLM registration.
171    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/// High-level autonomy requested while constructing runtime tools.
188#[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/// SDK-level tool construction policy.
198///
199/// Concrete runtimes can translate this into their own enforcement policy.
200#[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
229/// Sanitize a tool function name to match the strict OpenAI pattern
230/// `^[a-zA-Z0-9_-]+$`.
231///
232/// Used by OpenAI, DeepSeek, and other strict providers. Replaces dots, slashes,
233/// and any other disallowed characters with `_`.
234pub 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
246/// Light sanitization for lenient providers (Ollama) while preserving dots used
247/// in MCP namespaced tool names.
248pub 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}