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
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/// Classifies a tool's side-effect profile for filtering and model guidance.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum ToolCategory {
83    /// Pure read/search with no persistent side effects.
84    Read,
85    /// Mutates files, state, or external systems.
86    #[default]
87    Write,
88    /// Both read and write sub-operations.
89    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/// Full specification of a tool for LLM registration.
119#[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/// A tool call requested by the LLM.
129#[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/// A tool result to feed back to the LLM.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct ToolResultMessage {
145    pub tool_call_id: String,
146    pub content: String,
147}
148
149/// Result of a tool execution.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ToolResult {
152    pub success: bool,
153    pub output: String,
154    pub error: Option<String>,
155}
156
157/// Runtime ownership surface for a tool.
158///
159/// This is intentionally separate from read/write category. It describes who
160/// owns tool availability and scoping so runtimes can rebuild or inherit tools
161/// correctly across abilities, domains, and sub-agents.
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum ToolOrigin {
165    /// Host/runtime tools such as shell, files, git, web, and memory.
166    #[default]
167    Host,
168    /// External MCP server tools governed by an agent or ability MCP assignment.
169    Mcp,
170    /// Platform resource tools whose availability is governed by platform scopes.
171    Platform,
172    /// Harness orchestration tools such as ability or sub-agent control.
173    Harness,
174}
175
176/// Core tool trait for agent capabilities.
177#[async_trait]
178pub trait Tool: Send + Sync {
179    /// Tool name used in LLM function calling.
180    fn name(&self) -> &str;
181
182    /// Human-readable description shown to the LLM.
183    fn description(&self) -> &str;
184
185    /// JSON Schema for the tool's parameters.
186    fn parameters_schema(&self) -> serde_json::Value;
187
188    /// Execute the tool with the given arguments.
189    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
190
191    /// Tool category for profile-based filtering.
192    fn category(&self) -> ToolCategory {
193        ToolCategory::Write
194    }
195
196    /// Runtime ownership surface for this tool.
197    fn origin(&self) -> ToolOrigin {
198        ToolOrigin::Host
199    }
200
201    /// Whether calling this tool should immediately end the turn loop.
202    fn is_terminal(&self) -> bool {
203        false
204    }
205
206    /// Build the full spec for LLM registration.
207    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/// High-level autonomy requested while constructing runtime tools.
224#[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/// SDK-level tool construction policy.
234///
235/// Concrete runtimes can translate this into their own enforcement policy.
236#[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
265/// Sanitize a tool function name to match the strict OpenAI pattern
266/// `^[a-zA-Z0-9_-]+$`.
267///
268/// Used by OpenAI, DeepSeek, and other strict providers. Replaces dots, slashes,
269/// and any other disallowed characters with `_`.
270pub 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
282/// Light sanitization for lenient providers (Ollama) while preserving dots used
283/// in MCP namespaced tool names.
284pub 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}