use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
use std::path::PathBuf;
pub mod async_ops;
pub use async_ops::{
AsyncOperationKind, AsyncOperationSignalKind, AsyncOperationStatus,
INSPECT_OPERATIONS_TOOL_NAME, InspectOperationsArgs, SEND_OPERATION_INPUT_TOOL_NAME,
STOP_OPERATIONS_TOOL_NAME, SendOperationInputArgs, StopOperationsArgs,
WAIT_OPERATIONS_TOOL_NAME, WaitOperationsArgs, inspect_operations_parameters_schema,
send_operation_input_parameters_schema, stop_operations_parameters_schema,
wait_operations_parameters_schema,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolCategory {
Read,
#[default]
Write,
ReadWrite,
}
impl ToolCategory {
pub fn label(self) -> &'static str {
match self {
Self::Read => "READ",
Self::Write => "WRITE",
Self::ReadWrite => "READ/WRITE",
}
}
pub fn guidance(self) -> &'static str {
match self {
Self::Read => "Inspects or verifies state without persistent side effects.",
Self::Write => {
"Mutates persistent state. Use sparingly and avoid repeated calls in one turn."
}
Self::ReadWrite => {
"Can read and mutate state. Use carefully and avoid repeated calls in one turn."
}
}
}
pub fn is_write_like(self) -> bool {
!matches!(self, Self::Read)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
#[serde(default)]
pub category: ToolCategory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
impl Display for ToolCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "name={} arguments={}", self.name, self.arguments)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResultMessage {
pub tool_call_id: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolOrigin {
#[default]
Host,
Mcp,
Platform,
Harness,
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
fn category(&self) -> ToolCategory {
ToolCategory::Write
}
fn origin(&self) -> ToolOrigin {
ToolOrigin::Host
}
fn is_terminal(&self) -> bool {
false
}
fn spec(&self) -> ToolSpec {
let category = self.category();
ToolSpec {
name: self.name().to_string(),
description: format!(
"[{}] {} {}",
category.label(),
category.guidance(),
self.description()
),
parameters: self.parameters_schema(),
category,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolAutonomy {
ReadOnly,
#[default]
Supervised,
Full,
}
#[derive(Debug, Clone)]
pub struct ToolSecurity {
pub autonomy: ToolAutonomy,
pub workspace_dir: PathBuf,
pub forwarded_env_names: Vec<String>,
}
impl Default for ToolSecurity {
fn default() -> Self {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
Self {
autonomy: ToolAutonomy::Supervised,
workspace_dir: home.join(".nenjo").join("workspace"),
forwarded_env_names: Vec::new(),
}
}
}
impl ToolSecurity {
pub fn with_workspace_dir(workspace_dir: PathBuf) -> Self {
Self {
workspace_dir,
..Default::default()
}
}
}
pub fn sanitize_tool_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect()
}
pub fn sanitize_tool_name_lenient(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyTool;
#[async_trait]
impl Tool for DummyTool {
fn name(&self) -> &str {
"dummy"
}
fn description(&self) -> &str {
"A test tool"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": { "value": { "type": "string" } }
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: args["value"].as_str().unwrap_or_default().to_string(),
error: None,
})
}
}
#[test]
fn spec_uses_tool_metadata() {
let spec = DummyTool.spec();
assert_eq!(spec.name, "dummy");
assert_eq!(spec.category, ToolCategory::Write);
}
#[tokio::test]
async fn execute_returns_output() {
let result = DummyTool
.execute(serde_json::json!({"value": "hello"}))
.await
.unwrap();
assert!(result.success);
assert_eq!(result.output, "hello");
}
#[test]
fn tool_result_roundtrip() {
let result = ToolResult {
success: false,
output: String::new(),
error: Some("boom".into()),
};
let json = serde_json::to_string(&result).unwrap();
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.error.as_deref(), Some("boom"));
}
#[test]
fn sanitize_tool_name_replaces_dots_and_slashes() {
assert_eq!(
sanitize_tool_name("app.nenjo.platform/tasks"),
"app_nenjo_platform_tasks"
);
}
#[test]
fn sanitize_tool_name_preserves_valid_chars() {
assert_eq!(sanitize_tool_name("my-tool_v2"), "my-tool_v2");
}
#[test]
fn sanitize_tool_name_lenient_preserves_dots() {
assert_eq!(
sanitize_tool_name_lenient("app.nenjo.platform/tasks"),
"app.nenjo.platform_tasks"
);
}
}