App-templates: LLM auto-selects project template based on user requirement
- Add webapp template (FastAPI + SQLite) with INSTRUCTIONS.md and setup.sh - select_template() scans templates, asks LLM to match; apply_template() copies to workspace - ensure_workspace() runs setup.sh if present, otherwise falls back to default venv - INSTRUCTIONS.md injected into planning and execution prompts - Fix pre-existing clippy warning in kb.rs (filter_map → map) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
179
src/agent.rs
179
src/agent.rs
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -148,11 +149,133 @@ impl AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_venv(exec: &LocalExecutor, workdir: &str) {
|
||||
// --- Template system ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TemplateInfo {
|
||||
name: String,
|
||||
description: String,
|
||||
match_hint: String,
|
||||
}
|
||||
|
||||
fn templates_dir() -> &'static str {
|
||||
if Path::new("/app/templates").is_dir() {
|
||||
"/app/templates"
|
||||
} else {
|
||||
"app-templates"
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan available templates and ask LLM to pick one (or none).
|
||||
async fn select_template(llm: &LlmClient, requirement: &str) -> Option<String> {
|
||||
let base = Path::new(templates_dir());
|
||||
let mut entries = match tokio::fs::read_dir(base).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let mut templates: Vec<(String, TemplateInfo)> = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if !entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let id = entry.file_name().to_string_lossy().to_string();
|
||||
let meta_path = entry.path().join("template.json");
|
||||
if let Ok(data) = tokio::fs::read_to_string(&meta_path).await {
|
||||
if let Ok(info) = serde_json::from_str::<TemplateInfo>(&data) {
|
||||
templates.push((id, info));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if templates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let listing: String = templates
|
||||
.iter()
|
||||
.map(|(id, info)| format!("- id: {}\n 名称: {}\n 描述: {}\n 适用场景: {}", id, info.name, info.description, info.match_hint))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let prompt = format!(
|
||||
"以下是可用的项目模板:\n{}\n\n用户需求:{}\n\n选择最匹配的模板 ID,如果都不合适则回复 none。只回复模板 ID 或 none,不要其他内容。",
|
||||
listing, requirement
|
||||
);
|
||||
|
||||
let response = llm
|
||||
.chat(vec![
|
||||
ChatMessage::system("你是一个模板选择助手。根据用户需求选择最合适的项目模板。只回复模板 ID 或 none。"),
|
||||
ChatMessage::user(&prompt),
|
||||
])
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let answer = response.trim().to_lowercase();
|
||||
if answer == "none" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Verify the answer matches an actual template ID
|
||||
templates.iter().find(|(id, _)| id == &answer).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Copy template contents to workdir (excluding template.json).
|
||||
async fn apply_template(template_id: &str, workdir: &str) -> anyhow::Result<()> {
|
||||
let src = Path::new(templates_dir()).join(template_id);
|
||||
if !src.is_dir() {
|
||||
anyhow::bail!("Template directory not found: {}", template_id);
|
||||
}
|
||||
copy_dir_recursive(&src, Path::new(workdir)).await
|
||||
}
|
||||
|
||||
/// Recursively copy directory contents, skipping template.json at the top level.
|
||||
async fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
|
||||
// Use a stack to avoid async recursion
|
||||
let mut stack: Vec<(std::path::PathBuf, std::path::PathBuf, bool)> =
|
||||
vec![(src.to_path_buf(), dst.to_path_buf(), true)];
|
||||
|
||||
while let Some((src_dir, dst_dir, top_level)) = stack.pop() {
|
||||
tokio::fs::create_dir_all(&dst_dir).await?;
|
||||
let mut entries = tokio::fs::read_dir(&src_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
|
||||
if top_level && name_str == "template.json" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst_dir.join(&name);
|
||||
|
||||
if entry.file_type().await?.is_dir() {
|
||||
stack.push((src_path, dst_path, false));
|
||||
} else {
|
||||
tokio::fs::copy(&src_path, &dst_path).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read INSTRUCTIONS.md from workdir if it exists.
|
||||
async fn read_instructions(workdir: &str) -> String {
|
||||
let path = format!("{}/INSTRUCTIONS.md", workdir);
|
||||
tokio::fs::read_to_string(&path).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn ensure_workspace(exec: &LocalExecutor, workdir: &str) {
|
||||
let _ = tokio::fs::create_dir_all(workdir).await;
|
||||
let venv_path = format!("{}/.venv", workdir);
|
||||
if !std::path::Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", workdir).await;
|
||||
let setup_script = format!("{}/scripts/setup.sh", workdir);
|
||||
if Path::new(&setup_script).exists() {
|
||||
tracing::info!("Running setup.sh in {}", workdir);
|
||||
let _ = exec.execute("bash scripts/setup.sh", workdir).await;
|
||||
} else {
|
||||
let venv_path = format!("{}/.venv", workdir);
|
||||
if !Path::new(&venv_path).exists() {
|
||||
let _ = exec.execute("uv venv .venv", workdir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,15 +328,26 @@ async fn agent_loop(
|
||||
.execute(&pool)
|
||||
.await;
|
||||
|
||||
// Ensure workspace and venv exist
|
||||
ensure_venv(&exec, &workdir).await;
|
||||
// Template selection + workspace setup
|
||||
let template_id = select_template(&llm, &requirement).await;
|
||||
if let Some(ref tid) = template_id {
|
||||
tracing::info!("Template selected for workflow {}: {}", workflow_id, tid);
|
||||
let _ = tokio::fs::create_dir_all(&workdir).await;
|
||||
if let Err(e) = apply_template(tid, &workdir).await {
|
||||
tracing::error!("Failed to apply template {}: {}", tid, e);
|
||||
}
|
||||
}
|
||||
ensure_workspace(&exec, &workdir).await;
|
||||
let _ = tokio::fs::write(format!("{}/requirement.md", workdir), &requirement).await;
|
||||
|
||||
let instructions = read_instructions(&workdir).await;
|
||||
|
||||
tracing::info!("Starting agent loop for workflow {}", workflow_id);
|
||||
// Run tool-calling agent loop
|
||||
let result = run_agent_loop(
|
||||
&llm, &exec, &pool, &broadcast_tx,
|
||||
&project_id, &workflow_id, &requirement, &workdir, &mgr,
|
||||
&instructions,
|
||||
).await;
|
||||
|
||||
let final_status = if result.is_ok() { "done" } else { "failed" };
|
||||
@@ -269,8 +403,8 @@ async fn agent_loop(
|
||||
|
||||
let Some(wf) = wf else { continue };
|
||||
|
||||
// Ensure venv exists for comment re-runs too
|
||||
ensure_venv(&exec, &workdir).await;
|
||||
// Ensure workspace exists for comment re-runs too
|
||||
ensure_workspace(&exec, &workdir).await;
|
||||
|
||||
// Clear old plan steps (keep log entries for history)
|
||||
let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'")
|
||||
@@ -297,9 +431,12 @@ async fn agent_loop(
|
||||
wf.requirement, content
|
||||
);
|
||||
|
||||
let instructions = read_instructions(&workdir).await;
|
||||
|
||||
let result = run_agent_loop(
|
||||
&llm, &exec, &pool, &broadcast_tx,
|
||||
&project_id, &workflow_id, &combined, &workdir, &mgr,
|
||||
&instructions,
|
||||
).await;
|
||||
|
||||
let final_status = if result.is_ok() { "done" } else { "failed" };
|
||||
@@ -477,8 +614,8 @@ fn build_execution_tools() -> Vec<Tool> {
|
||||
]
|
||||
}
|
||||
|
||||
fn build_planning_prompt(project_id: &str) -> String {
|
||||
format!(
|
||||
fn build_planning_prompt(project_id: &str, instructions: &str) -> String {
|
||||
let mut prompt = format!(
|
||||
"你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。\n\
|
||||
\n\
|
||||
你的任务:\n\
|
||||
@@ -508,11 +645,15 @@ fn build_planning_prompt(project_id: &str) -> String {
|
||||
\n\
|
||||
请使用中文回复。",
|
||||
project_id,
|
||||
)
|
||||
);
|
||||
if !instructions.is_empty() {
|
||||
prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions));
|
||||
}
|
||||
prompt
|
||||
}
|
||||
|
||||
fn build_execution_prompt(project_id: &str) -> String {
|
||||
format!(
|
||||
fn build_execution_prompt(project_id: &str, instructions: &str) -> String {
|
||||
let mut prompt = format!(
|
||||
"你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。\n\
|
||||
\n\
|
||||
可用工具:\n\
|
||||
@@ -539,7 +680,11 @@ fn build_execution_prompt(project_id: &str) -> String {
|
||||
\n\
|
||||
请使用中文回复。",
|
||||
project_id,
|
||||
)
|
||||
);
|
||||
if !instructions.is_empty() {
|
||||
prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions));
|
||||
}
|
||||
prompt
|
||||
}
|
||||
|
||||
fn build_step_context(state: &AgentState, requirement: &str) -> String {
|
||||
@@ -697,6 +842,7 @@ async fn execute_tool(
|
||||
|
||||
// --- Tool-calling agent loop (state machine) ---
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_agent_loop(
|
||||
llm: &LlmClient,
|
||||
exec: &LocalExecutor,
|
||||
@@ -707,6 +853,7 @@ async fn run_agent_loop(
|
||||
requirement: &str,
|
||||
workdir: &str,
|
||||
mgr: &Arc<AgentManager>,
|
||||
instructions: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let planning_tools = build_planning_tools();
|
||||
let execution_tools = build_execution_tools();
|
||||
@@ -731,7 +878,7 @@ async fn run_agent_loop(
|
||||
let (messages, tools) = match &state.phase {
|
||||
AgentPhase::Planning => {
|
||||
let mut msgs = vec![
|
||||
ChatMessage::system(&build_planning_prompt(project_id)),
|
||||
ChatMessage::system(&build_planning_prompt(project_id, instructions)),
|
||||
ChatMessage::user(requirement),
|
||||
];
|
||||
msgs.extend(state.step_messages.clone());
|
||||
@@ -740,7 +887,7 @@ async fn run_agent_loop(
|
||||
AgentPhase::Executing { .. } => {
|
||||
let step_ctx = build_step_context(&state, requirement);
|
||||
let mut msgs = vec![
|
||||
ChatMessage::system(&build_execution_prompt(project_id)),
|
||||
ChatMessage::system(&build_execution_prompt(project_id, instructions)),
|
||||
ChatMessage::user(&step_ctx),
|
||||
];
|
||||
msgs.extend(state.step_messages.clone());
|
||||
|
||||
Reference in New Issue
Block a user