feat: auto-install Python deps from template pyproject.toml via uv

ExternalToolManager.discover() now accepts template root dir, detects
pyproject.toml and runs `uv sync` to create a venv. Tool invocation and
schema discovery inject the venv PATH/VIRTUAL_ENV so template tools can
import declared dependencies without manual installation.
This commit is contained in:
Fam Zheng
2026-03-09 15:22:35 +00:00
parent fa800b1601
commit c70fbc49f0
7 changed files with 197 additions and 32 deletions

View File

@@ -11,18 +11,73 @@ struct ExternalTool {
pub struct ExternalToolManager {
tools: HashMap<String, ExternalTool>,
venv_bin: Option<PathBuf>,
}
impl ExternalToolManager {
/// Scan a tools/ directory, calling `--print-schema` on each executable to discover tools.
pub async fn discover(tools_dir: &Path) -> Self {
/// Build the PATH string with venv bin prepended (if present).
fn env_path(&self) -> Option<String> {
self.venv_bin.as_ref().map(|venv_bin| {
format!(
"{}:{}",
venv_bin.display(),
std::env::var("PATH").unwrap_or_default()
)
})
}
/// Scan a template directory for external tools and Python dependencies.
///
/// - Discovers executables in `template_dir/tools/`
/// - If `template_dir/pyproject.toml` exists, runs `uv sync` to create a venv
pub async fn discover(template_dir: &Path) -> Self {
let mut tools = HashMap::new();
let mut entries = match tokio::fs::read_dir(tools_dir).await {
Ok(e) => e,
Err(_) => return Self { tools },
// --- Python venv setup ---
let venv_bin = if template_dir.join("pyproject.toml").is_file() {
tracing::info!("Found pyproject.toml in {}, running uv sync", template_dir.display());
let output = tokio::process::Command::new("uv")
.args(["sync", "--project", &template_dir.to_string_lossy(), "--quiet"])
.output()
.await;
match output {
Ok(o) if o.status.success() => {
let bin = template_dir.join(".venv/bin");
tracing::info!("uv sync succeeded, venv bin: {}", bin.display());
Some(bin)
}
Ok(o) => {
tracing::warn!(
"uv sync failed: {}",
String::from_utf8_lossy(&o.stderr)
);
None
}
Err(e) => {
tracing::warn!("Failed to run uv sync: {}", e);
None
}
}
} else {
None
};
// --- Tool discovery ---
let tools_dir = template_dir.join("tools");
let mut entries = match tokio::fs::read_dir(&tools_dir).await {
Ok(e) => e,
Err(_) => return Self { tools, venv_bin },
};
// Build PATH with venv for --print-schema calls
let env_path = venv_bin.as_ref().map(|bin| {
format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
)
});
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
@@ -41,12 +96,16 @@ impl ExternalToolManager {
continue;
}
// Call --print-schema
let output = match tokio::process::Command::new(&path)
.arg("--print-schema")
.output()
.await
{
// Call --print-schema (with venv PATH if available)
let mut cmd = tokio::process::Command::new(&path);
cmd.arg("--print-schema");
if let Some(ref p) = env_path {
cmd.env("PATH", p);
}
if let Some(ref bin) = venv_bin {
cmd.env("VIRTUAL_ENV", bin.parent().unwrap().display().to_string());
}
let output = match cmd.output().await {
Ok(o) => o,
Err(e) => {
tracing::warn!("Failed to run --print-schema on {}: {}", path.display(), e);
@@ -102,7 +161,7 @@ impl ExternalToolManager {
);
}
Self { tools }
Self { tools, venv_bin }
}
/// Return all discovered Tool definitions for LLM API calls.
@@ -122,11 +181,15 @@ impl ExternalToolManager {
.get(name)
.ok_or_else(|| anyhow::anyhow!("External tool not found: {}", name))?;
let output = tokio::process::Command::new(&tool.path)
.arg(args_json)
.current_dir(workdir)
.output()
.await?;
let mut cmd = tokio::process::Command::new(&tool.path);
cmd.arg(args_json).current_dir(workdir);
if let Some(ref p) = self.env_path() {
cmd.env("PATH", p);
}
if let Some(ref venv_bin) = self.venv_bin {
cmd.env("VIRTUAL_ENV", venv_bin.parent().unwrap().display().to_string());
}
let output = cmd.output().await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();