use crate::providers::ToolDefinition;
use crate::tools::ToolEffect;
use async_trait::async_trait;
use serde_json::Value;
use std::path::PathBuf;
pub use crate::tools::ToolResult;
pub struct ToolExecCtx<'a> {
pub project_root: &'a std::path::Path,
pub read_cache: &'a crate::tools::FileReadCache,
pub fs: &'a dyn koda_sandbox::fs::FileSystem,
pub caps: &'a crate::output_caps::OutputCaps,
pub sink: Option<(&'a dyn crate::engine::EngineSink, &'a str)>,
pub caller_spawner: Option<u32>,
pub bg_registry: &'a crate::tools::bg_process::BgRegistry,
pub trust: &'a crate::trust::TrustMode,
pub sandbox_policy: &'a koda_sandbox::SandboxPolicy,
pub proxy_port: Option<u16>,
pub socks5_port: Option<u16>,
pub session: Option<(&'a crate::db::Database, &'a str)>,
pub skill_registry: &'a crate::skills::SkillRegistry,
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &'static str;
fn definition(&self) -> ToolDefinition;
fn classify(&self, _args: &Value) -> ToolEffect {
ToolEffect::LocalMutation
}
fn extract_undo_path(&self, _args: &Value) -> Option<PathBuf> {
None
}
async fn execute(&self, ctx: &ToolExecCtx<'_>, args: &Value) -> ToolResult;
}
pub type DynTool = Box<dyn Tool>;
pub fn boxed<T: Tool + 'static>(tool: T) -> DynTool {
Box::new(tool)
}
impl<'a> ToolExecCtx<'a> {
#[inline]
pub fn project_root(&self) -> &std::path::Path {
self.project_root
}
#[inline]
pub fn read_cache(&self) -> &crate::tools::FileReadCache {
self.read_cache
}
#[inline]
pub fn fs(&self) -> &dyn koda_sandbox::fs::FileSystem {
self.fs
}
#[inline]
pub fn caps(&self) -> &crate::output_caps::OutputCaps {
self.caps
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)] pub(crate) fn for_test(
project_root: &'a std::path::Path,
read_cache: &'a crate::tools::FileReadCache,
fs: &'a dyn koda_sandbox::fs::FileSystem,
caps: &'a crate::output_caps::OutputCaps,
bg_registry: &'a crate::tools::bg_process::BgRegistry,
trust: &'a crate::trust::TrustMode,
sandbox_policy: &'a koda_sandbox::SandboxPolicy,
skill_registry: &'a crate::skills::SkillRegistry,
) -> Self {
Self {
project_root,
read_cache,
fs,
caps,
sink: None,
caller_spawner: None,
bg_registry,
trust,
sandbox_policy,
proxy_port: None,
socks5_port: None,
session: None,
skill_registry,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::SkillRegistry;
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
struct EchoReadTool;
#[async_trait]
impl Tool for EchoReadTool {
fn name(&self) -> &'static str {
"EchoRead"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "EchoRead".to_string(),
description: "Echo args back; for trait tests only.".to_string(),
parameters: json!({"type": "object"}),
}
}
fn classify(&self, _args: &Value) -> ToolEffect {
ToolEffect::ReadOnly
}
async fn execute(&self, _ctx: &ToolExecCtx<'_>, args: &Value) -> ToolResult {
ToolResult {
output: args.to_string(),
success: true,
full_output: None,
}
}
}
struct MutatingMockTool;
#[async_trait]
impl Tool for MutatingMockTool {
fn name(&self) -> &'static str {
"MutatingMock"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "MutatingMock".to_string(),
description: "Mock mutating tool for trait tests.".to_string(),
parameters: json!({"type": "object"}),
}
}
fn classify(&self, _args: &Value) -> ToolEffect {
ToolEffect::LocalMutation
}
fn extract_undo_path(&self, args: &Value) -> Option<PathBuf> {
args.get("path").and_then(|v| v.as_str()).map(PathBuf::from)
}
async fn execute(&self, _ctx: &ToolExecCtx<'_>, _args: &Value) -> ToolResult {
ToolResult {
output: "mutated".to_string(),
success: true,
full_output: None,
}
}
}
struct DefaultClassifyTool;
#[async_trait]
impl Tool for DefaultClassifyTool {
fn name(&self) -> &'static str {
"DefaultClassify"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "DefaultClassify".to_string(),
description: "Tests the defensive default.".to_string(),
parameters: json!({"type": "object"}),
}
}
async fn execute(&self, _ctx: &ToolExecCtx<'_>, _args: &Value) -> ToolResult {
ToolResult {
output: String::new(),
success: true,
full_output: None,
}
}
}
#[allow(clippy::too_many_arguments)] fn make_ctx<'a>(
root: &'a std::path::Path,
cache: &'a crate::tools::FileReadCache,
fs: &'a dyn koda_sandbox::fs::FileSystem,
caps: &'a crate::output_caps::OutputCaps,
bg: &'a crate::tools::bg_process::BgRegistry,
trust: &'a crate::trust::TrustMode,
policy: &'a koda_sandbox::SandboxPolicy,
skills: &'a SkillRegistry,
) -> ToolExecCtx<'a> {
ToolExecCtx {
project_root: root,
read_cache: cache,
fs,
caps,
sink: None,
caller_spawner: None,
bg_registry: bg,
trust,
sandbox_policy: policy,
proxy_port: None,
socks5_port: None,
session: None,
skill_registry: skills,
}
}
#[tokio::test]
async fn execute_returns_result_through_trait() {
let tool = EchoReadTool;
let cache: crate::tools::FileReadCache = Arc::new(Mutex::new(HashMap::new()));
let fs = koda_sandbox::fs::LocalFileSystem::new();
let root = std::path::PathBuf::from("/tmp");
let caps = crate::output_caps::OutputCaps::for_context(100_000);
let bg = crate::tools::bg_process::BgRegistry::new();
let trust = crate::trust::TrustMode::Safe;
let policy = koda_sandbox::SandboxPolicy::default();
let skills = crate::skills::SkillRegistry::default();
let ctx = make_ctx(&root, &cache, &fs, &caps, &bg, &trust, &policy, &skills);
let args = json!({"hello": "world"});
let result = tool.execute(&ctx, &args).await;
assert!(result.success);
assert_eq!(result.output, args.to_string());
}
#[test]
fn name_matches_definition_name() {
let t = EchoReadTool;
assert_eq!(t.name(), t.definition().name);
}
#[test]
fn classify_override_is_observed() {
assert_eq!(EchoReadTool.classify(&json!({})), ToolEffect::ReadOnly);
assert_eq!(
MutatingMockTool.classify(&json!({})),
ToolEffect::LocalMutation
);
}
#[test]
fn classify_default_is_local_mutation() {
assert_eq!(
DefaultClassifyTool.classify(&json!({})),
ToolEffect::LocalMutation
);
}
#[test]
fn extract_undo_path_default_is_none() {
assert!(EchoReadTool.extract_undo_path(&json!({})).is_none());
}
#[test]
fn extract_undo_path_override_picks_up_args() {
let t = MutatingMockTool;
let p = t.extract_undo_path(&json!({"path": "src/main.rs"}));
assert_eq!(p, Some(PathBuf::from("src/main.rs")));
assert!(t.extract_undo_path(&json!({})).is_none());
assert!(t.extract_undo_path(&json!({"path": 42})).is_none());
}
#[test]
fn boxed_helper_produces_dyn_tool() {
let tools: Vec<DynTool> = vec![
boxed(EchoReadTool),
boxed(MutatingMockTool),
boxed(DefaultClassifyTool),
];
assert_eq!(tools.len(), 3);
assert_eq!(tools[0].name(), "EchoRead");
assert_eq!(tools[1].name(), "MutatingMock");
assert_eq!(tools[2].name(), "DefaultClassify");
}
#[tokio::test]
async fn dyn_dispatch_executes_correct_tool() {
let tools: Vec<DynTool> = vec![boxed(EchoReadTool), boxed(MutatingMockTool)];
let cache: crate::tools::FileReadCache = Arc::new(Mutex::new(HashMap::new()));
let fs = koda_sandbox::fs::LocalFileSystem::new();
let root = std::path::PathBuf::from("/tmp");
let caps = crate::output_caps::OutputCaps::for_context(100_000);
let bg = crate::tools::bg_process::BgRegistry::new();
let trust = crate::trust::TrustMode::Safe;
let policy = koda_sandbox::SandboxPolicy::default();
let skills = crate::skills::SkillRegistry::default();
let ctx = make_ctx(&root, &cache, &fs, &caps, &bg, &trust, &policy, &skills);
let r1 = tools[0].execute(&ctx, &json!({})).await;
assert!(r1.success);
assert!(r1.output.contains("{}"));
let r2 = tools[1].execute(&ctx, &json!({})).await;
assert!(r2.success);
assert_eq!(r2.output, "mutated");
}
#[test]
fn ctx_accessors_match_field_values() {
let cache: crate::tools::FileReadCache = Arc::new(Mutex::new(HashMap::new()));
let fs = koda_sandbox::fs::LocalFileSystem::new();
let root = std::path::PathBuf::from("/tmp/whatever");
let caps = crate::output_caps::OutputCaps::for_context(100_000);
let bg = crate::tools::bg_process::BgRegistry::new();
let trust = crate::trust::TrustMode::Safe;
let policy = koda_sandbox::SandboxPolicy::default();
let skills = crate::skills::SkillRegistry::default();
let ctx = make_ctx(&root, &cache, &fs, &caps, &bg, &trust, &policy, &skills);
assert_eq!(ctx.project_root(), root.as_path());
assert!(Arc::ptr_eq(ctx.read_cache(), &cache));
}
}