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, inspect_operations_parameters_schema,
74    send_operation_input_parameters_schema, stop_operations_parameters_schema,
75    wait_operations_parameters_schema,
76};
77
78/// Classifies a tool's side-effect profile for filtering and model guidance.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
80#[serde(rename_all = "snake_case")]
81pub enum ToolCategory {
82    /// Pure read/search with no persistent side effects.
83    Read,
84    /// Mutates files, state, or external systems.
85    #[default]
86    Write,
87    /// Both read and write sub-operations.
88    ReadWrite,
89}
90
91impl ToolCategory {
92    pub fn label(self) -> &'static str {
93        match self {
94            Self::Read => "READ",
95            Self::Write => "WRITE",
96            Self::ReadWrite => "READ/WRITE",
97        }
98    }
99
100    pub fn guidance(self) -> &'static str {
101        match self {
102            Self::Read => "Inspects or verifies state without persistent side effects.",
103            Self::Write => {
104                "Mutates persistent state. Use sparingly and avoid repeated calls in one turn."
105            }
106            Self::ReadWrite => {
107                "Can read and mutate state. Use carefully and avoid repeated calls in one turn."
108            }
109        }
110    }
111
112    pub fn is_write_like(self) -> bool {
113        !matches!(self, Self::Read)
114    }
115}
116
117/// Full specification of a tool for LLM registration.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct ToolSpec {
120    pub name: String,
121    pub description: String,
122    pub parameters: serde_json::Value,
123    #[serde(default)]
124    pub category: ToolCategory,
125}
126
127/// A tool call requested by the LLM.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ToolCall {
130    pub id: String,
131    pub name: String,
132    pub arguments: String,
133}
134
135impl Display for ToolCall {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        write!(f, "name={} arguments={}", self.name, self.arguments)
138    }
139}
140
141/// A tool result to feed back to the LLM.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ToolResultMessage {
144    pub tool_call_id: String,
145    pub content: String,
146}
147
148/// Result of a tool execution.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ToolResult {
151    pub success: bool,
152    pub output: String,
153    pub error: Option<String>,
154}
155
156/// Runtime ownership surface for a tool.
157///
158/// This is intentionally separate from read/write category. It describes who
159/// owns tool availability and scoping so runtimes can rebuild or inherit tools
160/// correctly across abilities, domains, and sub-agents.
161#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum ToolOrigin {
164    /// Host/runtime tools such as shell, files, git, web, and memory.
165    #[default]
166    Host,
167    /// External MCP server tools governed by an agent or ability MCP assignment.
168    Mcp,
169    /// Platform resource tools whose availability is governed by platform scopes.
170    Platform,
171    /// Harness orchestration tools such as ability or sub-agent control.
172    Harness,
173}
174
175/// Core tool trait for agent capabilities.
176#[async_trait]
177pub trait Tool: Send + Sync {
178    /// Tool name used in LLM function calling.
179    fn name(&self) -> &str;
180
181    /// Human-readable description shown to the LLM.
182    fn description(&self) -> &str;
183
184    /// JSON Schema for the tool's parameters.
185    fn parameters_schema(&self) -> serde_json::Value;
186
187    /// Execute the tool with the given arguments.
188    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
189
190    /// Tool category for profile-based filtering.
191    fn category(&self) -> ToolCategory {
192        ToolCategory::Write
193    }
194
195    /// Runtime ownership surface for this tool.
196    fn origin(&self) -> ToolOrigin {
197        ToolOrigin::Host
198    }
199
200    /// Whether calling this tool should immediately end the turn loop.
201    fn is_terminal(&self) -> bool {
202        false
203    }
204
205    /// Build the full spec for LLM registration.
206    fn spec(&self) -> ToolSpec {
207        let category = self.category();
208        ToolSpec {
209            name: self.name().to_string(),
210            description: format!(
211                "[{}] {} {}",
212                category.label(),
213                category.guidance(),
214                self.description()
215            ),
216            parameters: self.parameters_schema(),
217            category,
218        }
219    }
220}
221
222/// High-level autonomy requested while constructing runtime tools.
223#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
224#[serde(rename_all = "lowercase")]
225pub enum ToolAutonomy {
226    ReadOnly,
227    #[default]
228    Supervised,
229    Full,
230}
231
232/// SDK-level tool construction policy.
233///
234/// Concrete runtimes can translate this into their own enforcement policy.
235#[derive(Debug, Clone)]
236pub struct ToolSecurity {
237    pub autonomy: ToolAutonomy,
238    pub workspace_dir: PathBuf,
239    pub forwarded_env_names: Vec<String>,
240}
241
242impl Default for ToolSecurity {
243    fn default() -> Self {
244        let home = std::env::var("HOME")
245            .map(PathBuf::from)
246            .unwrap_or_else(|_| PathBuf::from("."));
247        Self {
248            autonomy: ToolAutonomy::Supervised,
249            workspace_dir: home.join(".nenjo").join("workspace"),
250            forwarded_env_names: Vec::new(),
251        }
252    }
253}
254
255impl ToolSecurity {
256    pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
257        Self {
258            workspace_dir,
259            ..Default::default()
260        }
261    }
262}
263
264/// Sanitize a tool function name to match the strict OpenAI pattern
265/// `^[a-zA-Z0-9_-]+$`.
266///
267/// Used by OpenAI, DeepSeek, and other strict providers. Replaces dots, slashes,
268/// and any other disallowed characters with `_`.
269pub fn sanitize_tool_name(name: &str) -> String {
270    name.chars()
271        .map(|c| {
272            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
273                c
274            } else {
275                '_'
276            }
277        })
278        .collect()
279}
280
281/// Light sanitization for lenient providers (Ollama) while preserving dots used
282/// in MCP namespaced tool names.
283pub fn sanitize_tool_name_lenient(name: &str) -> String {
284    name.chars()
285        .map(|c| {
286            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
287                c
288            } else {
289                '_'
290            }
291        })
292        .collect()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    struct DummyTool;
300
301    #[async_trait]
302    impl Tool for DummyTool {
303        fn name(&self) -> &str {
304            "dummy"
305        }
306
307        fn description(&self) -> &str {
308            "A test tool"
309        }
310
311        fn parameters_schema(&self) -> serde_json::Value {
312            serde_json::json!({
313                "type": "object",
314                "properties": { "value": { "type": "string" } }
315            })
316        }
317
318        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
319            Ok(ToolResult {
320                success: true,
321                output: args["value"].as_str().unwrap_or_default().to_string(),
322                error: None,
323            })
324        }
325    }
326
327    #[test]
328    fn spec_uses_tool_metadata() {
329        let spec = DummyTool.spec();
330        assert_eq!(spec.name, "dummy");
331        assert_eq!(spec.category, ToolCategory::Write);
332    }
333
334    #[tokio::test]
335    async fn execute_returns_output() {
336        let result = DummyTool
337            .execute(serde_json::json!({"value": "hello"}))
338            .await
339            .unwrap();
340        assert!(result.success);
341        assert_eq!(result.output, "hello");
342    }
343
344    #[test]
345    fn tool_result_roundtrip() {
346        let result = ToolResult {
347            success: false,
348            output: String::new(),
349            error: Some("boom".into()),
350        };
351        let json = serde_json::to_string(&result).unwrap();
352        let parsed: ToolResult = serde_json::from_str(&json).unwrap();
353        assert_eq!(parsed.error.as_deref(), Some("boom"));
354    }
355
356    #[test]
357    fn sanitize_tool_name_replaces_dots_and_slashes() {
358        assert_eq!(
359            sanitize_tool_name("app.nenjo.platform/tasks"),
360            "app_nenjo_platform_tasks"
361        );
362    }
363
364    #[test]
365    fn sanitize_tool_name_preserves_valid_chars() {
366        assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
367    }
368
369    #[test]
370    fn sanitize_tool_name_lenient_preserves_dots() {
371        assert_eq!(
372            sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
373            "app.nenjo.platform_tasks"
374        );
375    }
376}