use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; use sqlx::sqlite::SqlitePool; use tokio::sync::{mpsc, RwLock, broadcast}; use crate::llm::{LlmClient, ChatMessage, Tool, ToolFunction}; use crate::exec::LocalExecutor; use crate::tools::ExternalToolManager; use crate::worker::WorkerManager; use crate::sink::{AgentUpdate, ServiceManager}; use crate::state::{AgentState, AgentPhase, Artifact, Step, StepStatus, StepResult, StepResultStatus, check_scratchpad_size}; pub struct ServiceInfo { pub port: u16, pub pid: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgentEvent { NewRequirement { workflow_id: String, requirement: String, template_id: Option }, Comment { workflow_id: String, content: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsMessage { PlanUpdate { workflow_id: String, steps: Vec }, StepStatusUpdate { step_id: String, status: String, output: String }, WorkflowStatusUpdate { workflow_id: String, status: String }, RequirementUpdate { workflow_id: String, requirement: String }, ReportReady { workflow_id: String }, ProjectUpdate { project_id: String, name: String }, LlmCallLog { workflow_id: String, entry: crate::db::LlmCallLogEntry }, ActivityUpdate { workflow_id: String, activity: String }, Error { message: String }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanStepInfo { pub order: i32, pub description: String, pub command: String, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub artifacts: Vec, } pub fn plan_infos_from_state(state: &AgentState) -> Vec { state.steps.iter().map(|s| { let status = match s.status { StepStatus::Pending => "pending", StepStatus::Running => "running", StepStatus::WaitingUser => "waiting_user", StepStatus::Done => "done", StepStatus::Failed => "failed", }; PlanStepInfo { order: s.order, description: s.title.clone(), command: s.description.clone(), status: Some(status.to_string()), artifacts: s.artifacts.clone(), } }).collect() } pub struct AgentManager { broadcast: RwLock>>, pool: SqlitePool, pub worker_mgr: Arc, } impl AgentManager { pub fn new( pool: SqlitePool, worker_mgr: Arc, ) -> Arc { Arc::new(Self { broadcast: RwLock::new(HashMap::new()), pool, worker_mgr, }) } pub async fn get_broadcast(&self, project_id: &str) -> broadcast::Receiver { let mut map = self.broadcast.write().await; let tx = map.entry(project_id.to_string()) .or_insert_with(|| broadcast::channel(64).0); tx.subscribe() } pub fn get_broadcast_sender(&self, project_id: &str) -> broadcast::Sender { // This is called synchronously from ws_worker; we use a blocking approach // since RwLock is tokio-based, we need a sync wrapper // Actually, let's use try_write or just create a new one // For simplicity, return a new sender each time (they share the channel) // This is safe because broadcast::Sender is Clone tokio::task::block_in_place(|| { let rt = tokio::runtime::Handle::current(); rt.block_on(async { let mut map = self.broadcast.write().await; map.entry(project_id.to_string()) .or_insert_with(|| broadcast::channel(64).0) .clone() }) }) } /// Dispatch an event to a worker. pub async fn send_event(self: &Arc, project_id: &str, event: AgentEvent) { match event { AgentEvent::NewRequirement { workflow_id, requirement, template_id } => { // Generate title (heuristic) let title = generate_title_heuristic(&requirement); let _ = sqlx::query("UPDATE projects SET name = ? WHERE id = ?") .bind(&title).bind(project_id).execute(&self.pool).await; let btx = { let mut map = self.broadcast.write().await; map.entry(project_id.to_string()) .or_insert_with(|| broadcast::channel(64).0) .clone() }; let _ = btx.send(WsMessage::ProjectUpdate { project_id: project_id.to_string(), name: title, }); // Update workflow status let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") .bind(&workflow_id).execute(&self.pool).await; let _ = btx.send(WsMessage::WorkflowStatusUpdate { workflow_id: workflow_id.clone(), status: "executing".into(), }); // Persist template_id if let Some(ref tid) = template_id { let _ = sqlx::query("UPDATE workflows SET template_id = ? WHERE id = ?") .bind(tid).bind(&workflow_id).execute(&self.pool).await; } // Dispatch to worker let assign = crate::worker::ServerToWorker::WorkflowAssign { workflow_id: workflow_id.clone(), project_id: project_id.to_string(), requirement, template_id, initial_state: None, require_plan_approval: false, }; // Retry dispatch up to 3 times (worker might be reconnecting) let mut dispatch_result = self.worker_mgr.assign_workflow(assign.clone()).await; for attempt in 1..3 { if dispatch_result.is_ok() { break; } tracing::warn!("Dispatch attempt {} failed, retrying in 5s...", attempt); tokio::time::sleep(std::time::Duration::from_secs(5)).await; dispatch_result = self.worker_mgr.assign_workflow(assign.clone()).await; } match dispatch_result { Ok(name) => { tracing::info!("Workflow {} dispatched to worker '{}'", workflow_id, name); } Err(e) => { let reason = format!("调度失败: {}", e); tracing::error!("Failed to dispatch workflow {}: {}", workflow_id, reason); let _ = sqlx::query("UPDATE workflows SET status = 'failed', status_reason = ? WHERE id = ?") .bind(&reason).bind(&workflow_id).execute(&self.pool).await; // Log to execution_log so frontend can show the reason let log_id = uuid::Uuid::new_v4().to_string(); let _ = sqlx::query( "INSERT INTO execution_log (id, workflow_id, step_order, tool_name, tool_input, output, status, created_at) VALUES (?, ?, 0, 'system', 'dispatch', ?, 'failed', datetime('now'))" ).bind(&log_id).bind(&workflow_id).bind(&reason).execute(&self.pool).await; let _ = btx.send(WsMessage::StepStatusUpdate { step_id: log_id, status: "failed".into(), output: reason, }); let _ = btx.send(WsMessage::WorkflowStatusUpdate { workflow_id, status: "failed".into(), }); } } } AgentEvent::Comment { workflow_id, content } => { // Try to forward to running worker first if let Err(_) = self.worker_mgr.forward_comment(&workflow_id, &content).await { // No worker handling this workflow — re-dispatch it // Load workflow to get project_id and requirement let wf = sqlx::query_as::<_, crate::db::Workflow>( "SELECT * FROM workflows WHERE id = ?" ).bind(&workflow_id).fetch_optional(&self.pool).await.ok().flatten(); if let Some(wf) = wf { tracing::info!("Re-dispatching workflow {} with comment", workflow_id); // Load latest state for resume let state_json: Option = sqlx::query_scalar( "SELECT state_json FROM agent_state_snapshots WHERE workflow_id = ? ORDER BY created_at DESC LIMIT 1" ).bind(&workflow_id).fetch_optional(&self.pool).await.ok().flatten(); let mut initial_state = state_json .and_then(|json| serde_json::from_str::(&json).ok()); // Attach comment as user feedback + reset failed/waiting steps if let Some(ref mut state) = initial_state { for step in &mut state.steps { if matches!(step.status, crate::state::StepStatus::Failed) { step.status = crate::state::StepStatus::Pending; } if matches!(step.status, crate::state::StepStatus::WaitingUser) { step.status = crate::state::StepStatus::Running; } } if let Some(order) = state.first_actionable_step() { if let Some(step) = state.steps.iter_mut().find(|s| s.order == order) { step.user_feedbacks.push(content); } } } let assign = crate::worker::ServerToWorker::WorkflowAssign { workflow_id: workflow_id.clone(), project_id: wf.project_id.clone(), requirement: wf.requirement, template_id: if wf.template_id.is_empty() { None } else { Some(wf.template_id) }, initial_state, require_plan_approval: false, }; let btx = { let mut map = self.broadcast.write().await; map.entry(wf.project_id.clone()) .or_insert_with(|| broadcast::channel(64).0) .clone() }; match self.worker_mgr.assign_workflow(assign).await { Ok(name) => { let _ = sqlx::query("UPDATE workflows SET status = 'executing', status_reason = '' WHERE id = ?") .bind(&workflow_id).execute(&self.pool).await; let _ = btx.send(WsMessage::WorkflowStatusUpdate { workflow_id, status: "executing".into(), }); tracing::info!("Workflow re-dispatched to worker '{}'", name); } Err(e) => { tracing::error!("Failed to re-dispatch workflow {}: {}", workflow_id, e); } } } } } } } } // Template system is in crate::template // --- Tool definitions --- fn make_tool(name: &str, description: &str, parameters: serde_json::Value) -> Tool { Tool { tool_type: "function".into(), function: ToolFunction { name: name.into(), description: description.into(), parameters, }, } } fn tool_read_file() -> Tool { make_tool("read_file", "读取工作区中的文件内容", serde_json::json!({ "type": "object", "properties": { "path": { "type": "string", "description": "工作区内的相对路径" } }, "required": ["path"] })) } fn tool_list_files() -> Tool { make_tool("list_files", "列出工作区目录中的文件和子目录", serde_json::json!({ "type": "object", "properties": { "path": { "type": "string", "description": "工作区内的相对路径,默认为根目录" } } })) } fn build_planning_tools() -> Vec { vec![ make_tool("update_plan", "设置高层执行计划。分析需求后调用此工具提交计划。每个步骤应是一个逻辑阶段(不是具体命令),包含简短标题和详细描述。调用后自动进入执行阶段。", serde_json::json!({ "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "步骤标题,简短概括(如'搭建环境')" }, "description": { "type": "string", "description": "详细描述,说明具体要做什么、为什么" } }, "required": ["title", "description"] }, "description": "高层计划步骤列表" } }, "required": ["steps"] })), tool_list_files(), tool_read_file(), ] } /// Coordinator tools — used by the main loop after step completion fn build_coordinator_tools() -> Vec { vec![ make_tool("update_plan", "修改执行计划。提供完整步骤列表,系统自动 diff:description 未变的已完成步骤保留成果,变化的步骤及后续重新执行。", serde_json::json!({ "type": "object", "properties": { "steps": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string", "description": "步骤标题" }, "description": { "type": "string", "description": "详细描述" } }, "required": ["title", "description"] } } }, "required": ["steps"] })), make_tool("update_scratchpad", "更新全局备忘录。跨步骤持久化的关键信息。新内容会追加到已有内容之后。", serde_json::json!({ "type": "object", "properties": { "content": { "type": "string", "description": "要追加的内容" } }, "required": ["content"] })), make_tool("update_requirement", "更新项目需求描述。当需要调整方向时使用。", serde_json::json!({ "type": "object", "properties": { "requirement": { "type": "string", "description": "更新后的需求描述" } }, "required": ["requirement"] })), ] } /// Step execution tools — used by run_step_loop fn build_step_tools() -> Vec { vec![ make_tool("execute", "在工作区目录中执行 shell 命令", serde_json::json!({ "type": "object", "properties": { "command": { "type": "string", "description": "要执行的 shell 命令" } }, "required": ["command"] })), tool_read_file(), make_tool("write_file", "在工作区中写入文件(自动创建父目录)", serde_json::json!({ "type": "object", "properties": { "path": { "type": "string", "description": "工作区内的相对路径" }, "content": { "type": "string", "description": "要写入的文件内容" } }, "required": ["path", "content"] })), tool_list_files(), make_tool("start_service", "启动后台服务进程(如 FastAPI 应用)。系统会自动分配端口并通过环境变量 PORT 传入。服务启动后可通过 /api/projects/{project_id}/app/ 访问。注意:启动命令应监听 0.0.0.0:$PORT。", serde_json::json!({ "type": "object", "properties": { "command": { "type": "string", "description": "启动命令,如 'uvicorn main:app --host 0.0.0.0 --port $PORT'" } }, "required": ["command"] })), make_tool("stop_service", "停止当前项目正在运行的后台服务进程。", serde_json::json!({ "type": "object", "properties": {} })), make_tool("update_scratchpad", "更新步骤级工作记忆。用于记录本步骤内的中间状态(步骤结束后丢弃,精华写进 summary)。不是日志,只保留当前有用的信息。", serde_json::json!({ "type": "object", "properties": { "content": { "type": "string", "description": "要追加的内容" } }, "required": ["content"] })), make_tool("ask_user", "向用户提问,暂停执行等待用户回复。用于需要用户输入、确认或决策的场景。", serde_json::json!({ "type": "object", "properties": { "question": { "type": "string", "description": "要向用户提出的问题或需要确认的内容" } }, "required": ["question"] })), make_tool("step_done", "完成当前步骤。必须提供摘要和产出物列表(无产出物时传空数组)。", serde_json::json!({ "type": "object", "properties": { "summary": { "type": "string", "description": "本步骤的工作摘要" }, "artifacts": { "type": "array", "description": "本步骤的产出物列表。无产出物时传空数组 []", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "产物名称" }, "path": { "type": "string", "description": "文件路径(相对工作目录)" }, "type": { "type": "string", "enum": ["file", "json", "markdown"], "description": "产物类型" }, "description": { "type": "string", "description": "一句话说明" } }, "required": ["name", "path", "type"] } } }, "required": ["summary", "artifacts"] })), ] } fn build_planning_prompt(project_id: &str, instructions: &str) -> String { let mut prompt = include_str!("prompts/planning.md") .replace("{project_id}", project_id); if !instructions.is_empty() { prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions)); } prompt } fn build_coordinator_prompt(project_id: &str, instructions: &str) -> String { let mut prompt = include_str!("prompts/execution.md") .replace("{project_id}", project_id); if !instructions.is_empty() { prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions)); } prompt } fn build_step_execution_prompt(project_id: &str, instructions: &str) -> String { let mut prompt = include_str!("prompts/step_execution.md") .replace("{project_id}", project_id); if !instructions.is_empty() { prompt.push_str(&format!("\n\n## 项目模板指令\n\n{}", instructions)); } prompt } /// Build user message for a step sub-loop fn build_step_user_message(step: &Step, completed_summaries: &[(i32, String, String, Vec)], parent_scratchpad: &str) -> String { let mut ctx = String::new(); ctx.push_str(&format!("## 当前步骤(步骤 {})\n", step.order)); ctx.push_str(&format!("标题:{}\n", step.title)); ctx.push_str(&format!("描述:{}\n", step.description)); if !step.user_feedbacks.is_empty() { ctx.push_str("\n用户反馈:\n"); for fb in &step.user_feedbacks { ctx.push_str(&format!("- {}\n", fb)); } } ctx.push('\n'); if !completed_summaries.is_empty() { ctx.push_str("## 已完成步骤摘要\n"); for (order, title, summary, artifacts) in completed_summaries { ctx.push_str(&format!("- 步骤 {} ({}): {}\n", order, title, summary)); if !artifacts.is_empty() { let arts: Vec = artifacts.iter() .map(|a| format!("{} ({})", a.name, a.artifact_type)) .collect(); ctx.push_str(&format!(" 产物: {}\n", arts.join(", "))); } } ctx.push('\n'); } if !parent_scratchpad.is_empty() { ctx.push_str("## 项目备忘录(只读)\n"); ctx.push_str(parent_scratchpad); ctx.push('\n'); } ctx } // --- Helpers --- /// Truncate a string at a char boundary, returning at most `max_bytes` bytes. fn truncate_str(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { return s; } let mut end = max_bytes; while end > 0 && !s.is_char_boundary(end) { end -= 1; } &s[..end] } // --- Tool execution --- async fn execute_tool( name: &str, arguments: &str, workdir: &str, exec: &LocalExecutor, ) -> String { let args: serde_json::Value = serde_json::from_str(arguments).unwrap_or_default(); match name { "execute" => { let cmd = args["command"].as_str().unwrap_or(""); match exec.execute(cmd, workdir).await { Ok(r) => { let mut out = r.stdout; if !r.stderr.is_empty() { out.push_str("\nSTDERR: "); out.push_str(&r.stderr); } if r.exit_code != 0 { out.push_str(&format!("\n[exit code: {}]", r.exit_code)); } if out.len() > 8000 { let truncated = truncate_str(&out, 8000).to_string(); out = truncated; out.push_str("\n...(truncated)"); } out } Err(e) => format!("Error: {}", e), } } "read_file" => { let path = args["path"].as_str().unwrap_or(""); if path.contains("..") { return "Error: path traversal not allowed".into(); } let full = std::path::PathBuf::from(workdir).join(path); match tokio::fs::read_to_string(&full).await { Ok(content) => { if content.len() > 8000 { format!("{}...(truncated, {} bytes total)", truncate_str(&content, 8000), content.len()) } else { content } } Err(e) => format!("Error: {}", e), } } "write_file" => { let path = args["path"].as_str().unwrap_or(""); let content = args["content"].as_str().unwrap_or(""); if path.contains("..") { return "Error: path traversal not allowed".into(); } let full = std::path::PathBuf::from(workdir).join(path); if let Some(parent) = full.parent() { let _ = tokio::fs::create_dir_all(parent).await; } match tokio::fs::write(&full, content).await { Ok(()) => format!("Written {} bytes to {}", content.len(), path), Err(e) => format!("Error: {}", e), } } "list_files" => { let path = args["path"].as_str().unwrap_or("."); if path.contains("..") { return "Error: path traversal not allowed".into(); } let full = std::path::PathBuf::from(workdir).join(path); match tokio::fs::read_dir(&full).await { Ok(mut entries) => { let mut items = Vec::new(); while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name().to_string_lossy().to_string(); let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false); items.push(if is_dir { format!("{}/", name) } else { name }); } items.sort(); if items.is_empty() { "(empty directory)".into() } else { items.join("\n") } } Err(e) => format!("Error: {}", e), } } _ => format!("Unknown tool: {}", name), } } // --- Tool-calling agent loop (state machine) --- /// Send a state snapshot via update channel. async fn send_snapshot(tx: &mpsc::Sender, workflow_id: &str, step_order: i32, state: &AgentState) { let _ = tx.send(AgentUpdate::StateSnapshot { workflow_id: workflow_id.to_string(), step_order, state: state.clone(), }).await; } /// Send an execution log entry via update channel. async fn send_execution( tx: &mpsc::Sender, workflow_id: &str, step_order: i32, tool_name: &str, tool_input: &str, output: &str, status: &str, ) { let _ = tx.send(AgentUpdate::ExecutionLog { workflow_id: workflow_id.to_string(), step_order, tool_name: tool_name.to_string(), tool_input: tool_input.to_string(), output: output.to_string(), status: status.to_string(), }).await; } /// Send an LLM call log entry via update channel. #[allow(clippy::too_many_arguments)] async fn send_llm_call( tx: &mpsc::Sender, workflow_id: &str, step_order: i32, phase: &str, messages_count: i32, tools_count: i32, tool_calls_json: &str, text_response: &str, prompt_tokens: Option, completion_tokens: Option, latency_ms: i64, ) { let _ = tx.send(AgentUpdate::LlmCallLog { workflow_id: workflow_id.to_string(), step_order, phase: phase.to_string(), messages_count, tools_count, tool_calls: tool_calls_json.to_string(), text_response: text_response.to_string(), prompt_tokens, completion_tokens, latency_ms, }).await; } /// Run an isolated sub-loop for a single step. Returns StepResult. #[allow(clippy::too_many_arguments)] pub async fn run_step_loop( llm: &LlmClient, exec: &LocalExecutor, update_tx: &mpsc::Sender, event_rx: &mut mpsc::Receiver, project_id: &str, workflow_id: &str, workdir: &str, svc_mgr: &ServiceManager, instructions: &str, step: &Step, completed_summaries: &[(i32, String, String, Vec)], parent_scratchpad: &str, external_tools: Option<&ExternalToolManager>, all_steps: &[Step], ) -> StepResult { let system_prompt = build_step_execution_prompt(project_id, instructions); let user_message = build_step_user_message(step, completed_summaries, parent_scratchpad); let mut step_tools = build_step_tools(); if let Some(ext) = external_tools { step_tools.extend(ext.tool_definitions()); } let mut step_chat_history: Vec = Vec::new(); let mut step_scratchpad = String::new(); let step_order = step.order; for iteration in 0..50 { // Build messages: system + user context + chat history let mut messages = vec![ ChatMessage::system(&system_prompt), ChatMessage::user(&user_message), ]; // If step scratchpad is non-empty, inject it if !step_scratchpad.is_empty() { let last_user = messages.len() - 1; if let Some(content) = &messages[last_user].content { let mut amended = content.clone(); amended.push_str(&format!("\n\n## 步骤工作记忆\n{}", step_scratchpad)); messages[last_user].content = Some(amended); } } messages.extend(step_chat_history.clone()); let msg_count = messages.len() as i32; let tool_count = step_tools.len() as i32; let phase_label = format!("step({})", step_order); tracing::info!("[workflow {}] Step {} LLM call #{} msgs={}", workflow_id, step_order, iteration + 1, messages.len()); let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} — 等待 LLM 响应...", step_order), }).await; let call_start = std::time::Instant::now(); let response = match llm.chat_with_tools(messages, &step_tools).await { Ok(r) => r, Err(e) => { tracing::error!("[workflow {}] Step {} LLM call failed: {}", workflow_id, step_order, e); return StepResult { status: StepResultStatus::Failed { error: format!("LLM call failed: {}", e) }, artifacts: Vec::new(), summary: format!("LLM 调用失败: {}", e), }; } }; let latency_ms = call_start.elapsed().as_millis() as i64; let (prompt_tokens, completion_tokens) = response.usage.as_ref() .map(|u| (Some(u.prompt_tokens), Some(u.completion_tokens))) .unwrap_or((None, None)); let choice = match response.choices.into_iter().next() { Some(c) => c, None => { return StepResult { status: StepResultStatus::Failed { error: "No response from LLM".into() }, summary: "LLM 无响应".into(), artifacts: Vec::new(), }; } }; step_chat_history.push(choice.message.clone()); let llm_text_response = choice.message.content.clone().unwrap_or_default(); if let Some(tool_calls) = &choice.message.tool_calls { tracing::info!("[workflow {}] Step {} tool calls: {}", workflow_id, step_order, tool_calls.iter().map(|tc| tc.function.name.as_str()).collect::>().join(", ")); let mut step_done_result: Option = None; for tc in tool_calls { if step_done_result.is_some() { step_chat_history.push(ChatMessage::tool_result(&tc.id, "(skipped: step completed)")); continue; } let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or_default(); match tc.function.name.as_str() { "step_done" => { let summary = args["summary"].as_str().unwrap_or("").to_string(); let artifacts: Vec = args.get("artifacts") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|a| { Some(Artifact { name: a["name"].as_str()?.to_string(), path: a["path"].as_str()?.to_string(), artifact_type: a["type"].as_str().unwrap_or("file").to_string(), description: a["description"].as_str().unwrap_or("").to_string(), }) }).collect()) .unwrap_or_default(); // Save artifacts for art in &artifacts { let _ = update_tx.send(AgentUpdate::ArtifactSave { workflow_id: workflow_id.to_string(), step_order, artifact: art.clone(), }).await; } send_execution(update_tx, workflow_id, step_order, "step_done", &summary, "步骤完成", "done").await; step_chat_history.push(ChatMessage::tool_result(&tc.id, "步骤已完成。")); step_done_result = Some(StepResult { status: StepResultStatus::Done, summary, artifacts, }); } "update_scratchpad" => { let content = args["content"].as_str().unwrap_or(""); let mut new_pad = step_scratchpad.clone(); if !new_pad.is_empty() { new_pad.push('\n'); } new_pad.push_str(content); match check_scratchpad_size(&new_pad) { Ok(()) => { step_scratchpad = new_pad; step_chat_history.push(ChatMessage::tool_result(&tc.id, "步骤工作记忆已更新。")); } Err(msg) => { step_chat_history.push(ChatMessage::tool_result(&tc.id, &msg)); } } } "ask_user" => { let reason = args["question"].as_str().unwrap_or("等待确认"); let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} — 等待用户确认: {}", step_order, reason), }).await; // Broadcast waiting status using local steps data let waiting_steps = plan_infos_with_override(all_steps, step_order, "waiting_user"); let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: waiting_steps, }).await; let _ = update_tx.send(AgentUpdate::WorkflowStatus { workflow_id: workflow_id.to_string(), status: "waiting_user".into(), reason: String::new(), }).await; send_execution(update_tx, workflow_id, step_order, "ask_user", reason, reason, "waiting").await; tracing::info!("[workflow {}] Step {} waiting for approval: {}", workflow_id, step_order, reason); // Block until Comment event let approval_content = loop { match event_rx.recv().await { Some(AgentEvent::Comment { content, .. }) => break content, Some(_) => continue, None => { return StepResult { status: StepResultStatus::Failed { error: "Event channel closed".into() }, summary: "事件通道关闭".into(), artifacts: Vec::new(), }; } } }; tracing::info!("[workflow {}] Step {} approval response: {}", workflow_id, step_order, approval_content); if approval_content.starts_with("rejected:") { let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim(); send_execution(update_tx, workflow_id, step_order, "ask_user", "rejected", reason, "failed").await; step_chat_history.push(ChatMessage::tool_result(&tc.id, &format!("用户拒绝: {}", reason))); step_done_result = Some(StepResult { status: StepResultStatus::Failed { error: format!("用户终止: {}", reason) }, summary: format!("用户终止了执行: {}", reason), artifacts: Vec::new(), }); continue; } // Approved let feedback = if approval_content.starts_with("approved:") { approval_content.strip_prefix("approved:").unwrap_or("").trim().to_string() } else { approval_content.clone() }; let _ = update_tx.send(AgentUpdate::WorkflowStatus { workflow_id: workflow_id.to_string(), status: "executing".into(), reason: String::new(), }).await; let tool_msg = if feedback.is_empty() { "用户已确认,继续执行。".to_string() } else { format!("用户已确认。反馈: {}", feedback) }; step_chat_history.push(ChatMessage::tool_result(&tc.id, &tool_msg)); } "start_service" => { let cmd = args["command"].as_str().unwrap_or(""); { let mut services = svc_mgr.services.write().await; if let Some(old) = services.remove(project_id) { let _ = nix::sys::signal::kill( nix::unistd::Pid::from_raw(old.pid as i32), nix::sys::signal::Signal::SIGTERM, ); } } let port = svc_mgr.allocate_port(); let cmd_with_port = cmd.replace("$PORT", &port.to_string()); let venv_bin = format!("{}/.venv/bin", workdir); let path_env = match std::env::var("PATH") { Ok(p) => format!("{}:{}", venv_bin, p), Err(_) => venv_bin, }; let result = match tokio::process::Command::new("sh") .arg("-c") .arg(&cmd_with_port) .current_dir(workdir) .env("PORT", port.to_string()) .env("PATH", &path_env) .env("VIRTUAL_ENV", format!("{}/.venv", workdir)) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() { Ok(child) => { let pid = child.id().unwrap_or(0); svc_mgr.services.write().await.insert(project_id.to_string(), ServiceInfo { port, pid }); tokio::time::sleep(std::time::Duration::from_secs(2)).await; format!("服务已启动,端口 {},访问地址:/api/projects/{}/app/", port, project_id) } Err(e) => format!("Error: 启动失败:{}", e), }; let status = if result.starts_with("Error:") { "failed" } else { "done" }; send_execution(update_tx, workflow_id, step_order, "start_service", cmd, &result, status).await; step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } "stop_service" => { let mut services = svc_mgr.services.write().await; let result = if let Some(svc) = services.remove(project_id) { let _ = nix::sys::signal::kill( nix::unistd::Pid::from_raw(svc.pid as i32), nix::sys::signal::Signal::SIGTERM, ); "服务已停止。".to_string() } else { "当前没有运行中的服务。".to_string() }; send_execution(update_tx, workflow_id, step_order, "stop_service", "", &result, "done").await; step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } // External tools name if external_tools.as_ref().is_some_and(|e| e.has_tool(name)) => { let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} — 工具: {}", step_order, name), }).await; let result = match external_tools.unwrap().invoke(name, &tc.function.arguments, workdir).await { Ok(output) => { let truncated = truncate_str(&output, 8192); truncated.to_string() } Err(e) => format!("Tool error: {}", e), }; let status = if result.starts_with("Tool error:") { "failed" } else { "done" }; send_execution(update_tx, workflow_id, step_order, &tc.function.name, &tc.function.arguments, &result, status).await; step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } // IO tools: execute, read_file, write_file, list_files _ => { let tool_desc = match tc.function.name.as_str() { "execute" => { let cmd_preview = args.get("command").and_then(|v| v.as_str()).unwrap_or("").chars().take(60).collect::(); format!("执行命令: {}", cmd_preview) } "read_file" => format!("读取文件: {}", args.get("path").and_then(|v| v.as_str()).unwrap_or("?")), "write_file" => format!("写入文件: {}", args.get("path").and_then(|v| v.as_str()).unwrap_or("?")), "list_files" => "列出文件".to_string(), other => format!("工具: {}", other), }; let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} — {}", step_order, tool_desc), }).await; let result = execute_tool(&tc.function.name, &tc.function.arguments, workdir, exec).await; let status = if result.starts_with("Error:") { "failed" } else { "done" }; send_execution(update_tx, workflow_id, step_order, &tc.function.name, &tc.function.arguments, &result, status).await; // Real-time file sync: upload written file to server immediately if tc.function.name == "write_file" && status == "done" { if let Some(rel_path) = args.get("path").and_then(|v| v.as_str()) { let full = std::path::Path::new(workdir).join(rel_path); if let Ok(bytes) = tokio::fs::read(&full).await { use base64::Engine; let _ = update_tx.send(AgentUpdate::FileSync { project_id: project_id.to_string(), path: rel_path.to_string(), data_b64: base64::engine::general_purpose::STANDARD.encode(&bytes), }).await; } } } step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } } } // Log LLM call let tc_json: Vec = tool_calls.iter().map(|tc| { serde_json::json!({ "name": tc.function.name, "arguments_preview": truncate_str(&tc.function.arguments, 200), }) }).collect(); let tc_json_str = serde_json::to_string(&tc_json).unwrap_or_else(|_| "[]".to_string()); send_llm_call( update_tx, workflow_id, step_order, &phase_label, msg_count, tool_count, &tc_json_str, &llm_text_response, prompt_tokens, completion_tokens, latency_ms, ).await; if let Some(result) = step_done_result { return result; } } else { // Text response without tool calls let content = choice.message.content.as_deref().unwrap_or("(no content)"); tracing::info!("[workflow {}] Step {} text response: {}", workflow_id, step_order, truncate_str(content, 200)); send_execution(update_tx, workflow_id, step_order, "text_response", "", content, "done").await; send_llm_call( update_tx, workflow_id, step_order, &phase_label, msg_count, tool_count, "[]", content, prompt_tokens, completion_tokens, latency_ms, ).await; // Text response in step loop — continue, LLM may follow up with tool calls } } // Hit 50-iteration limit tracing::warn!("[workflow {}] Step {} hit iteration limit (50)", workflow_id, step_order); StepResult { status: StepResultStatus::Failed { error: "步骤迭代次数超限(50轮)".into() }, summary: "步骤执行超过50轮迭代限制,未能完成".into(), artifacts: Vec::new(), } } /// Compute plan step infos with a status override for a specific step. fn plan_infos_with_override(steps: &[Step], override_order: i32, override_status: &str) -> Vec { steps.iter().map(|s| { let status = if s.order == override_order { override_status.to_string() } else { match s.status { StepStatus::Pending => "pending", StepStatus::Running => "running", StepStatus::WaitingUser => "waiting_user", StepStatus::Done => "done", StepStatus::Failed => "failed", }.to_string() }; PlanStepInfo { order: s.order, description: s.title.clone(), command: s.description.clone(), status: Some(status), artifacts: s.artifacts.clone(), } }).collect() } #[allow(clippy::too_many_arguments)] pub async fn run_agent_loop( llm: &LlmClient, exec: &LocalExecutor, update_tx: &mpsc::Sender, event_rx: &mut mpsc::Receiver, project_id: &str, workflow_id: &str, requirement: &str, workdir: &str, svc_mgr: &ServiceManager, instructions: &str, initial_state: Option, external_tools: Option<&ExternalToolManager>, require_plan_approval: bool, ) -> anyhow::Result<()> { let planning_tools = build_planning_tools(); let coordinator_tools = build_coordinator_tools(); let mut state = initial_state.unwrap_or_else(AgentState::new); // --- Planning phase loop --- // Keep iterating until we transition out of Planning for iteration in 0..20 { if !matches!(state.phase, AgentPhase::Planning) { break; } let system_prompt = build_planning_prompt(project_id, instructions); let messages = state.build_messages(&system_prompt, requirement); let msg_count = messages.len() as i32; let tool_count = planning_tools.len() as i32; tracing::info!("[workflow {}] Planning LLM call #{} msgs={}", workflow_id, iteration + 1, messages.len()); let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: "规划中 — 等待 LLM 响应...".to_string(), }).await; let call_start = std::time::Instant::now(); let response = match llm.chat_with_tools(messages, &planning_tools).await { Ok(r) => r, Err(e) => { tracing::error!("[workflow {}] LLM call failed: {}", workflow_id, e); return Err(e); } }; let latency_ms = call_start.elapsed().as_millis() as i64; let (prompt_tokens, completion_tokens) = response.usage.as_ref() .map(|u| (Some(u.prompt_tokens), Some(u.completion_tokens))) .unwrap_or((None, None)); let choice = response.choices.into_iter().next() .ok_or_else(|| anyhow::anyhow!("No response from LLM"))?; state.current_step_chat_history.push(choice.message.clone()); let llm_text_response = choice.message.content.clone().unwrap_or_default(); if let Some(tool_calls) = &choice.message.tool_calls { let mut phase_transition = false; for tc in tool_calls { if phase_transition { state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, "(skipped: phase transition)")); continue; } let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or_default(); match tc.function.name.as_str() { "update_plan" => { let raw_steps = args["steps"].as_array().cloned().unwrap_or_default(); state.steps.clear(); for (i, item) in raw_steps.iter().enumerate() { let order = (i + 1) as i32; let title = item["title"].as_str().unwrap_or(item.as_str().unwrap_or("")).to_string(); let detail = item["description"].as_str().unwrap_or("").to_string(); state.steps.push(Step { order, title, description: detail, status: StepStatus::Pending, summary: None, user_feedbacks: Vec::new(), db_id: String::new(), artifacts: Vec::new(), }); } let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; send_snapshot(update_tx, workflow_id, 0, &state).await; tracing::info!("[workflow {}] Plan set ({} steps)", workflow_id, state.steps.len()); // If require_plan_approval, wait for user to confirm the plan if require_plan_approval { tracing::info!("[workflow {}] Waiting for plan approval", workflow_id); let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: "计划已生成 — 等待用户确认...".to_string(), }).await; let _ = update_tx.send(AgentUpdate::WorkflowStatus { workflow_id: workflow_id.to_string(), status: "waiting_user".into(), reason: String::new(), }).await; send_execution(update_tx, workflow_id, 0, "plan_approval", "等待确认计划", "等待用户确认执行计划", "waiting").await; // Block until Comment event let approval_content = loop { match event_rx.recv().await { Some(AgentEvent::Comment { content, .. }) => break content, Some(_) => continue, None => { anyhow::bail!("Event channel closed while waiting for plan approval"); } } }; tracing::info!("[workflow {}] Plan approval response: {}", workflow_id, approval_content); if approval_content.starts_with("rejected:") { let reason = approval_content.strip_prefix("rejected:").unwrap_or("").trim(); tracing::info!("[workflow {}] Plan rejected: {}", workflow_id, reason); send_execution(update_tx, workflow_id, 0, "plan_approval", "rejected", reason, "failed").await; state.current_step_chat_history.push(ChatMessage::tool_result( &tc.id, &format!("用户拒绝了此计划: {}。请根据反馈修改计划后重新调用 update_plan。", reason), )); state.steps.clear(); let _ = update_tx.send(AgentUpdate::WorkflowStatus { workflow_id: workflow_id.to_string(), status: "executing".into(), reason: String::new(), }).await; // Stay in Planning phase, continue the loop continue; } // Approved let feedback = if approval_content.starts_with("approved:") { approval_content.strip_prefix("approved:").unwrap_or("").trim().to_string() } else { String::new() }; send_execution(update_tx, workflow_id, 0, "plan_approval", "approved", &feedback, "done").await; let _ = update_tx.send(AgentUpdate::WorkflowStatus { workflow_id: workflow_id.to_string(), status: "executing".into(), reason: String::new(), }).await; } // Enter execution phase if let Some(first) = state.steps.first_mut() { first.status = StepStatus::Running; } let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; state.current_step_chat_history.clear(); state.phase = AgentPhase::Executing { step: 1 }; phase_transition = true; send_snapshot(update_tx, workflow_id, 0, &state).await; tracing::info!("[workflow {}] Entering Executing", workflow_id); } // Planning phase IO tools _ => { let result = execute_tool(&tc.function.name, &tc.function.arguments, workdir, exec).await; state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } } } let tc_json: Vec = tool_calls.iter().map(|tc| { serde_json::json!({ "name": tc.function.name, "arguments_preview": truncate_str(&tc.function.arguments, 200) }) }).collect(); let tc_json_str = serde_json::to_string(&tc_json).unwrap_or_else(|_| "[]".to_string()); send_llm_call(update_tx, workflow_id, 0, "planning", msg_count, tool_count, &tc_json_str, &llm_text_response, prompt_tokens, completion_tokens, latency_ms).await; } else { let content = choice.message.content.as_deref().unwrap_or("(no content)"); tracing::info!("[workflow {}] Planning text response: {}", workflow_id, truncate_str(content, 200)); send_execution(update_tx, workflow_id, 0, "text_response", "", content, "done").await; send_llm_call(update_tx, workflow_id, 0, "planning", msg_count, tool_count, "[]", content, prompt_tokens, completion_tokens, latency_ms).await; } } // --- Executing phase: step isolation loop --- while matches!(state.phase, AgentPhase::Executing { .. }) { let step_order = match state.first_actionable_step() { Some(o) => o, None => { state.phase = AgentPhase::Completed; break; } }; // Mark step as Running if let Some(step) = state.steps.iter_mut().find(|s| s.order == step_order) { step.status = StepStatus::Running; } state.phase = AgentPhase::Executing { step: step_order }; state.current_step_chat_history.clear(); let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; send_snapshot(update_tx, workflow_id, step_order, &state).await; // Build completed summaries for context let completed_summaries: Vec<(i32, String, String, Vec)> = state.steps.iter() .filter(|s| matches!(s.status, StepStatus::Done)) .map(|s| (s.order, s.title.clone(), s.summary.clone().unwrap_or_default(), s.artifacts.clone())) .collect(); let step = state.steps.iter().find(|s| s.order == step_order).unwrap().clone(); tracing::info!("[workflow {}] Starting step {} sub-loop: {}", workflow_id, step_order, step.title); // Run the isolated step sub-loop let step_result = run_step_loop( llm, exec, update_tx, event_rx, project_id, workflow_id, workdir, svc_mgr, instructions, &step, &completed_summaries, &state.scratchpad, external_tools, &state.steps, ).await; tracing::info!("[workflow {}] Step {} completed: {:?}", workflow_id, step_order, step_result.status); // Update step status based on result match &step_result.status { StepResultStatus::Done => { if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) { s.status = StepStatus::Done; s.summary = Some(step_result.summary.clone()); s.artifacts = step_result.artifacts.clone(); } } StepResultStatus::Failed { error } => { if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) { s.status = StepStatus::Failed; s.summary = Some(step_result.summary.clone()); } let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; send_snapshot(update_tx, workflow_id, step_order, &state).await; return Err(anyhow::anyhow!("Step {} failed: {}", step_order, error)); } StepResultStatus::NeedsInput { message: _ } => { if let Some(s) = state.steps.iter_mut().find(|s| s.order == step_order) { s.status = StepStatus::WaitingUser; } send_snapshot(update_tx, workflow_id, step_order, &state).await; continue; } } let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; send_snapshot(update_tx, workflow_id, step_order, &state).await; // --- Coordinator review --- // Check if there are more steps; if not, we're done if state.first_actionable_step().is_none() { state.phase = AgentPhase::Completed; break; } // Coordinator LLM reviews the step result and may update the plan let coordinator_prompt = build_coordinator_prompt(project_id, instructions); let review_message = format!( "步骤 {} 「{}」执行完成。\n\n执行摘要:{}\n\n请审视结果。如需修改后续计划请使用 update_plan,否则回复确认继续。", step_order, step.title, step_result.summary ); // Build coordinator context with plan overview + scratchpad let mut coordinator_ctx = String::new(); coordinator_ctx.push_str("## 计划概览\n"); for s in &state.steps { let marker = match s.status { StepStatus::Done => " [done]", StepStatus::Running => " [running]", StepStatus::WaitingUser => " [waiting]", StepStatus::Failed => " [FAILED]", StepStatus::Pending => "", }; coordinator_ctx.push_str(&format!("{}. {}{}\n", s.order, s.title, marker)); if let Some(summary) = &s.summary { coordinator_ctx.push_str(&format!(" 摘要: {}\n", summary)); } } if !state.scratchpad.is_empty() { coordinator_ctx.push_str(&format!("\n## 全局备忘录\n{}\n", state.scratchpad)); } let coord_messages = vec![ ChatMessage::system(&coordinator_prompt), ChatMessage::user(&coordinator_ctx), ChatMessage::user(&review_message), ]; // Add to main chat history for context state.current_step_chat_history.clear(); tracing::info!("[workflow {}] Coordinator review for step {}", workflow_id, step_order); let _ = update_tx.send(AgentUpdate::Activity { workflow_id: workflow_id.to_string(), activity: format!("步骤 {} 完成 — 协调器审核中...", step_order), }).await; let call_start = std::time::Instant::now(); let coord_response = match llm.chat_with_tools(coord_messages.clone(), &coordinator_tools).await { Ok(r) => r, Err(e) => { tracing::warn!("[workflow {}] Coordinator LLM call failed, continuing: {}", workflow_id, e); continue; // Non-fatal, just skip review } }; let latency_ms = call_start.elapsed().as_millis() as i64; let (prompt_tokens, completion_tokens) = coord_response.usage.as_ref() .map(|u| (Some(u.prompt_tokens), Some(u.completion_tokens))) .unwrap_or((None, None)); send_llm_call(update_tx, workflow_id, step_order, "coordinator", coord_messages.len() as i32, coordinator_tools.len() as i32, "[]", "", prompt_tokens, completion_tokens, latency_ms).await; if let Some(choice) = coord_response.choices.into_iter().next() { if let Some(tool_calls) = &choice.message.tool_calls { for tc in tool_calls { let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or_default(); match tc.function.name.as_str() { "update_plan" => { let raw_steps = args["steps"].as_array().cloned().unwrap_or_default(); let new_steps: Vec = raw_steps.iter().enumerate().map(|(i, item)| { Step { order: (i + 1) as i32, title: item["title"].as_str().unwrap_or("").to_string(), description: item["description"].as_str().unwrap_or("").to_string(), status: StepStatus::Pending, summary: None, user_feedbacks: Vec::new(), db_id: String::new(), artifacts: Vec::new(), } }).collect(); state.apply_plan_diff(new_steps); let _ = update_tx.send(AgentUpdate::PlanUpdate { workflow_id: workflow_id.to_string(), steps: plan_infos_from_state(&state), }).await; tracing::info!("[workflow {}] Coordinator revised plan", workflow_id); send_snapshot(update_tx, workflow_id, step_order, &state).await; } "update_scratchpad" => { let content = args["content"].as_str().unwrap_or(""); let mut new_pad = state.scratchpad.clone(); if !new_pad.is_empty() { new_pad.push('\n'); } new_pad.push_str(content); if check_scratchpad_size(&new_pad).is_ok() { state.scratchpad = new_pad; } } "update_requirement" => { let new_req = args["requirement"].as_str().unwrap_or(""); let _ = tokio::fs::write(format!("{}/requirement.md", workdir), new_req).await; let _ = update_tx.send(AgentUpdate::RequirementUpdate { workflow_id: workflow_id.to_string(), requirement: new_req.to_string(), }).await; } _ => {} } } } else { // Text response — coordinator is satisfied, continue let content = choice.message.content.as_deref().unwrap_or(""); tracing::info!("[workflow {}] Coordinator: {}", workflow_id, truncate_str(content, 200)); } } } // Final snapshot send_snapshot(update_tx, workflow_id, state.current_step(), &state).await; Ok(()) } /// Simple log entry for report generation (no DB dependency). /// Used by the worker binary to collect execution logs during agent loop. #[allow(dead_code)] pub struct SimpleLogEntry { pub step_order: i32, pub tool_name: String, pub tool_input: String, pub output: String, pub status: String, } #[allow(dead_code)] pub async fn generate_report( llm: &LlmClient, requirement: &str, entries: &[SimpleLogEntry], project_id: &str, ) -> anyhow::Result { let steps_detail: String = entries .iter() .map(|e| { let output_preview = if e.output.len() > 2000 { format!("{}...(truncated)", truncate_str(&e.output, 2000)) } else { e.output.clone() }; format!( "### [{}] {} (step {})\nInput: `{}`\nOutput:\n```\n{}\n```\n", e.status, e.tool_name, e.step_order, truncate_str(&e.tool_input, 500), output_preview ) }) .collect::>() .join("\n"); let system_prompt = include_str!("prompts/report.md") .replace("{project_id}", project_id); let user_msg = format!( "需求:\n{}\n\n执行详情:\n{}", requirement, steps_detail ); let report = llm .chat(vec![ ChatMessage::system(&system_prompt), ChatMessage::user(&user_msg), ]) .await?; Ok(report) } fn generate_title_heuristic(requirement: &str) -> String { let first_line = requirement.lines().next().unwrap_or(requirement); let trimmed = first_line.trim().trim_start_matches('#').trim(); truncate_str(trimmed, 50).to_string() } #[cfg(test)] mod tests { use super::*; use crate::state::{Step, StepStatus}; fn make_step(order: i32, title: &str, desc: &str, status: StepStatus) -> Step { Step { order, title: title.into(), description: desc.into(), status, summary: None, user_feedbacks: Vec::new(), db_id: String::new(), artifacts: Vec::new(), } } // --- build_step_user_message --- #[test] fn step_msg_basic() { let step = make_step(1, "Setup env", "Install dependencies", StepStatus::Running); let msg = build_step_user_message(&step, &[], ""); assert!(msg.contains("## 当前步骤(步骤 1)")); assert!(msg.contains("标题:Setup env")); assert!(msg.contains("描述:Install dependencies")); // No completed summaries or scratchpad sections assert!(!msg.contains("已完成步骤摘要")); assert!(!msg.contains("项目备忘录")); } #[test] fn step_msg_with_completed_summaries() { let step = make_step(3, "Deploy", "Push to prod", StepStatus::Running); let summaries = vec![ (1, "Setup".into(), "Installed deps".into(), Vec::new()), (2, "Build".into(), "Compiled OK".into(), Vec::new()), ]; let msg = build_step_user_message(&step, &summaries, ""); assert!(msg.contains("## 已完成步骤摘要")); assert!(msg.contains("步骤 1 (Setup): Installed deps")); assert!(msg.contains("步骤 2 (Build): Compiled OK")); } #[test] fn step_msg_with_parent_scratchpad() { let step = make_step(2, "Build", "compile", StepStatus::Running); let msg = build_step_user_message(&step, &[], "DB_HOST=localhost\nDB_PORT=5432"); assert!(msg.contains("## 项目备忘录(只读)")); assert!(msg.contains("DB_HOST=localhost")); assert!(msg.contains("DB_PORT=5432")); } #[test] fn step_msg_with_user_feedback() { let step = Step { user_feedbacks: vec!["Use Python 3.12".into(), "Skip linting".into()], ..make_step(1, "Setup", "setup env", StepStatus::Running) }; let msg = build_step_user_message(&step, &[], ""); assert!(msg.contains("用户反馈")); assert!(msg.contains("- Use Python 3.12")); assert!(msg.contains("- Skip linting")); } #[test] fn step_msg_full_context() { let step = Step { user_feedbacks: vec!["add caching".into()], ..make_step(3, "API", "build REST API", StepStatus::Running) }; let summaries = vec![ (1, "DB".into(), "Schema created".into(), Vec::new()), (2, "Models".into(), "ORM models done".into(), Vec::new()), ]; let msg = build_step_user_message(&step, &summaries, "tech_stack=FastAPI"); // All sections present assert!(msg.contains("## 当前步骤(步骤 3)")); assert!(msg.contains("## 已完成步骤摘要")); assert!(msg.contains("## 项目备忘录(只读)")); assert!(msg.contains("用户反馈")); // Content correct assert!(msg.contains("build REST API")); assert!(msg.contains("Schema created")); assert!(msg.contains("tech_stack=FastAPI")); assert!(msg.contains("add caching")); } // --- truncate_str --- #[test] fn truncate_short_noop() { assert_eq!(truncate_str("hello", 10), "hello"); } #[test] fn truncate_exact() { assert_eq!(truncate_str("hello", 5), "hello"); } #[test] fn truncate_cuts() { assert_eq!(truncate_str("hello world", 5), "hello"); } #[test] fn truncate_respects_char_boundary() { let s = "你好世界"; // each char is 3 bytes // 7 bytes → should cut to 6 (2 chars) let t = truncate_str(s, 7); assert_eq!(t, "你好"); assert_eq!(t.len(), 6); } // --- tool definitions --- #[test] fn step_tools_have_step_done() { let tools = build_step_tools(); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); assert!(names.contains(&"step_done"), "step_done must be in step tools"); assert!(!names.contains(&"advance_step"), "advance_step must NOT be in step tools"); assert!(!names.contains(&"update_plan"), "update_plan must NOT be in step tools"); assert!(!names.contains(&"update_requirement"), "update_requirement must NOT be in step tools"); } #[test] fn step_tools_have_execution_tools() { let tools = build_step_tools(); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); for expected in &["execute", "read_file", "write_file", "list_files", "start_service", "stop_service", "update_scratchpad", "ask_user"] { assert!(names.contains(expected), "{} must be in step tools", expected); } } #[test] fn coordinator_tools_correct() { let tools = build_coordinator_tools(); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); assert!(names.contains(&"update_plan")); assert!(names.contains(&"update_scratchpad")); assert!(names.contains(&"update_requirement")); // Must NOT have execution tools assert!(!names.contains(&"execute")); assert!(!names.contains(&"step_done")); assert!(!names.contains(&"advance_step")); } #[test] fn planning_tools_correct() { let tools = build_planning_tools(); let names: Vec<&str> = tools.iter().map(|t| t.function.name.as_str()).collect(); assert!(names.contains(&"update_plan")); assert!(names.contains(&"list_files")); assert!(names.contains(&"read_file")); assert!(!names.contains(&"execute")); assert!(!names.contains(&"step_done")); } // --- plan_infos_from_state --- #[test] fn plan_infos_maps_correctly() { let state = AgentState { phase: AgentPhase::Executing { step: 2 }, steps: vec![ Step { status: StepStatus::Done, summary: Some("done".into()), ..make_step(1, "A", "desc A", StepStatus::Done) }, make_step(2, "B", "desc B", StepStatus::Running), ], current_step_chat_history: Vec::new(), scratchpad: String::new(), }; let infos = plan_infos_from_state(&state); assert_eq!(infos.len(), 2); assert_eq!(infos[0].order, 1); assert_eq!(infos[0].description, "A"); // title maps to description field assert_eq!(infos[0].command, "desc A"); // description maps to command field assert_eq!(infos[0].status.as_deref(), Some("done")); assert_eq!(infos[1].status.as_deref(), Some("running")); } }