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}
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
227/// Sanitize a tool function name to match the strict OpenAI pattern
228/// `^[a-zA-Z0-9_-]+$`.
229///
230/// Used by OpenAI, DeepSeek, and other strict providers. Replaces dots, slashes,
231/// and any other disallowed characters with `_`.
232pub 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
244/// Light sanitization for lenient providers (Ollama) while preserving dots used
245/// in MCP namespaced tool names.
246pub 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}