feat: initial release — ZeroClaw v0.1.0
- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.) - 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook) - 5-step onboarding wizard with Project Context personalization - OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.) - SQLite memory backend with auto-save - Skills system with on-demand loading - Security: autonomy levels, command allowlists, cost limits - 532 tests passing, 0 clippy warnings
This commit is contained in:
commit
05cb353f7f
71 changed files with 15757 additions and 0 deletions
203
src/tools/file_read.rs
Normal file
203
src/tools/file_read.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read file contents with path sandboxing
|
||||
pub struct FileReadTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl FileReadTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"file_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the contents of a file in the workspace"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relative path to the file within the workspace"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
|
||||
|
||||
// Security check: validate path is within workspace
|
||||
if !self.security.is_path_allowed(path) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Path not allowed by security policy: {path}")),
|
||||
});
|
||||
}
|
||||
|
||||
let full_path = self.security.workspace_dir.join(path);
|
||||
|
||||
match tokio::fs::read_to_string(&full_path).await {
|
||||
Ok(contents) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: contents,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to read file: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
|
||||
fn test_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: workspace,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_read_name() {
|
||||
let tool = FileReadTool::new(test_security(std::env::temp_dir()));
|
||||
assert_eq!(tool.name(), "file_read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_read_schema_has_path() {
|
||||
let tool = FileReadTool::new(test_security(std::env::temp_dir()));
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["path"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("path")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_existing_file() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("test.txt"), "hello world")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool.execute(json!({"path": "test.txt"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "hello world");
|
||||
assert!(result.error.is_none());
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_nonexistent_file() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_missing");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool.execute(json!({"path": "nope.txt"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("Failed to read"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_path_traversal() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_traversal");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "../../../etc/passwd"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_blocks_absolute_path() {
|
||||
let tool = FileReadTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({"path": "/etc/passwd"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_missing_path_param() {
|
||||
let tool = FileReadTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_empty_file() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_empty");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("empty.txt"), "").await.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_read_nested_path() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_read_nested");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(dir.join("sub/dir"))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.join("sub/dir/deep.txt"), "deep content")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileReadTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "sub/dir/deep.txt"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "deep content");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
}
|
||||
242
src/tools/file_write.rs
Normal file
242
src/tools/file_write.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Write file contents with path sandboxing
|
||||
pub struct FileWriteTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl FileWriteTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FileWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"file_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Write contents to a file in the workspace"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Relative path to the file within the workspace"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Content to write to the file"
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?;
|
||||
|
||||
let content = args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
|
||||
|
||||
// Security check: validate path is within workspace
|
||||
if !self.security.is_path_allowed(path) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Path not allowed by security policy: {path}")),
|
||||
});
|
||||
}
|
||||
|
||||
let full_path = self.security.workspace_dir.join(path);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = full_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
match tokio::fs::write(&full_path, content).await {
|
||||
Ok(()) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Written {} bytes to {path}", content.len()),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to write file: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
|
||||
fn test_security(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy: AutonomyLevel::Supervised,
|
||||
workspace_dir: workspace,
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_name() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
assert_eq!(tool.name(), "file_write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_write_schema_has_path_and_content() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["path"].is_object());
|
||||
assert!(schema["properties"]["content"].is_object());
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.contains(&json!("path")));
|
||||
assert!(required.contains(&json!("content")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_creates_file() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "out.txt", "content": "written!"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("8 bytes"));
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("out.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "written!");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_creates_parent_dirs() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_nested");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "a/b/c/deep.txt", "content": "deep"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "deep");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_overwrites_existing() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_overwrite");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
tokio::fs::write(dir.join("exist.txt"), "old")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "exist.txt", "content": "new"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let content = tokio::fs::read_to_string(dir.join("exist.txt"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content, "new");
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_path_traversal() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_traversal");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "../../etc/evil", "content": "bad"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_blocks_absolute_path() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "/etc/evil", "content": "bad"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_missing_path_param() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({"content": "data"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_missing_content_param() {
|
||||
let tool = FileWriteTool::new(test_security(std::env::temp_dir()));
|
||||
let result = tool.execute(json!({"path": "file.txt"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_write_empty_content() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_file_write_empty");
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
tokio::fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let tool = FileWriteTool::new(test_security(dir.clone()));
|
||||
let result = tool
|
||||
.execute(json!({"path": "empty.txt", "content": ""}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("0 bytes"));
|
||||
|
||||
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
}
|
||||
118
src/tools/memory_forget.rs
Normal file
118
src/tools/memory_forget.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::memory::Memory;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Let the agent forget/delete a memory entry
|
||||
pub struct MemoryForgetTool {
|
||||
memory: Arc<dyn Memory>,
|
||||
}
|
||||
|
||||
impl MemoryForgetTool {
|
||||
pub fn new(memory: Arc<dyn Memory>) -> Self {
|
||||
Self { memory }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryForgetTool {
|
||||
fn name(&self) -> &str {
|
||||
"memory_forget"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Remove a memory by key. Use to delete outdated facts or sensitive data. Returns whether the memory was found and removed."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The key of the memory to forget"
|
||||
}
|
||||
},
|
||||
"required": ["key"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let key = args
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?;
|
||||
|
||||
match self.memory.forget(key).await {
|
||||
Ok(true) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Forgot memory: {key}"),
|
||||
error: None,
|
||||
}),
|
||||
Ok(false) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("No memory found with key: {key}"),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to forget memory: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::{MemoryCategory, SqliteMemory};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_mem() -> (TempDir, Arc<dyn Memory>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
(tmp, Arc::new(mem))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryForgetTool::new(mem);
|
||||
assert_eq!(tool.name(), "memory_forget");
|
||||
assert!(tool.parameters_schema()["properties"]["key"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_existing() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
mem.store("temp", "temporary", MemoryCategory::Conversation)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = MemoryForgetTool::new(mem.clone());
|
||||
let result = tool.execute(json!({"key": "temp"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("Forgot"));
|
||||
|
||||
assert!(mem.get("temp").await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_nonexistent() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryForgetTool::new(mem);
|
||||
let result = tool.execute(json!({"key": "nope"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("No memory found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_missing_key() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryForgetTool::new(mem);
|
||||
let result = tool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
163
src/tools/memory_recall.rs
Normal file
163
src/tools/memory_recall.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::memory::Memory;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Let the agent search its own memory
|
||||
pub struct MemoryRecallTool {
|
||||
memory: Arc<dyn Memory>,
|
||||
}
|
||||
|
||||
impl MemoryRecallTool {
|
||||
pub fn new(memory: Arc<dyn Memory>) -> Self {
|
||||
Self { memory }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryRecallTool {
|
||||
fn name(&self) -> &str {
|
||||
"memory_recall"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search long-term memory for relevant facts, preferences, or context. Returns scored results ranked by relevance."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Keywords or phrase to search for in memory"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Max results to return (default: 5)"
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'query' parameter"))?;
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.map_or(5, |v| v as usize);
|
||||
|
||||
match self.memory.recall(query, limit).await {
|
||||
Ok(entries) if entries.is_empty() => Ok(ToolResult {
|
||||
success: true,
|
||||
output: "No memories found matching that query.".into(),
|
||||
error: None,
|
||||
}),
|
||||
Ok(entries) => {
|
||||
let mut output = format!("Found {} memories:\n", entries.len());
|
||||
for entry in &entries {
|
||||
let score = entry.score.map_or_else(String::new, |s| format!(" [{s:.0}%]"));
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"- [{}] {}: {}{score}",
|
||||
entry.category, entry.key, entry.content
|
||||
);
|
||||
}
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Memory recall failed: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::{MemoryCategory, SqliteMemory};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn seeded_mem() -> (TempDir, Arc<dyn Memory>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
(tmp, Arc::new(mem))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_empty() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
let result = tool
|
||||
.execute(json!({"query": "anything"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("No memories found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_finds_match() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
mem.store("lang", "User prefers Rust", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store("tz", "Timezone is EST", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
let result = tool.execute(json!({"query": "Rust"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("Rust"));
|
||||
assert!(result.output.contains("Found 1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_respects_limit() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
for i in 0..10 {
|
||||
mem.store(&format!("k{i}"), &format!("Rust fact {i}"), MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
let result = tool
|
||||
.execute(json!({"query": "Rust", "limit": 3}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("Found 3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_missing_query() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
let result = tool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let (_tmp, mem) = seeded_mem();
|
||||
let tool = MemoryRecallTool::new(mem);
|
||||
assert_eq!(tool.name(), "memory_recall");
|
||||
assert!(tool.parameters_schema()["properties"]["query"].is_object());
|
||||
}
|
||||
}
|
||||
146
src/tools/memory_store.rs
Normal file
146
src/tools/memory_store.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::memory::{Memory, MemoryCategory};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Let the agent store memories — its own brain writes
|
||||
pub struct MemoryStoreTool {
|
||||
memory: Arc<dyn Memory>,
|
||||
}
|
||||
|
||||
impl MemoryStoreTool {
|
||||
pub fn new(memory: Arc<dyn Memory>) -> Self {
|
||||
Self { memory }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MemoryStoreTool {
|
||||
fn name(&self) -> &str {
|
||||
"memory_store"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Store a fact, preference, or note in long-term memory. Use category 'core' for permanent facts, 'daily' for session notes, 'conversation' for chat context."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Unique key for this memory (e.g. 'user_lang', 'project_stack')"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The information to remember"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"enum": ["core", "daily", "conversation"],
|
||||
"description": "Memory category: core (permanent), daily (session), conversation (chat)"
|
||||
}
|
||||
},
|
||||
"required": ["key", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let key = args
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'key' parameter"))?;
|
||||
|
||||
let content = args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?;
|
||||
|
||||
let category = match args.get("category").and_then(|v| v.as_str()) {
|
||||
Some("daily") => MemoryCategory::Daily,
|
||||
Some("conversation") => MemoryCategory::Conversation,
|
||||
_ => MemoryCategory::Core,
|
||||
};
|
||||
|
||||
match self.memory.store(key, content, category).await {
|
||||
Ok(()) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Stored memory: {key}"),
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Failed to store memory: {e}")),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::SqliteMemory;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_mem() -> (TempDir, Arc<dyn Memory>) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
(tmp, Arc::new(mem))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryStoreTool::new(mem);
|
||||
assert_eq!(tool.name(), "memory_store");
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["key"].is_object());
|
||||
assert!(schema["properties"]["content"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_core() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryStoreTool::new(mem.clone());
|
||||
let result = tool
|
||||
.execute(json!({"key": "lang", "content": "Prefers Rust"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.contains("lang"));
|
||||
|
||||
let entry = mem.get("lang").await.unwrap();
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().content, "Prefers Rust");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_with_category() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryStoreTool::new(mem.clone());
|
||||
let result = tool
|
||||
.execute(json!({"key": "note", "content": "Fixed bug", "category": "daily"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_missing_key() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryStoreTool::new(mem);
|
||||
let result = tool.execute(json!({"content": "no key"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_missing_content() {
|
||||
let (_tmp, mem) = test_mem();
|
||||
let tool = MemoryStoreTool::new(mem);
|
||||
let result = tool.execute(json!({"key": "no_content"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
189
src/tools/mod.rs
Normal file
189
src/tools/mod.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
pub mod file_read;
|
||||
pub mod file_write;
|
||||
pub mod memory_forget;
|
||||
pub mod memory_recall;
|
||||
pub mod memory_store;
|
||||
pub mod shell;
|
||||
pub mod traits;
|
||||
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_write::FileWriteTool;
|
||||
pub use memory_forget::MemoryForgetTool;
|
||||
pub use memory_recall::MemoryRecallTool;
|
||||
pub use memory_store::MemoryStoreTool;
|
||||
pub use shell::ShellTool;
|
||||
pub use traits::Tool;
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{ToolResult, ToolSpec};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::memory::Memory;
|
||||
use crate::security::SecurityPolicy;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Create the default tool registry
|
||||
pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(ShellTool::new(security.clone())),
|
||||
Box::new(FileReadTool::new(security.clone())),
|
||||
Box::new(FileWriteTool::new(security)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Create full tool registry including memory tools
|
||||
pub fn all_tools(
|
||||
security: Arc<SecurityPolicy>,
|
||||
memory: Arc<dyn Memory>,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(ShellTool::new(security.clone())),
|
||||
Box::new(FileReadTool::new(security.clone())),
|
||||
Box::new(FileWriteTool::new(security)),
|
||||
Box::new(MemoryStoreTool::new(memory.clone())),
|
||||
Box::new(MemoryRecallTool::new(memory.clone())),
|
||||
Box::new(MemoryForgetTool::new(memory)),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> {
|
||||
let security = Arc::new(SecurityPolicy {
|
||||
workspace_dir: config.workspace_dir.clone(),
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::memory::create_memory(&config.memory, &config.workspace_dir)?);
|
||||
let tools_list = all_tools(security, mem);
|
||||
|
||||
match command {
|
||||
super::ToolCommands::List => {
|
||||
println!("Available tools ({}):", tools_list.len());
|
||||
for tool in &tools_list {
|
||||
println!(" - {}: {}", tool.name(), tool.description());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
super::ToolCommands::Test { tool, args } => {
|
||||
let matched = tools_list.iter().find(|t| t.name() == tool);
|
||||
match matched {
|
||||
Some(t) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&args)?;
|
||||
let result = t.execute(parsed).await?;
|
||||
println!("Success: {}", result.success);
|
||||
println!("Output: {}", result.output);
|
||||
if let Some(err) = result.error {
|
||||
println!("Error: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => anyhow::bail!("Unknown tool: {tool}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_tools_has_three() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tools = default_tools(security);
|
||||
assert_eq!(tools.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tools_names() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tools = default_tools(security);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
assert!(names.contains(&"shell"));
|
||||
assert!(names.contains(&"file_read"));
|
||||
assert!(names.contains(&"file_write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tools_all_have_descriptions() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tools = default_tools(security);
|
||||
for tool in &tools {
|
||||
assert!(
|
||||
!tool.description().is_empty(),
|
||||
"Tool {} has empty description",
|
||||
tool.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tools_all_have_schemas() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tools = default_tools(security);
|
||||
for tool in &tools {
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(
|
||||
schema.is_object(),
|
||||
"Tool {} schema is not an object",
|
||||
tool.name()
|
||||
);
|
||||
assert!(
|
||||
schema["properties"].is_object(),
|
||||
"Tool {} schema has no properties",
|
||||
tool.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_spec_generation() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tools = default_tools(security);
|
||||
for tool in &tools {
|
||||
let spec = tool.spec();
|
||||
assert_eq!(spec.name, tool.name());
|
||||
assert_eq!(spec.description, tool.description());
|
||||
assert!(spec.parameters.is_object());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_serde() {
|
||||
let result = ToolResult {
|
||||
success: true,
|
||||
output: "hello".into(),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
let parsed: ToolResult = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.success);
|
||||
assert_eq!(parsed.output, "hello");
|
||||
assert!(parsed.error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_with_error_serde() {
|
||||
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!(!parsed.success);
|
||||
assert_eq!(parsed.error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_spec_serde() {
|
||||
let spec = ToolSpec {
|
||||
name: "test".into(),
|
||||
description: "A test tool".into(),
|
||||
parameters: serde_json::json!({"type": "object"}),
|
||||
};
|
||||
let json = serde_json::to_string(&spec).unwrap();
|
||||
let parsed: ToolSpec = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.name, "test");
|
||||
assert_eq!(parsed.description, "A test tool");
|
||||
}
|
||||
}
|
||||
166
src/tools/shell.rs
Normal file
166
src/tools/shell.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Shell command execution tool with sandboxing
|
||||
pub struct ShellTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
}
|
||||
|
||||
impl ShellTool {
|
||||
pub fn new(security: Arc<SecurityPolicy>) -> Self {
|
||||
Self { security }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ShellTool {
|
||||
fn name(&self) -> &str {
|
||||
"shell"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute a shell command in the workspace directory"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The shell command to execute"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let command = args
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'command' parameter"))?;
|
||||
|
||||
// Security check: validate command against allowlist
|
||||
if !self.security.is_command_allowed(command) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Command not allowed by security policy: {command}")),
|
||||
});
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(command)
|
||||
.current_dir(&self.security.workspace_dir)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
|
||||
Ok(ToolResult {
|
||||
success: output.status.success(),
|
||||
output: stdout,
|
||||
error: if stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stderr)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
|
||||
fn test_security(autonomy: AutonomyLevel) -> Arc<SecurityPolicy> {
|
||||
Arc::new(SecurityPolicy {
|
||||
autonomy,
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
..SecurityPolicy::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_name() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
assert_eq!(tool.name(), "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_description() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
assert!(!tool.description().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_tool_schema_has_command() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["command"].is_object());
|
||||
assert!(schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("command")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_executes_allowed_command() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let result = tool
|
||||
.execute(json!({"command": "echo hello"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.output.trim().contains("hello"));
|
||||
assert!(result.error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_blocks_disallowed_command() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let result = tool.execute(json!({"command": "rm -rf /"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_blocks_readonly() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::ReadOnly));
|
||||
let result = tool.execute(json!({"command": "ls"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.error.as_ref().unwrap().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_missing_command_param() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let result = tool.execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("command"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_wrong_type_param() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let result = tool.execute(json!({"command": 123})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_captures_exit_code() {
|
||||
let tool = ShellTool::new(test_security(AutonomyLevel::Supervised));
|
||||
let result = tool
|
||||
.execute(json!({"command": "ls /nonexistent_dir_xyz"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.success);
|
||||
}
|
||||
}
|
||||
43
src/tools/traits.rs
Normal file
43
src/tools/traits.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Result of a tool execution
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
pub success: bool,
|
||||
pub output: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Description of a tool for the LLM
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Core tool trait — implement for any capability
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Tool name (used in LLM function calling)
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Human-readable description
|
||||
fn description(&self) -> &str;
|
||||
|
||||
/// JSON schema for parameters
|
||||
fn parameters_schema(&self) -> serde_json::Value;
|
||||
|
||||
/// Execute the tool with given arguments
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
|
||||
|
||||
/// Get the full spec for LLM registration
|
||||
fn spec(&self) -> ToolSpec {
|
||||
ToolSpec {
|
||||
name: self.name().to_string(),
|
||||
description: self.description().to_string(),
|
||||
parameters: self.parameters_schema(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue