use agent_types::tool::spec_types::EffectProfile;
use serde::Deserialize;
use serde_json::Value;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct DeclarativeToolManifest {
pub name: String,
pub description: String,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub output: Option<OutputSection>,
#[serde(default)]
pub effect: EffectSection,
pub input_schema: toml::Value,
pub exec: ExecSection,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OutputSection {
#[serde(default = "default_output_description")]
pub description: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EffectSection {
#[serde(default)]
pub reads_filesystem: bool,
#[serde(default)]
pub writes_filesystem: bool,
#[serde(default)]
pub network_access: bool,
#[serde(default)]
pub side_effects: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExecSection {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default = "default_stdin")]
pub stdin: StdinMode,
#[serde(default = "default_stdout")]
pub stdout: StdoutMode,
#[serde(default)]
pub env: Vec<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StdinMode {
Json,
None,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StdoutMode {
Text,
Json,
}
#[derive(Debug, Clone)]
pub struct LoadedDeclarativeTool {
pub manifest_path: PathBuf,
pub tool_dir: PathBuf,
pub manifest: DeclarativeToolManifest,
pub input_schema_json: Value,
}
impl LoadedDeclarativeTool {
pub fn load(path: &Path) -> Result<Self, String> {
let content = std::fs::read_to_string(path)
.map_err(|error| format!("failed to read {}: {error}", path.display()))?;
let manifest: DeclarativeToolManifest = toml::from_str(&content)
.map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
manifest.validate(path)?;
let input_schema_json = toml_to_json(manifest.input_schema.clone());
let tool_dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
Ok(Self {
manifest_path: path.to_path_buf(),
tool_dir,
manifest,
input_schema_json,
})
}
}
impl DeclarativeToolManifest {
fn validate(&self, path: &Path) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err(format!("{} has an empty tool name", path.display()));
}
if !is_valid_tool_name(&self.name) {
return Err(format!(
"{} has invalid tool name '{}'; use letters, numbers, '_' or '-'",
path.display(),
self.name
));
}
if self.description.trim().is_empty() {
return Err(format!("{} has an empty description", path.display()));
}
if self.exec.command.trim().is_empty() {
return Err(format!("{} has an empty exec.command", path.display()));
}
if self.timeout_ms == 0 {
return Err(format!("{} has timeout_ms=0", path.display()));
}
for env_name in &self.exec.env {
if env_name.trim().is_empty() || env_name.contains('=') {
return Err(format!(
"{} has invalid exec.env entry '{}'; use environment variable names only",
path.display(),
env_name
));
}
}
Ok(())
}
}
impl From<&EffectSection> for EffectProfile {
fn from(effect: &EffectSection) -> Self {
Self {
reads_filesystem: effect.reads_filesystem,
writes_filesystem: effect.writes_filesystem,
network_access: effect.network_access,
side_effects: effect.side_effects,
}
}
}
fn toml_to_json(value: toml::Value) -> Value {
match value {
toml::Value::String(value) => Value::String(value),
toml::Value::Integer(value) => Value::Number(value.into()),
toml::Value::Float(value) => serde_json::Number::from_f64(value)
.map(Value::Number)
.unwrap_or(Value::Null),
toml::Value::Boolean(value) => Value::Bool(value),
toml::Value::Datetime(value) => Value::String(value.to_string()),
toml::Value::Array(values) => Value::Array(values.into_iter().map(toml_to_json).collect()),
toml::Value::Table(table) => Value::Object(
table
.into_iter()
.map(|(key, value)| (key, toml_to_json(value)))
.collect(),
),
}
}
fn is_valid_tool_name(name: &str) -> bool {
name.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
}
fn default_timeout_ms() -> u64 {
30_000
}
fn default_output_description() -> String {
"Tool output".to_string()
}
fn default_stdin() -> StdinMode {
StdinMode::Json
}
fn default_stdout() -> StdoutMode {
StdoutMode::Text
}