use agent_contracts::runtime::runtime_view::RuntimeView;
use agent_contracts::tool::{ToolExecutor, ToolSpecView};
use agent_types::tool::call_types::FinalToolCall;
use agent_types::tool::execution_types::{RawToolOutcome, ToolExecutionError, ToolExecutorOutput};
use async_trait::async_trait;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::{timeout, Duration};
use super::manifest::{LoadedDeclarativeTool, StdinMode, StdoutMode};
use super::spec::DeclarativeToolSpec;
pub struct DeclarativeToolExecutor {
spec: Arc<DeclarativeToolSpec>,
manifest_path: PathBuf,
tool_dir: PathBuf,
command: String,
args: Vec<String>,
timeout_ms: u64,
stdin_mode: StdinMode,
stdout_mode: StdoutMode,
env_names: Vec<String>,
}
impl DeclarativeToolExecutor {
pub fn from_loaded_tool(spec: Arc<DeclarativeToolSpec>, tool: &LoadedDeclarativeTool) -> Self {
Self {
spec,
manifest_path: tool.manifest_path.clone(),
tool_dir: tool.tool_dir.clone(),
command: tool.manifest.exec.command.clone(),
args: tool.manifest.exec.args.clone(),
timeout_ms: tool.manifest.timeout_ms,
stdin_mode: tool.manifest.exec.stdin,
stdout_mode: tool.manifest.exec.stdout,
env_names: tool.manifest.exec.env.clone(),
}
}
fn stdin_payload(&self, call: &FinalToolCall, runtime: &dyn RuntimeView) -> serde_json::Value {
let workspace_root = runtime.agent_context().workspace().root.clone();
let metadata = runtime.agent_context().metadata();
json!({
"args": call.input,
"context": {
"agent_id": metadata.agent_id,
"model": metadata.model,
"session_id": metadata.session_id,
"directory": workspace_root,
"worktree": workspace_root,
"tool_dir": self.tool_dir,
}
})
}
fn expand_tilde_path(value: &str) -> Option<PathBuf> {
if value == "~" {
return std::env::var_os("HOME").map(PathBuf::from);
}
value.strip_prefix("~/").and_then(|suffix| {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(suffix))
})
}
fn resolve_command_token(&self, value: &str) -> String {
if let Some(expanded) = Self::expand_tilde_path(value) {
return expanded.to_string_lossy().into_owned();
}
if value.starts_with("./") || value.starts_with("../") {
return self
.tool_dir
.join(Path::new(value))
.to_string_lossy()
.into_owned();
}
value.to_string()
}
fn resolve_arg_token(&self, value: &str) -> String {
if let Some(expanded) = Self::expand_tilde_path(value) {
return expanded.to_string_lossy().into_owned();
}
if value.starts_with("./") || value.starts_with("../") {
return self.tool_dir.join(value).to_string_lossy().into_owned();
}
value.to_string()
}
async fn invoke_process(
&self,
call: &FinalToolCall,
runtime: &dyn RuntimeView,
) -> Result<RawToolOutcome, ToolExecutionError> {
let workspace_root = runtime.agent_context().workspace().root.clone();
let command_token = self.resolve_command_token(&self.command);
let args = self
.args
.iter()
.map(|arg| self.resolve_arg_token(arg))
.collect::<Vec<_>>();
let mut command = Command::new(command_token);
command
.args(&args)
.kill_on_drop(true)
.current_dir(&workspace_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("XIAOO_WORKSPACE_ROOT", &workspace_root)
.env("XIAOO_TOOL_MANIFEST", &self.manifest_path)
.env("XIAOO_TOOL_DIR", &self.tool_dir);
if let Some(session_id) = runtime.agent_context().metadata().session_id.as_deref() {
command.env("XIAOO_SESSION_ID", session_id);
}
command.env(
"XIAOO_AGENT_ID",
&runtime.agent_context().metadata().agent_id,
);
for env_name in &self.env_names {
if let Ok(value) = std::env::var(env_name) {
command.env(env_name, value);
}
}
match self.stdin_mode {
StdinMode::Json => {
command.stdin(Stdio::piped());
}
StdinMode::None => {
command.stdin(Stdio::null());
}
}
let mut child = command
.spawn()
.map_err(|error| ToolExecutionError::ExecutionFailed {
message: format!(
"failed to spawn custom tool '{}': {error}",
self.spec.name().0
),
})?;
if self.stdin_mode == StdinMode::Json {
let payload =
serde_json::to_vec(&self.stdin_payload(call, runtime)).map_err(|error| {
ToolExecutionError::ExecutionFailed {
message: format!("failed to serialize custom tool input: {error}"),
}
})?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(&payload).await.map_err(|error| {
ToolExecutionError::ExecutionFailed {
message: format!("failed to write custom tool stdin: {error}"),
}
})?;
}
}
let output = timeout(
Duration::from_millis(self.timeout_ms),
child.wait_with_output(),
)
.await
.map_err(|_| ToolExecutionError::Timeout {
timeout_ms: self.timeout_ms,
})?
.map_err(|error| ToolExecutionError::ExecutionFailed {
message: format!(
"failed to wait for custom tool '{}': {error}",
self.spec.name().0
),
})?;
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !output.status.success() {
return Ok(RawToolOutcome::Error {
message: format!(
"custom tool '{}' exited with status {}{}",
self.spec.name().0,
output.status,
if stderr.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr.trim())
}
),
});
}
match self.stdout_mode {
StdoutMode::Text => Ok(RawToolOutcome::Success { output: stdout }),
StdoutMode::Json => {
let value: serde_json::Value = serde_json::from_str(&stdout).map_err(|error| {
ToolExecutionError::ExecutionFailed {
message: format!(
"custom tool '{}' returned invalid JSON: {error}",
self.spec.name().0
),
}
})?;
Ok(RawToolOutcome::Success {
output: value.to_string(),
})
}
}
}
}
#[async_trait]
impl ToolExecutor for DeclarativeToolExecutor {
fn spec(&self) -> &dyn ToolSpecView {
self.spec.as_ref()
}
async fn invoke(
&self,
call: &FinalToolCall,
runtime: &dyn RuntimeView,
) -> Result<ToolExecutorOutput, ToolExecutionError> {
Ok(ToolExecutorOutput::Completed {
raw_outcome: self.invoke_process(call, runtime).await?,
})
}
}
#[cfg(test)]
mod tests {
use super::super::manifest::{DeclarativeToolManifest, EffectSection, ExecSection};
use super::*;
fn test_executor(tool_dir: PathBuf) -> DeclarativeToolExecutor {
let loaded = LoadedDeclarativeTool {
manifest_path: tool_dir.join("test_tool.toml"),
tool_dir,
manifest: DeclarativeToolManifest {
name: "test_tool".to_string(),
description: "test tool".to_string(),
timeout_ms: 5000,
output: None,
effect: EffectSection::default(),
input_schema: toml::Value::Table(toml::map::Map::new()),
exec: ExecSection {
command: "sh".to_string(),
args: Vec::new(),
stdin: StdinMode::Json,
stdout: StdoutMode::Text,
env: Vec::new(),
},
},
input_schema_json: json!({}),
};
let spec = Arc::new(DeclarativeToolSpec::from_loaded_tool(&loaded));
DeclarativeToolExecutor::from_loaded_tool(spec, &loaded)
}
#[test]
fn expands_tilde_for_explicit_tool_paths() {
let Some(home) = std::env::var_os("HOME") else {
return;
};
let executor = test_executor(PathBuf::from("/workspace/.xiaoo/tools"));
let resolved = executor.resolve_arg_token("~/.xiaoo/tools/md_to_html.mjs");
assert_eq!(
resolved,
PathBuf::from(home)
.join(".xiaoo/tools/md_to_html.mjs")
.to_string_lossy()
.into_owned()
);
}
#[test]
fn resolves_dot_relative_tool_paths_from_manifest_dir() {
let executor = test_executor(PathBuf::from("/home/user/.xiaoo/tools"));
assert_eq!(
executor.resolve_arg_token("./md_to_html.mjs"),
PathBuf::from("/home/user/.xiaoo/tools")
.join("./md_to_html.mjs")
.to_string_lossy()
.into_owned()
);
assert_eq!(
executor.resolve_command_token("./runner.sh"),
PathBuf::from("/home/user/.xiaoo/tools")
.join("./runner.sh")
.to_string_lossy()
.into_owned()
);
}
#[test]
fn keeps_workspace_relative_args_unchanged() {
let executor = test_executor(PathBuf::from("/home/user/.xiaoo/tools"));
assert_eq!(
executor.resolve_arg_token(".xiaoo/tools/echo_payload.sh"),
".xiaoo/tools/echo_payload.sh"
);
}
}