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

@@ -698,13 +698,13 @@ fn build_step_tools() -> Vec<Tool> {
},
"required": ["reason"]
})),
make_tool("step_done", "完成当前步骤。必须提供摘要。可选声明本步骤的产出物", serde_json::json!({
make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)", serde_json::json!({
"type": "object",
"properties": {
"summary": { "type": "string", "description": "本步骤的工作摘要" },
"artifacts": {
"type": "array",
"description": "本步骤的产出物列表",
"description": "本步骤的产出物列表。无产出物时传空数组 []",
"items": {
"type": "object",
"properties": {
@@ -717,7 +717,7 @@ fn build_step_tools() -> Vec<Tool> {
}
}
},
"required": ["summary"]
"required": ["summary", "artifacts"]
})),
tool_kb_search(),
tool_kb_read(),

View File

@@ -94,7 +94,7 @@ impl LlmClient {
pub fn new(config: &LlmConfig) -> Self {
Self {
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.timeout(std::time::Duration::from_secs(300))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to build HTTP client"),

View File

@@ -550,8 +550,7 @@ impl LoadedTemplate {
.await
.unwrap_or_default();
let tools_dir = base.join("tools");
let external_tools = ExternalToolManager::discover(&tools_dir).await;
let external_tools = ExternalToolManager::discover(base).await;
tracing::info!("Template '{}': {} external tools", template_id, external_tools.len());
let kb_dir = base.join("kb");

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();