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/// Runtime ownership surface for a tool.
146///
147/// This is intentionally separate from read/write category. It describes who
148/// owns tool availability and scoping so runtimes can rebuild or inherit tools
149/// correctly across abilities, domains, and sub-agents.
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum ToolOrigin {
153    /// Host/runtime tools such as shell, files, git, web, and memory.
154    #[default]
155    Host,
156    /// External MCP server tools governed by an agent or ability MCP assignment.
157    Mcp,
158    /// Platform resource tools whose availability is governed by platform scopes.
159    Platform,
160    /// Harness orchestration tools such as ability or sub-agent control.
161    Harness,
162}
163
164/// Core tool trait for agent capabilities.
165#[async_trait]
166pub trait Tool: Send + Sync {
167    /// Tool name used in LLM function calling.
168    fn name(&self) -> &str;
169
170    /// Human-readable description shown to the LLM.
171    fn description(&self) -> &str;
172
173    /// JSON Schema for the tool's parameters.
174    fn parameters_schema(&self) -> serde_json::Value;
175
176    /// Execute the tool with the given arguments.
177    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
178
179    /// Tool category for profile-based filtering.
180    fn category(&self) -> ToolCategory {
181        ToolCategory::Write
182    }
183
184    /// Runtime ownership surface for this tool.
185    fn origin(&self) -> ToolOrigin {
186        ToolOrigin::Host
187    }
188
189    /// Whether calling this tool should immediately end the turn loop.
190    fn is_terminal(&self) -> bool {
191        false
192    }
193
194    /// Build the full spec for LLM registration.
195    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/// High-level autonomy requested while constructing runtime tools.
212#[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/// SDK-level tool construction policy.
222///
223/// Concrete runtimes can translate this into their own enforcement policy.
224#[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
253/// Sanitize a tool function name to match the strict OpenAI pattern
254/// `^[a-zA-Z0-9_-]+$`.
255///
256/// Used by OpenAI, DeepSeek, and other strict providers. Replaces dots, slashes,
257/// and any other disallowed characters with `_`.
258pub 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
270/// Light sanitization for lenient providers (Ollama) while preserving dots used
271/// in MCP namespaced tool names.
272pub 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}