diff --git a/doc/context.md b/doc/context.md index 4bb19bb..dfd106c 100644 --- a/doc/context.md +++ b/doc/context.md @@ -1,392 +1,86 @@ -# Agent Runtime 设计 +# Agent Context 构建 -## Context 管理现状与设计 +AgentState 如何变成 LLM API call 的 messages 数组。定义见 `src/state.rs`。 -## 现状 - -当前没有做 context 长度限制,存在超过 model token limit 的风险。 - -### 已有的缓解机制 - -1. **Phase transition 时 clear**:`step_messages` 在 planning→executing 和 step→step 切换时会 `clear()`,避免跨阶段累积 -2. **单条 tool output 截断**:bash 输出限制 8000 bytes,read_file 超长时也截断 -3. **Step context 摘要**:已完成步骤只保留 summary(`step_summaries`),不带完整输出 - -### 风险场景 - -- 一个 execution step 内 tool call 轮次过多(反复 bash、read_file),`step_messages` 无限增长 -- 每轮 LLM 的 assistant message + tool result 都 push 进 `step_messages`,没有上限 -- 最终整个 messages 数组超过模型 context window - -## 方案设计 - -### 策略:滑动窗口 + 早期消息摘要 - -当 `step_messages` 长度超过阈值时,保留最近 N 轮完整对话,早期的 tool call/result 对折叠为一条摘要消息。 - -``` -[system prompt] -[user: step context] -[summary of early tool interactions] ← 压缩后的历史 -[recent assistant + tool messages] ← 完整保留最近 N 轮 -``` - -### 具体实现 - -1. **Token 估算**:用字符数粗估(1 token ≈ 3-4 chars 中英混合),不需要精确 tokenizer -2. **阈值**:可配置,默认如 80000 chars(约 20k-25k tokens),给 system prompt 和 response 留余量 -3. **压缩触发**:每次构建 messages 时检查总长度,超过阈值则压缩 -4. **压缩方式**: - - 简单版:直接丢弃早期 tool call/result 对,替换为 `[已执行 N 次工具调用,最近结果见下文]` - - 进阶版:用 LLM 生成摘要(额外一次 API 调用,但质量更好) -5. **不压缩的部分**:system prompt、user context、最近 2-3 轮完整交互 - -### 实现位置 - -在 `run_agent_loop` 中构建 messages 之后、调用 LLM 之前,插入压缩逻辑: +## AgentState ```rust -// agent.rs run_agent_loop 内,约 L706-L725 -let (mut messages, tools) = match &state.phase { ... }; - -// 压缩 context -compact_messages(&mut messages, MAX_CONTEXT_CHARS); -``` - -`compact_messages` 函数:从前往后扫描,保留 system/user 头部,计算总长度,超限时将早期 assistant+tool 消息替换为摘要。 - ---- - -## 执行中用户反馈处理 (Plan-Centric Feedback) - -### 核心原则 - -Agent loop 是 **以 plan 为中心线性推进** 的。用户反馈的处理不应该是简单的"中断/不中断"二元决策,而应该根据反馈落在 plan 的哪个位置来决定行为: - -``` -Plan: [step1 ✓] [step2 ✓] [step3 🔄 executing] [step4 ○] [step5 ○] - ↑ - 当前执行位置 - -用户反馈 → 影响哪里? - ├─ 影响已完成部分 (step1/step2) → 回退:中断当前执行,replan from affected step - ├─ 改了需求本身 → 回退:中断当前执行,全部 replan - ├─ 影响未完成部分 (step4/step5) → 前进:不中断,调整后续 plan steps - └─ 影响当前步骤 (step3) → 视情况:注入到当前 step context 或中断重做 -``` - -### 与"中断"的关系 - -"中断"只是一个实现手段,不是目的。真正的决策是: - -1. **反馈是否影响当前或已完成的工作?** → 必须中断(继续执行是浪费) -2. **反馈只影响未来的步骤?** → 不中断,修改 plan 里的 pending steps,执行到那里时自然生效 - -这意味着 cancel token 之类的机制仍然需要(作为中断的执行手段),但决策逻辑是 plan-aware 的。 - -### 决策流程 - -``` -用户 comment 到达 - │ - ├─ workflow idle (done/failed) - │ └─ 当前逻辑:以 comment 为 context 重新执行 - │ - └─ workflow executing (step N) - │ - ├─ 1. 分类 LLM 调用(轻量,不阻塞执行) - │ 输入:原始需求、当前 plan、当前步骤进度、用户 comment - │ 输出:{ impact: "past" | "current" | "future" | "requirement_change" } - │ - ├─ impact = "past" 或 "requirement_change" - │ → cancel 当前执行 - │ → replan(带上已完成步骤的摘要 + 用户反馈) - │ - ├─ impact = "current" - │ → 注入到 step_messages(当前步骤的 context) - │ → agent 在下一次 LLM 调用时自然看到 - │ → 如果反馈是否定性的("这样不行"),cancel 当前步骤,重做 - │ - └─ impact = "future" - → 修改 plan 中 pending steps(update/insert/delete) - → 不中断当前执行 - → 执行到对应步骤时自然生效 -``` - -### 实现要点 - -#### 1. Plan 作为可变数据结构 - -当前 plan steps 存在 DB 里,但 agent 执行时是线性推进的,没有"修改后续步骤"的能力。需要: -- plan steps 支持运行时增删改(不仅是状态更新) -- agent 每执行完一步,从 DB 重新读 plan(而不是用内存快照),这样外部修改能被感知 - -#### 2. 并发:分类 LLM 与执行 LLM 并行 - -分类调用不应该阻塞执行。用 `tokio::spawn` 做分类,分类结果通过 channel 或 shared state 回传。如果分类结果是"需要中断",设置 cancel token。 - -```rust -// 伪代码 -tokio::spawn(async move { - let impact = classify_feedback(&llm, &plan, current_step, &comment).await; - match impact { - Impact::Past | Impact::RequirementChange => { - cancel_token.cancel(); - // replan 会在 cancel 生效后由主循环触发 - } - Impact::Current => { - // 注入到 step context - step_messages_tx.send(comment).await; - } - Impact::Future => { - // 调用 LLM 修改后续 plan steps - replan_future_steps(&llm, &plan, current_step, &comment).await; - } - } -}); -``` - -#### 3. 中断后的 Context 衔接 - -中断执行后重启时,需要构建的 context: -- 已完成步骤的摘要(已有 `step_summaries` 机制) -- 被中断步骤的部分执行记录 -- 用户反馈内容 -- "请根据用户反馈重新规划剩余步骤"的指令 - -这和上面 context 压缩的滑动窗口方案互补——中断后重启本质上就是一次强制的 context 压缩。 - -### 与 context 压缩的统一视角 - -两个问题其实是同一件事的两面: - -| | Context 压缩 | 用户反馈 | -|---|---|---| -| 触发条件 | token 数超阈值 | 用户发了 comment | -| 本质操作 | 摘要化早期历史,保留近期 | 根据反馈位置,决定哪些历史需要保留/丢弃/重做 | -| 共享机制 | step_summaries、compact_messages | 同上 + plan 的增删改 | - -两者都需要一个健壮的"摘要化已完成工作"的基础能力。先把这个基础能力做好,上面两个场景都能受益。 - -### Plan-Centric Context 优先级 - -Context 管理也应该围绕 plan 展开。Plan 本身是结构化的、紧凑的,基本不会超。撑爆 context 的是执行日志(一个 step 内反复 tool call 的历史)。这就有了清晰的层级和主次: - -``` -优先级(从高到低,压缩时从低往高砍): - -1. System prompt + 需求 ← 不可压缩,恒定大小 -2. Plan 全貌(步骤标题 + 状态) ← 不可压缩,O(步骤数) 很短 -3. 用户 comments ← 保留原文,通常很短 -4. 当前步骤的最近 N 轮 tool call ← 完整保留(agent 需要看到最近的执行结果) -5. 当前步骤的早期 tool call ← 第一优先压缩目标,摘要化 -6. 已完成步骤的执行摘要 ← 已经是一句话/步骤,极紧凑 -``` - -关键洞察:**Plan 是骨架,执行日志是血肉。** 骨架永远保留,血肉按新鲜度裁剪。这和人类记忆一样——你记得"今天做了什么"(plan),但不记得每一步的具体命令输出(执行日志)。 - -压缩算法因此变得很简单: -1. 算总 token 估计 -2. 如果超阈值,从"当前步骤的早期 tool call"开始砍,替换为 `[前 N 次工具调用已折叠,关键结果:...]` -3. 如果还超,压缩已完成步骤的摘要(合并多步为一段) -4. Plan 和 system prompt 永远不动 - ---- - -## 状态管理、并发与一致性 - -### 系统中的参与者和他们各自的"世界观" - -一致性问题的本质是:多个参与者对同一份状态有各自的视图,这些视图会因为延迟和并发而产生分歧。 - -| 参与者 | 看到的世界 | 更新方式 | 延迟 | -|---|---|---|---| -| **Agent loop** | 内存 `AgentState` | 直接修改 | 0(权威源) | -| **LLM** | 上一次调用时传入的 messages | 每次调用时重建 | 一次 LLM 调用的时间(数秒) | -| **DB** | 持久化的 plan_steps, workflow.status | agent 写入 | 取决于 agent 何时 flush | -| **前端** | 最近一次 WS 推送 / API 响应 | WS broadcast | 网络延迟 + WS 推送时机 | -| **用户** | 看着前端 + 自己的判断 | 发 comment | 人类反应时间(秒~分钟) | - -问题出在哪: - -``` -时间线: - t0 agent 开始执行 step 3 - t1 前端显示 step 3 running(WS 推送) - t2 用户看到 step 3,觉得方向不对,开始打字 - t3 agent 完成 step 3,开始 step 4 - t4 前端显示 step 4 running - t5 用户发出 comment:"step 3 不对,应该这样做" - → 但此时 step 3 已经做完了,step 4 在跑了 - → 用户的反馈是基于 t1 时的世界观 -``` - -这种"用户看到的是过去"是不可消除的(人类反应时间),关键是系统怎么处理这个 gap。 - -### 核心设计:统一状态 + 单写者 - -#### 状态 Schema - -一个 project 的完整运行时状态 = 一个 `ProjectState` 对象: - -```rust -struct ProjectState { - workflow_id: String, - requirement: String, - status: WorkflowStatus, // Idle / Executing / Done / Failed - - plan: Vec, // 有序步骤,每步有 status - current_step: Option, // 正在执行哪一步 - - step_summaries: Vec, // 已完成步骤的紧凑摘要 - step_messages: Vec, // 当前步骤的 tool call 历史(临时,会膨胀) - - memo: String, // agent 备忘 - iteration: u32, // LLM 调用轮次 - - cancel: CancellationToken, // 中断信号 +struct AgentState { + phase: AgentPhase, // Planning | Executing { step } | Completed + steps: Vec, // 执行计划,每个 step 有 status + optional summary + current_step_chat_history: Vec, // 当前步骤内的多轮对话,step 切换时 clear + scratchpad: String, // LLM 的跨步骤工作区 } ``` -用 `Arc>` 持有。 +整个结构体 `Serialize/Deserialize`,可 JSON 直接存 DB。 -#### 单写者规则 - -**只有 agent loop 修改 `ProjectState`。** 没有例外。 - -其他所有参与者(反馈分类器、API handler、WS handler)都是读者或事件发送者: - -- 想看状态 → read lock,clone 出快照,立即释放 -- 想改状态 → 发 event 到 agent loop 的 channel,由 agent loop 决定怎么改 - -这彻底消除了写-写竞争。不需要事务,不需要 CAS,不需要冲突解决。 - -#### 数据流 +## Step 生命周期 ``` -用户 comment ─→ API handler ─→ mpsc channel ─→ agent loop (唯一写者) - │ - write lock ──→ ProjectState - │ - write DB (flush) - │ - broadcast WS ──→ 前端 +Pending → Running → Done(summary) 或 Failed ``` -### 一致性分析:三对关系 +summary 在 step 完成时由 LLM 填入,作为后续步骤的压缩上下文。 -#### 1. 内存 ↔ DB 一致性 +## Messages 组装 -当前问题:`AgentState`(内存)和 `plan_steps`(DB)是两套独立数据,各管各的。 +### Planning 阶段 -**解法:内存是 master,DB 是 checkpoint。** +``` +[ system(planning_prompt), user(requirement), ...current_step_chat_history ] +``` -- Agent loop 修改 `ProjectState`(内存),在关键节点 flush 到 DB -- 前端 API 读内存(read lock),不读 DB(运行时) -- DB 只在重启恢复时读 +### Executing 阶段 -Flush 时机(从密到疏): -| 事件 | 写 DB | 原因 | +``` +[ system(execution_prompt), user(step_context), ...current_step_chat_history ] +``` + +`step_context` 由 `build_step_context()` 拼接: + +``` +## 需求 +{requirement} + +## 计划概览 +1. 分析代码结构 done +2. 实现核心逻辑 >> current +3. 写测试 FAILED +4. 集成测试 + +## 当前步骤(步骤 2) +标题:实现核心逻辑 +描述:... + +## 已完成步骤摘要 ← 从 steps 中 filter Done,取 summary +- 步骤 1: ... + +## Scratchpad ← LLM 自己维护的跨步骤笔记 +... +``` + +## 持久化 + +### DB 表 + +- **agent_state_snapshots** — step 切换时 insert 一行 AgentState JSON 快照(追加,不覆盖,保留历史)。恢复时取最新一行。 +- **execution_log** — tool call 的输入输出记录(不可变历史),前端展示 + report 生成用。 + +Plan 步骤只从 AgentState JSON 读,不再单独写表。 + +### Executing 阶段 text response + +LLM 返回纯文本时不隐含"workflow 结束",写 execution_log 显示给用户。只有显式调 `advance_step` 才推进步骤。 + +## 待做:Context 压缩 + +当前无长度限制,`current_step_chat_history` 在单步 tool call 轮次过多时会无限增长。 + +压缩按优先级从低到高砍: + +| 优先级 | 内容 | 处理 | |---|---|---| -| workflow.status 变更 | 立即 | 重启恢复必需 | -| plan 变更(新建/修改步骤) | 立即 | 重启恢复 + 持久记录 | -| 步骤完成(summary 产出) | 立即 | 重启恢复的关键数据 | -| step_messages 增长 | 不写 | 临时数据,中断时才摘要化 | -| memo 更新 | 步骤切换时 | 丢了问题不大 | - -重启恢复 = 从 DB 重建 `ProjectState`,`step_messages` 为空(丢失可接受,summaries 提供足够上下文)。 - -#### 2. 后端 ↔ 前端 一致性 - -前端通过两个渠道获取状态: -- **WS 推送**:agent loop 每次修改状态后 broadcast -- **API 拉取**:前端 mount 时、切换 project 时 - -一致性模型:**最终一致**。前端可能落后几百毫秒,但不会出现永久分歧。 - -需要保证的不变量: -- WS 推送的顺序和状态变更顺序一致(单写者天然保证) -- 前端收到 WS 消息后,可以安全地 patch 本地状态(不需要全量刷新) -- 如果 WS 断连,重连后拉一次全量状态即可恢复 - -#### 3. Agent loop ↔ LLM ↔ 用户反馈 一致性 - -这是最微妙的。三方的时间线: - -``` -Agent loop: ──[step3 执行中]──[step3 完成]──[step4 开始]──... -LLM: ──[thinking...]──[返回 tool calls]── -用户: ──[看到 step3]──[打字中...]──[发送 comment]── -``` - -三个视角可能同时看到不同的状态。处理方式: - -**a) LLM 的"冻结视角"** - -每次 LLM 调用时,传入的 messages 是一个 point-in-time 快照。LLM 返回的 tool calls 是基于那个快照的。在 LLM 思考期间,状态可能已经变了(比如用户发了 comment),但 LLM 不知道。 - -这没关系——LLM 返回后,agent loop 先检查 cancel flag,再决定是否执行 tool calls。如果 cancel 了,丢弃 LLM 的响应,不浪费。 - -**b) 用户反馈的"过时性"** - -用户发 comment 时看到的可能是几秒前的状态。反馈里提到的"step 3"可能已经完成了。 - -处理:反馈分类器拿当前快照(read lock),对比用户反馈和当前进度,判断反馈是关于: -- 已完成的步骤 → 需要 revert/redo -- 当前步骤 → 注入或中断 -- 未来步骤 → 修改 plan -- 需求级别 → 全部 replan - -分类结果作为 event 发给 agent loop。Agent loop 收到时再次基于最新状态做最终决策(双重检查)。 - -**c) 反馈分类器的并发** - -反馈分类器 `tokio::spawn` 出去,和 agent loop 并行: - -``` -Agent loop: ──[step4 执行中]──────────[step4 完成]──[处理 feedback event]── - ↑ -Feedback classifier: ──[拿 snapshot]──[LLM 分类...]──[发 event]─┘ -``` - -分类器拿的 snapshot 可能已经过时了(分类期间 agent 又推进了一步)。但因为分类结果是建议性的,agent loop 收到后基于最新状态做最终决策,所以不会出错。最坏情况:建议无效,忽略。 - -### Agent loop 内部的事件处理 - -Agent loop 不再是"拿 event → 跑完整个 run_agent_loop → 拿下一个 event"。而是在 tool call 循环内部也能感知新事件: - -```rust -for iteration in 0..MAX_ITERATIONS { - // 1. 检查中断 - if state.cancel.is_cancelled() { - // 摘要化当前 step_messages,存 DB - break; - } - - // 2. 检查是否有 pending feedback events(非阻塞) - while let Ok(event) = feedback_rx.try_recv() { - handle_feedback(&mut state, event); - // 可能修改了 plan、设置了 cancel、注入了 step_messages - } - - // 3. 构建 messages,调 LLM - let messages = build_messages(&state); - let response = llm.chat(messages).await?; - - // 4. 执行 tool calls,更新 state - // ... -} -``` - -关键:`feedback_rx.try_recv()` 是非阻塞的。在每次 LLM 调用之间检查一次。如果有 feedback,立即在 agent loop 内处理(修改 plan / 设置 cancel / 注入 context),然后继续循环。 - -这保持了**单写者**——所有状态变更都在这个循环里发生。 - -### 实现顺序 - -1. **定义 `ProjectState`** — 集中状态,`Arc>`。Agent loop 内部用起来,行为不变。 -2. **DB flush 策略** — 关键节点写 DB(status 变更、步骤完成),前端 API 改为读内存。 -3. **Step 摘要持久化** — 步骤完成时写 summary 到 DB,重启时重建 ProjectState。 -4. **Context 压缩** — step_messages 滑动窗口。 -5. **CancellationToken + feedback channel** — agent loop 内部 `try_recv`,单写者处理反馈。 -6. **Feedback 分类器** — `tokio::spawn`,read lock 拿快照,LLM 分类,结果发回 agent loop。 +| 高 | system prompt / requirement / plan 概览 | 不压缩 | +| 中 | 当前步骤最近 N 轮 tool call | 完整保留 | +| 低 | 当前步骤早期 tool call | 替换为摘要 | diff --git a/src/agent.rs b/src/agent.rs index 3f0d2a9..3d7398a 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -10,45 +10,7 @@ use crate::llm::{LlmClient, ChatMessage, Tool, ToolFunction}; use crate::exec::LocalExecutor; use crate::LlmConfig; -// --- State machine types --- - -#[derive(Debug, Clone)] -enum AgentPhase { - Planning, - Executing { step: i32 }, - Completed, -} - -#[derive(Debug, Clone)] -enum PlanItemStatus { - Pending, - Running, - Done, -} - -#[derive(Debug, Clone)] -struct PlanItem { - order: i32, - title: String, - description: String, - status: PlanItemStatus, - db_id: String, -} - -#[derive(Debug, Clone)] -struct StepSummary { - order: i32, - summary: String, -} - -struct AgentState { - phase: AgentPhase, - plan: Vec, - step_summaries: Vec, - step_messages: Vec, - memo: String, - log_step_order: i32, -} +use crate::state::{AgentState, AgentPhase, Step, StepStatus}; pub struct ServiceInfo { pub port: u16, @@ -79,6 +41,25 @@ pub struct PlanStepInfo { pub order: i32, pub description: String, pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +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::Done => "done", + StepStatus::Failed => "failed", + }; + PlanStepInfo { + order: s.order, + description: s.title.clone(), + command: s.description.clone(), + status: Some(status.to_string()), + } + }).collect() } pub struct AgentManager { @@ -347,7 +328,7 @@ async fn agent_loop( let result = run_agent_loop( &llm, &exec, &pool, &broadcast_tx, &project_id, &workflow_id, &requirement, &workdir, &mgr, - &instructions, + &instructions, None, ).await; let final_status = if result.is_ok() { "done" } else { "failed" }; @@ -369,16 +350,16 @@ async fn agent_loop( status: final_status.into(), }); - // Generate report from recorded steps - let all_steps = sqlx::query_as::<_, crate::db::PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" + // Generate report from execution log + let log_entries = sqlx::query_as::<_, crate::db::ExecutionLogEntry>( + "SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at" ) .bind(&workflow_id) .fetch_all(&pool) .await .unwrap_or_default(); - if let Ok(report) = generate_report(&llm, &requirement, &all_steps, &project_id).await { + if let Ok(report) = generate_report(&llm, &requirement, &log_entries, &project_id).await { let _ = sqlx::query("UPDATE workflows SET report = ? WHERE id = ?") .bind(&report) .bind(&workflow_id) @@ -403,76 +384,101 @@ async fn agent_loop( let Some(wf) = wf else { continue }; - // 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'") - .bind(&workflow_id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::PlanUpdate { - workflow_id: workflow_id.clone(), - steps: vec![], - }); - - // Re-run agent loop with comment as additional context - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: "executing".into(), - }); - let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") - .bind(&workflow_id) - .execute(&pool) - .await; - - let combined = format!( - "原始需求:\n{}\n\n用户反馈:\n{}\n\n请处理用户的反馈。如果反馈改变了目标方向,请使用 update_requirement 更新需求。继续在同一工作区中工作。", - 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" }; - if let Err(e) = &result { - let _ = broadcast_tx.send(WsMessage::Error { - message: format!("Agent error: {}", e), - }); - } - - let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?") - .bind(final_status) - .bind(&workflow_id) - .execute(&pool) - .await; - let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { - workflow_id: workflow_id.clone(), - status: final_status.into(), - }); - - // Regenerate report - let all_steps = sqlx::query_as::<_, crate::db::PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" + // Load latest state snapshot + let snapshot = sqlx::query_scalar::<_, String>( + "SELECT state_json FROM agent_state_snapshots WHERE workflow_id = ? ORDER BY created_at DESC LIMIT 1" ) .bind(&workflow_id) - .fetch_all(&pool) + .fetch_optional(&pool) .await - .unwrap_or_default(); + .ok() + .flatten(); - if let Ok(report) = generate_report(&llm, &wf.requirement, &all_steps, &project_id).await { - let _ = sqlx::query("UPDATE workflows SET report = ? WHERE id = ?") - .bind(&report) + let state = snapshot + .and_then(|json| serde_json::from_str::(&json).ok()) + .unwrap_or_else(AgentState::new); + + // Process feedback: LLM decides whether to revise plan + let state = process_feedback( + &llm, &pool, &broadcast_tx, + &project_id, &workflow_id, state, &content, + ).await; + + // If there are actionable steps, resume execution + if state.first_actionable_step().is_some() { + ensure_workspace(&exec, &workdir).await; + + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.clone(), + status: "executing".into(), + }); + let _ = sqlx::query("UPDATE workflows SET status = 'executing' WHERE id = ?") .bind(&workflow_id) .execute(&pool) .await; - let _ = broadcast_tx.send(WsMessage::ReportReady { + + // Prepare state for execution: set first pending step to Running + let mut state = state; + if let Some(next) = state.first_actionable_step() { + if let Some(step) = state.steps.iter_mut().find(|s| s.order == next) { + if matches!(step.status, StepStatus::Pending) { + step.status = StepStatus::Running; + } + } + state.phase = AgentPhase::Executing { step: next }; + state.current_step_chat_history.clear(); + } + + let instructions = read_instructions(&workdir).await; + + let result = run_agent_loop( + &llm, &exec, &pool, &broadcast_tx, + &project_id, &workflow_id, &wf.requirement, &workdir, &mgr, + &instructions, Some(state), + ).await; + + let final_status = if result.is_ok() { "done" } else { "failed" }; + if let Err(e) = &result { + let _ = broadcast_tx.send(WsMessage::Error { + message: format!("Agent error: {}", e), + }); + } + + let _ = sqlx::query("UPDATE workflows SET status = ? WHERE id = ?") + .bind(final_status) + .bind(&workflow_id) + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { workflow_id: workflow_id.clone(), + status: final_status.into(), + }); + + // Regenerate report + let log_entries = sqlx::query_as::<_, crate::db::ExecutionLogEntry>( + "SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at" + ) + .bind(&workflow_id) + .fetch_all(&pool) + .await + .unwrap_or_default(); + + if let Ok(report) = generate_report(&llm, &wf.requirement, &log_entries, &project_id).await { + let _ = sqlx::query("UPDATE workflows SET report = ? WHERE id = ?") + .bind(&report) + .bind(&workflow_id) + .execute(&pool) + .await; + let _ = broadcast_tx.send(WsMessage::ReportReady { + workflow_id: workflow_id.clone(), + }); + } + } else { + // No actionable steps — feedback was informational only + // Mark workflow back to done + let _ = broadcast_tx.send(WsMessage::WorkflowStatusUpdate { + workflow_id: workflow_id.clone(), + status: "done".into(), }); } } @@ -602,10 +608,10 @@ fn build_execution_tools() -> Vec { }, "required": ["summary"] })), - make_tool("save_memo", "保存备忘录信息,后续所有步骤的上下文中都会包含此内容。用于记录跨步骤需要的关键信息(如文件路径、配置项、重要发现)。", serde_json::json!({ + make_tool("update_scratchpad", "更新跨步骤工作区。后续所有步骤的上下文中都会包含此内容。用于记录关键信息(如文件路径、配置项、重要发现)。新内容会追加到已有内容之后。", serde_json::json!({ "type": "object", "properties": { - "content": { "type": "string", "description": "要保存的备忘录内容" } + "content": { "type": "string", "description": "要追加的内容" } }, "required": ["content"] })), @@ -615,37 +621,8 @@ fn build_execution_tools() -> Vec { } fn build_planning_prompt(project_id: &str, instructions: &str) -> String { - let mut prompt = format!( - "你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。\n\ - \n\ - 你的任务:\n\ - 1. 仔细分析用户的需求\n\ - 2. 使用 list_files 和 read_file 检查工作区的现有状态\n\ - 3. 制定一个高层执行计划,调用 update_plan 提交\n\ - \n\ - 计划要求:\n\ - - 每个步骤应是一个逻辑阶段(如'搭建环境'、'实现后端 API'),而非具体命令\n\ - - 每个步骤包含简短标题和详细描述\n\ - - 步骤数量合理(通常 3-8 步)\n\ - \n\ - 调用 update_plan 后,系统会自动进入执行阶段。\n\ - \n\ - 环境信息:\n\ - - 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/)\n\ - - 可用工具:bash、git、curl、uv\n\ - - 静态文件访问:/api/projects/{0}/files/{{filename}}\n\ - - 后台服务访问:/api/projects/{0}/app/(反向代理,路径会被转发到应用的 /)\n\ - \n\ - 【重要】反向代理注意事项:\n\ - - 用户通过 /api/projects/{0}/app/ 访问应用,请求被代理到应用的 / 路径\n\ - - 因此前端 HTML 中的所有 API 请求必须使用【不带开头 / 的相对路径】\n\ - - 正确示例:fetch('todos') 或 fetch('./todos') 错误示例:fetch('/todos') 或 fetch('/api/todos')\n\ - - HTML 中的 标签不需要设置,只要不用绝对路径就行\n\ - - 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文\n\ - \n\ - 请使用中文回复。", - project_id, - ); + 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)); } @@ -653,88 +630,54 @@ fn build_planning_prompt(project_id: &str, instructions: &str) -> String { } fn build_execution_prompt(project_id: &str, instructions: &str) -> String { - let mut prompt = format!( - "你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。\n\ - \n\ - 可用工具:\n\ - - execute:执行 shell 命令\n\ - - read_file / write_file / list_files:文件操作\n\ - - start_service / stop_service:管理后台服务\n\ - - update_requirement:更新项目需求\n\ - - advance_step:完成当前步骤并进入下一步(必须提供摘要)\n\ - - save_memo:保存备忘录(跨步骤持久化的关键信息)\n\ - \n\ - 工作流程:\n\ - 1. 阅读下方的「当前步骤」描述\n\ - 2. 使用工具执行所需操作\n\ - 3. 完成后调用 advance_step(summary=...) 推进到下一步\n\ - 4. 最后一步完成后,直接回复简要总结(不调用工具)即可结束\n\ - \n\ - 环境信息:\n\ - - 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/)\n\ - - 使用 `uv add <包名>` 或 `pip install <包名>` 安装依赖\n\ - - 静态文件访问:/api/projects/{0}/files/{{filename}}\n\ - - 后台服务访问:/api/projects/{0}/app/(启动命令需监听 0.0.0.0:$PORT)\n\ - - 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404\n\ - - 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文\n\ - \n\ - 请使用中文回复。", - project_id, - ); + 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_context(state: &AgentState, requirement: &str) -> String { - let mut ctx = String::new(); - - // Requirement - ctx.push_str("## 需求\n"); - ctx.push_str(requirement); - ctx.push_str("\n\n"); - - // Plan overview - ctx.push_str("## 计划概览\n"); - let current_step = match &state.phase { - AgentPhase::Executing { step } => *step, - _ => 0, - }; - for item in &state.plan { - let marker = match item.status { - PlanItemStatus::Done => " done", - PlanItemStatus::Running => " >> current", - PlanItemStatus::Pending => "", +fn build_feedback_prompt(project_id: &str, state: &AgentState, feedback: &str) -> String { + let mut plan_state = String::new(); + for s in &state.steps { + let status = match s.status { + StepStatus::Done => " [done]", + StepStatus::Running => " [running]", + StepStatus::Failed => " [FAILED]", + StepStatus::Pending => "", }; - ctx.push_str(&format!("{}. {}{}\n", item.order, item.title, marker)); - } - ctx.push('\n'); - - // Current step detail - if let Some(item) = state.plan.iter().find(|p| p.order == current_step) { - ctx.push_str(&format!("## 当前步骤(步骤 {})\n", current_step)); - ctx.push_str(&format!("标题:{}\n", item.title)); - ctx.push_str(&format!("描述:{}\n\n", item.description)); - } - - // Completed step summaries - if !state.step_summaries.is_empty() { - ctx.push_str("## 已完成步骤摘要\n"); - for s in &state.step_summaries { - ctx.push_str(&format!("- 步骤 {}: {}\n", s.order, s.summary)); + plan_state.push_str(&format!("{}. {}{}\n {}\n", s.order, s.title, status, s.description)); + if let Some(summary) = &s.summary { + plan_state.push_str(&format!(" 摘要: {}\n", summary)); } - ctx.push('\n'); } + include_str!("prompts/feedback.md") + .replace("{project_id}", project_id) + .replace("{plan_state}", &plan_state) + .replace("{feedback}", feedback) +} - // Memo - if !state.memo.is_empty() { - ctx.push_str("## 备忘录\n"); - ctx.push_str(&state.memo); - ctx.push('\n'); - } - - ctx +fn build_feedback_tools() -> Vec { + vec![ + make_tool("revise_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"] + })), + ] } // --- Helpers --- @@ -842,6 +785,141 @@ async fn execute_tool( // --- Tool-calling agent loop (state machine) --- +/// Save an AgentState snapshot to DB. +async fn save_state_snapshot(pool: &SqlitePool, workflow_id: &str, step_order: i32, state: &AgentState) { + let id = uuid::Uuid::new_v4().to_string(); + let json = serde_json::to_string(state).unwrap_or_default(); + let _ = sqlx::query( + "INSERT INTO agent_state_snapshots (id, workflow_id, step_order, state_json, created_at) VALUES (?, ?, ?, ?, datetime('now'))" + ) + .bind(&id) + .bind(workflow_id) + .bind(step_order) + .bind(&json) + .execute(pool) + .await; +} + +/// Log a tool call to execution_log and broadcast to frontend. +async fn log_execution( + pool: &SqlitePool, + broadcast_tx: &broadcast::Sender, + workflow_id: &str, + step_order: i32, + tool_name: &str, + tool_input: &str, + output: &str, + status: &str, +) { + let 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 (?, ?, ?, ?, ?, ?, ?, datetime('now'))" + ) + .bind(&id) + .bind(workflow_id) + .bind(step_order) + .bind(tool_name) + .bind(tool_input) + .bind(output) + .bind(status) + .execute(pool) + .await; + + let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { + step_id: id, + status: status.into(), + output: output.to_string(), + }); +} + +/// Process user feedback: call LLM to decide whether to revise the plan. +/// Returns the (possibly modified) AgentState ready for resumed execution. +async fn process_feedback( + llm: &LlmClient, + pool: &SqlitePool, + broadcast_tx: &broadcast::Sender, + project_id: &str, + workflow_id: &str, + mut state: AgentState, + feedback: &str, +) -> AgentState { + let prompt = build_feedback_prompt(project_id, &state, feedback); + let tools = build_feedback_tools(); + let messages = vec![ + ChatMessage::system(&prompt), + ChatMessage::user(feedback), + ]; + + tracing::info!("[workflow {}] Processing feedback with LLM", workflow_id); + let response = match llm.chat_with_tools(messages, &tools).await { + Ok(r) => r, + Err(e) => { + tracing::error!("[workflow {}] Feedback LLM call failed: {}", workflow_id, e); + // On failure, attach feedback to first non-done step and return unchanged + if let Some(step) = state.steps.iter_mut().find(|s| !matches!(s.status, StepStatus::Done)) { + step.user_feedbacks.push(feedback.to_string()); + } + return state; + } + }; + + let choice = match response.choices.into_iter().next() { + Some(c) => c, + None => return state, + }; + + if let Some(tool_calls) = &choice.message.tool_calls { + for tc in tool_calls { + if tc.function.name == "revise_plan" { + let args: serde_json::Value = serde_json::from_str(&tc.function.arguments).unwrap_or_default(); + let raw_steps = args["steps"].as_array().cloned().unwrap_or_default(); + + let new_steps: Vec = raw_steps.iter().enumerate().map(|(i, item)| { + let order = (i + 1) as i32; + Step { + order, + 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(), + } + }).collect(); + + // Apply docker-cache diff + state.apply_plan_diff(new_steps); + + // Broadcast updated plan + let _ = broadcast_tx.send(WsMessage::PlanUpdate { + workflow_id: workflow_id.to_string(), + steps: plan_infos_from_state(&state), + }); + + tracing::info!("[workflow {}] Plan revised via feedback. First actionable: {:?}", + workflow_id, state.first_actionable_step()); + } + } + } else { + // Text response only — feedback is informational, no plan change + let text = choice.message.content.as_deref().unwrap_or(""); + tracing::info!("[workflow {}] Feedback processed, no plan change: {}", workflow_id, truncate_str(text, 200)); + log_execution(pool, broadcast_tx, workflow_id, state.current_step(), "text_response", "", text, "done").await; + } + + // Attach feedback to the first actionable step (or last step) + let target_order = state.first_actionable_step() + .unwrap_or_else(|| state.steps.last().map(|s| s.order).unwrap_or(0)); + if let Some(step) = state.steps.iter_mut().find(|s| s.order == target_order) { + step.user_feedbacks.push(feedback.to_string()); + } + + // Snapshot after feedback processing + save_state_snapshot(pool, workflow_id, state.current_step(), &state).await; + + state +} + #[allow(clippy::too_many_arguments)] async fn run_agent_loop( llm: &LlmClient, @@ -854,47 +932,26 @@ async fn run_agent_loop( workdir: &str, mgr: &Arc, instructions: &str, + initial_state: Option, ) -> anyhow::Result<()> { let planning_tools = build_planning_tools(); let execution_tools = build_execution_tools(); - let mut state = AgentState { - phase: AgentPhase::Planning, - plan: Vec::new(), - step_summaries: Vec::new(), - step_messages: Vec::new(), - memo: String::new(), - log_step_order: sqlx::query_scalar::<_, i32>( - "SELECT COALESCE(MAX(step_order), 0) FROM plan_steps WHERE workflow_id = ?" - ) - .bind(workflow_id) - .fetch_one(pool) - .await - .unwrap_or(0), - }; + let mut state = initial_state.unwrap_or_else(AgentState::new); for iteration in 0..80 { // Build messages and select tools based on current phase - let (messages, tools) = match &state.phase { - AgentPhase::Planning => { - let mut msgs = vec![ - ChatMessage::system(&build_planning_prompt(project_id, instructions)), - ChatMessage::user(requirement), - ]; - msgs.extend(state.step_messages.clone()); - (msgs, &planning_tools) - } - AgentPhase::Executing { .. } => { - let step_ctx = build_step_context(&state, requirement); - let mut msgs = vec![ - ChatMessage::system(&build_execution_prompt(project_id, instructions)), - ChatMessage::user(&step_ctx), - ]; - msgs.extend(state.step_messages.clone()); - (msgs, &execution_tools) - } + let system_prompt = match &state.phase { + AgentPhase::Planning => build_planning_prompt(project_id, instructions), + AgentPhase::Executing { .. } => build_execution_prompt(project_id, instructions), AgentPhase::Completed => break, }; + let tools = match &state.phase { + AgentPhase::Planning => &planning_tools, + AgentPhase::Executing { .. } => &execution_tools, + AgentPhase::Completed => break, + }; + let messages = state.build_messages(&system_prompt, requirement); tracing::info!("[workflow {}] LLM call #{} phase={:?} msgs={}", workflow_id, iteration + 1, state.phase, messages.len()); let response = match llm.chat_with_tools(messages, tools).await { @@ -908,8 +965,8 @@ async fn run_agent_loop( let choice = response.choices.into_iter().next() .ok_or_else(|| anyhow::anyhow!("No response from LLM"))?; - // Add assistant message to step-local history - state.step_messages.push(choice.message.clone()); + // Add assistant message to chat history + state.current_step_chat_history.push(choice.message.clone()); if let Some(tool_calls) = &choice.message.tool_calls { tracing::info!("[workflow {}] Tool calls: {}", workflow_id, @@ -919,135 +976,94 @@ async fn run_agent_loop( for tc in tool_calls { if phase_transition { - // Give dummy results for remaining tool calls after a transition - state.step_messages.push(ChatMessage::tool_result(&tc.id, "(skipped: 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(); + let cur = state.current_step(); match tc.function.name.as_str() { "update_plan" => { let raw_steps = args["steps"].as_array().cloned().unwrap_or_default(); - // Clear old plan steps in DB - let _ = sqlx::query("DELETE FROM plan_steps WHERE workflow_id = ? AND kind = 'plan'") - .bind(workflow_id) - .execute(pool) - .await; - - let mut plan_infos = Vec::new(); - state.plan.clear(); + state.steps.clear(); for (i, item) in raw_steps.iter().enumerate() { - let sid = uuid::Uuid::new_v4().to_string(); 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(); - let _ = sqlx::query( - "INSERT INTO plan_steps (id, workflow_id, step_order, description, command, status, created_at, kind) VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'), 'plan')" - ) - .bind(&sid) - .bind(workflow_id) - .bind(order) - .bind(&title) - .bind(&detail) - .execute(pool) - .await; - - plan_infos.push(PlanStepInfo { order, description: title.clone(), command: detail.clone() }); - state.plan.push(PlanItem { + state.steps.push(Step { order, title, description: detail, - status: PlanItemStatus::Pending, - db_id: sid, + status: StepStatus::Pending, + summary: None, + user_feedbacks: Vec::new(), + db_id: String::new(), }); } + // Transition: Planning → Executing(1) + if let Some(first) = state.steps.first_mut() { + first.status = StepStatus::Running; + } + let _ = broadcast_tx.send(WsMessage::PlanUpdate { workflow_id: workflow_id.to_string(), - steps: plan_infos, + steps: plan_infos_from_state(&state), }); - // Transition: Planning → Executing(1) - if let Some(first) = state.plan.first_mut() { - first.status = PlanItemStatus::Running; - let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?") - .bind(&first.db_id) - .execute(pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: first.db_id.clone(), - status: "running".into(), - output: String::new(), - }); - } - - state.step_messages.clear(); + state.current_step_chat_history.clear(); state.phase = AgentPhase::Executing { step: 1 }; phase_transition = true; - tracing::info!("[workflow {}] Plan set ({} steps), entering Executing(1)", workflow_id, state.plan.len()); + + // Snapshot after plan is set + save_state_snapshot(pool, workflow_id, 0, &state).await; + tracing::info!("[workflow {}] Plan set ({} steps), entering Executing(1)", workflow_id, state.steps.len()); } "advance_step" => { let summary = args["summary"].as_str().unwrap_or("").to_string(); - let current_step = match &state.phase { - AgentPhase::Executing { step } => *step, - _ => 0, - }; - // Mark current step done - if let Some(item) = state.plan.iter_mut().find(|p| p.order == current_step) { - item.status = PlanItemStatus::Done; - let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") - .bind(&item.db_id) - .execute(pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: item.db_id.clone(), - status: "done".into(), - output: String::new(), - }); + // Mark current step done with summary + if let Some(step) = state.steps.iter_mut().find(|s| s.order == cur) { + step.status = StepStatus::Done; + step.summary = Some(summary); } - state.step_summaries.push(StepSummary { - order: current_step, - summary, - }); - // Move to next step or complete - let next_step = current_step + 1; - if let Some(next_item) = state.plan.iter_mut().find(|p| p.order == next_step) { - next_item.status = PlanItemStatus::Running; - let _ = sqlx::query("UPDATE plan_steps SET status = 'running' WHERE id = ?") - .bind(&next_item.db_id) - .execute(pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: next_item.db_id.clone(), - status: "running".into(), - output: String::new(), - }); - state.phase = AgentPhase::Executing { step: next_step }; - tracing::info!("[workflow {}] Advanced to step {}", workflow_id, next_step); + let next = cur + 1; + if let Some(next_step) = state.steps.iter_mut().find(|s| s.order == next) { + next_step.status = StepStatus::Running; + state.phase = AgentPhase::Executing { step: next }; + tracing::info!("[workflow {}] Advanced to step {}", workflow_id, next); } else { state.phase = AgentPhase::Completed; tracing::info!("[workflow {}] All steps completed", workflow_id); } - state.step_messages.clear(); + state.current_step_chat_history.clear(); phase_transition = true; + + // Broadcast step status change to frontend + let _ = broadcast_tx.send(WsMessage::PlanUpdate { + workflow_id: workflow_id.to_string(), + steps: plan_infos_from_state(&state), + }); + + // Snapshot on step transition + save_state_snapshot(pool, workflow_id, cur, &state).await; } - "save_memo" => { + "update_scratchpad" => { let content = args["content"].as_str().unwrap_or(""); - if !state.memo.is_empty() { - state.memo.push('\n'); + if !state.scratchpad.is_empty() { + state.scratchpad.push('\n'); } - state.memo.push_str(content); - state.step_messages.push(ChatMessage::tool_result(&tc.id, "备忘录已保存。")); + state.scratchpad.push_str(content); + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, "Scratchpad 已更新。")); } "update_requirement" => { @@ -1062,7 +1078,7 @@ async fn run_agent_loop( workflow_id: workflow_id.to_string(), requirement: new_req.to_string(), }); - state.step_messages.push(ChatMessage::tool_result(&tc.id, "需求已更新。")); + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, "需求已更新。")); } "start_service" => { @@ -1083,7 +1099,7 @@ async fn run_agent_loop( Ok(p) => format!("{}:{}", venv_bin, p), Err(_) => venv_bin, }; - match tokio::process::Command::new("sh") + let result = match tokio::process::Command::new("sh") .arg("-c") .arg(&cmd_with_port) .current_dir(workdir) @@ -1098,29 +1114,28 @@ async fn run_agent_loop( let pid = child.id().unwrap_or(0); mgr.services.write().await.insert(project_id.to_string(), ServiceInfo { port, pid }); tokio::time::sleep(std::time::Duration::from_secs(2)).await; - let url = format!("/api/projects/{}/app/", project_id); - state.step_messages.push(ChatMessage::tool_result( - &tc.id, - &format!("服务已启动,端口 {},访问地址:{}", port, url), - )); + format!("服务已启动,端口 {},访问地址:/api/projects/{}/app/", port, project_id) } - Err(e) => { - state.step_messages.push(ChatMessage::tool_result(&tc.id, &format!("启动失败:{}", e))); - } - } + Err(e) => format!("Error: 启动失败:{}", e), + }; + let status = if result.starts_with("Error:") { "failed" } else { "done" }; + log_execution(pool, broadcast_tx, workflow_id, cur, "start_service", cmd, &result, status).await; + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } "stop_service" => { let mut services = mgr.services.write().await; - if let Some(svc) = services.remove(project_id) { + 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, ); - state.step_messages.push(ChatMessage::tool_result(&tc.id, "服务已停止。")); + "服务已停止。".to_string() } else { - state.step_messages.push(ChatMessage::tool_result(&tc.id, "当前没有运行中的服务。")); - } + "当前没有运行中的服务。".to_string() + }; + log_execution(pool, broadcast_tx, workflow_id, cur, "stop_service", "", &result, "done").await; + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } "kb_search" => { @@ -1143,7 +1158,7 @@ async fn run_agent_loop( } else { "知识库未初始化。".to_string() }; - state.step_messages.push(ChatMessage::tool_result(&tc.id, &result)); + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } "kb_read" => { @@ -1156,71 +1171,19 @@ async fn run_agent_loop( } else { "知识库未初始化。".to_string() }; - state.step_messages.push(ChatMessage::tool_result(&tc.id, &result)); + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } // IO tools: execute, read_file, write_file, list_files _ => { - let current_plan_step_id = match &state.phase { - AgentPhase::Executing { step } => { - state.plan.iter().find(|p| p.order == *step).map(|p| p.db_id.clone()).unwrap_or_default() - } - _ => String::new(), - }; - - state.log_step_order += 1; - let step_id = uuid::Uuid::new_v4().to_string(); - - let description = match tc.function.name.as_str() { - "execute" => format!("$ {}", args["command"].as_str().unwrap_or("?")), - "read_file" => format!("Read: {}", args["path"].as_str().unwrap_or("?")), - "write_file" => format!("Write: {}", args["path"].as_str().unwrap_or("?")), - "list_files" => format!("List: {}", args["path"].as_str().unwrap_or(".")), - "kb_search" => format!("KB Search: {}", args["query"].as_str().unwrap_or("?")), - "kb_read" => "KB Read".to_string(), - other => other.to_string(), - }; - - let _ = sqlx::query( - "INSERT INTO plan_steps (id, workflow_id, step_order, description, command, status, created_at, plan_step_id) VALUES (?, ?, ?, ?, ?, 'running', datetime('now'), ?)" - ) - .bind(&step_id) - .bind(workflow_id) - .bind(state.log_step_order) - .bind(&description) - .bind(&tc.function.arguments) - .bind(¤t_plan_step_id) - .execute(pool) - .await; - - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: step_id.clone(), - status: "running".into(), - output: String::new(), - }); - let result = execute_tool(&tc.function.name, &tc.function.arguments, workdir, exec).await; let status = if result.starts_with("Error:") { "failed" } else { "done" }; - - let _ = sqlx::query("UPDATE plan_steps SET status = ?, output = ? WHERE id = ?") - .bind(status) - .bind(&result) - .bind(&step_id) - .execute(pool) - .await; - - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: step_id.clone(), - status: status.into(), - output: result.clone(), - }); - - state.step_messages.push(ChatMessage::tool_result(&tc.id, &result)); + log_execution(pool, broadcast_tx, workflow_id, cur, &tc.function.name, &tc.function.arguments, &result, status).await; + state.current_step_chat_history.push(ChatMessage::tool_result(&tc.id, &result)); } } } - // If phase transitioned, the step_messages were cleared; loop continues with fresh context if phase_transition { continue; } @@ -1229,29 +1192,11 @@ async fn run_agent_loop( let content = choice.message.content.as_deref().unwrap_or("(no content)"); tracing::info!("[workflow {}] LLM text response: {}", workflow_id, truncate_str(content, 200)); - match &state.phase { - AgentPhase::Executing { step } => { - // Text response during execution = current step done, workflow complete - let current_step = *step; - if let Some(item) = state.plan.iter_mut().find(|p| p.order == current_step) { - item.status = PlanItemStatus::Done; - let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") - .bind(&item.db_id) - .execute(pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: item.db_id.clone(), - status: "done".into(), - output: String::new(), - }); - } - state.phase = AgentPhase::Completed; - } - AgentPhase::Planning => { - // LLM might be thinking/analyzing before calling update_plan; continue - } - AgentPhase::Completed => break, - } + // Log text response to execution_log for frontend display + log_execution(pool, broadcast_tx, workflow_id, state.current_step(), "text_response", "", content, "done").await; + + // Text response does NOT end the workflow. Only advance_step progresses. + // In Planning phase, LLM may be thinking before calling update_plan — just continue. } if matches!(state.phase, AgentPhase::Completed) { @@ -1259,20 +1204,8 @@ async fn run_agent_loop( } } - // Cleanup: mark any remaining running steps as done - for item in &state.plan { - if matches!(item.status, PlanItemStatus::Running) { - let _ = sqlx::query("UPDATE plan_steps SET status = 'done' WHERE id = ?") - .bind(&item.db_id) - .execute(pool) - .await; - let _ = broadcast_tx.send(WsMessage::StepStatusUpdate { - step_id: item.db_id.clone(), - status: "done".into(), - output: String::new(), - }); - } - } + // Final snapshot + save_state_snapshot(pool, workflow_id, state.current_step(), &state).await; Ok(()) } @@ -1280,42 +1213,27 @@ async fn run_agent_loop( async fn generate_report( llm: &LlmClient, requirement: &str, - steps: &[crate::db::PlanStep], + entries: &[crate::db::ExecutionLogEntry], project_id: &str, ) -> anyhow::Result { - let steps_detail: String = steps + let steps_detail: String = entries .iter() - .map(|s| { - let output_preview = if s.output.len() > 2000 { - format!("{}...(truncated)", truncate_str(&s.output, 2000)) + .map(|e| { + let output_preview = if e.output.len() > 2000 { + format!("{}...(truncated)", truncate_str(&e.output, 2000)) } else { - s.output.clone() + e.output.clone() }; format!( - "### Step {} [{}]: {}\nCommand: `{}`\nOutput:\n```\n{}\n```\n", - s.step_order, s.status, s.description, s.command, output_preview + "### [{}] {} (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 = format!( - "你是一个技术报告撰写者。请生成一份简洁的 Markdown 报告,总结工作流的执行结果。\n\ - \n\ - 报告应包含:\n\ - 1. 标题和简要总结\n\ - 2. 关键结果和产出(从步骤输出中提取重要信息)\n\ - 3. 如果启动了 Web 应用/服务(start_service),在报告顶部醒目标出应用访问地址:`/api/projects/{0}/app/`\n\ - 4. 生成的文件(如果有),引用地址为:`/api/projects/{0}/files/{{{{filename}}}}`\n\ - 5. 遇到的问题(如果有步骤失败)\n\ - \n\ - 格式要求:\n\ - - 简洁明了,重点是结果而非过程\n\ - - 使用 Markdown 格式(标题、代码块、表格、列表)\n\ - - 需要可视化时,使用 ```mermaid 代码块绘制 Mermaid 图表\n\ - - 使用中文撰写", - project_id, - ); + let system_prompt = include_str!("prompts/report.md") + .replace("{project_id}", project_id); let user_msg = format!( "需求:\n{}\n\n执行详情:\n{}", diff --git a/src/api/workflows.rs b/src/api/workflows.rs index 83e793c..13ca917 100644 --- a/src/api/workflows.rs +++ b/src/api/workflows.rs @@ -9,7 +9,7 @@ use axum::{ use serde::Deserialize; use crate::AppState; use crate::agent::AgentEvent; -use crate::db::{Workflow, PlanStep, Comment}; +use crate::db::{Workflow, ExecutionLogEntry, Comment}; use super::{ApiResult, db_err}; #[derive(serde::Serialize)] @@ -77,9 +77,9 @@ async fn create_workflow( async fn list_steps( State(state): State>, Path(workflow_id): Path, -) -> ApiResult> { - sqlx::query_as::<_, PlanStep>( - "SELECT * FROM plan_steps WHERE workflow_id = ? ORDER BY step_order" +) -> ApiResult> { + sqlx::query_as::<_, ExecutionLogEntry>( + "SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at" ) .bind(&workflow_id) .fetch_all(&state.db.pool) diff --git a/src/db.rs b/src/db.rs index 83dbbcb..1bb91b1 100644 --- a/src/db.rs +++ b/src/db.rs @@ -41,20 +41,6 @@ impl Database { .execute(&self.pool) .await?; - sqlx::query( - "CREATE TABLE IF NOT EXISTS plan_steps ( - id TEXT PRIMARY KEY, - workflow_id TEXT NOT NULL REFERENCES workflows(id), - step_order INTEGER NOT NULL, - description TEXT NOT NULL, - command TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - output TEXT NOT NULL DEFAULT '' - )" - ) - .execute(&self.pool) - .await?; - sqlx::query( "CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, @@ -73,27 +59,6 @@ impl Database { .execute(&self.pool) .await; - // Migration: add created_at to plan_steps - let _ = sqlx::query( - "ALTER TABLE plan_steps ADD COLUMN created_at TEXT NOT NULL DEFAULT ''" - ) - .execute(&self.pool) - .await; - - // Migration: add kind to plan_steps ('plan' or 'log') - let _ = sqlx::query( - "ALTER TABLE plan_steps ADD COLUMN kind TEXT NOT NULL DEFAULT 'log'" - ) - .execute(&self.pool) - .await; - - // Migration: add plan_step_id to plan_steps (log entries reference their parent plan step) - let _ = sqlx::query( - "ALTER TABLE plan_steps ADD COLUMN plan_step_id TEXT NOT NULL DEFAULT ''" - ) - .execute(&self.pool) - .await; - // Migration: add deleted column to projects let _ = sqlx::query( "ALTER TABLE projects ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0" @@ -165,6 +130,34 @@ impl Database { .await; } + // New tables: agent_state_snapshots + execution_log + sqlx::query( + "CREATE TABLE IF NOT EXISTS agent_state_snapshots ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id), + step_order INTEGER NOT NULL, + state_json TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE TABLE IF NOT EXISTS execution_log ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id), + step_order INTEGER NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT NOT NULL DEFAULT '', + output TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'running', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )" + ) + .execute(&self.pool) + .await?; + sqlx::query( "CREATE TABLE IF NOT EXISTS timers ( id TEXT PRIMARY KEY, @@ -206,17 +199,15 @@ pub struct Workflow { } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] -pub struct PlanStep { +pub struct ExecutionLogEntry { pub id: String, pub workflow_id: String, pub step_order: i32, - pub description: String, - pub command: String, - pub status: String, + pub tool_name: String, + pub tool_input: String, pub output: String, + pub status: String, pub created_at: String, - pub kind: String, - pub plan_step_id: String, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/src/main.rs b/src/main.rs index 82dd362..c5bb03a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod db; mod kb; mod llm; mod exec; +mod state; mod timer; mod ws; diff --git a/src/prompts/execution.md b/src/prompts/execution.md new file mode 100644 index 0000000..a1f4b22 --- /dev/null +++ b/src/prompts/execution.md @@ -0,0 +1,25 @@ +你是一个 AI 智能体,正处于【执行阶段】。请专注完成当前步骤的任务。 + +可用工具: +- execute:执行 shell 命令 +- read_file / write_file / list_files:文件操作 +- start_service / stop_service:管理后台服务 +- update_requirement:更新项目需求 +- advance_step:完成当前步骤并进入下一步(必须提供摘要) +- update_scratchpad:保存跨步骤持久化的关键信息 + +工作流程: +1. 阅读下方的「当前步骤」描述 +2. 使用工具执行所需操作 +3. 完成后调用 advance_step(summary=...) 推进到下一步 +4. 最后一步完成后,直接回复简要总结(不调用工具)即可结束 + +环境信息: +- 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/) +- 使用 `uv add <包名>` 或 `pip install <包名>` 安装依赖 +- 静态文件访问:/api/projects/{project_id}/files/{filename} +- 后台服务访问:/api/projects/{project_id}/app/(启动命令需监听 0.0.0.0:$PORT) +- 【重要】应用通过反向代理访问,前端 HTML/JS 中的 fetch/XHR 请求必须使用相对路径(如 fetch('todos')),绝对不能用 / 开头的路径(如 fetch('/todos')),否则会 404 +- 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文 + +请使用中文回复。 diff --git a/src/prompts/feedback.md b/src/prompts/feedback.md new file mode 100644 index 0000000..b536468 --- /dev/null +++ b/src/prompts/feedback.md @@ -0,0 +1,32 @@ +# 用户反馈处理 + +你是项目 `{project_id}` 的 AI 执行引擎。用户对当前执行计划提交了反馈。 + +## 你的任务 + +1. 分析用户反馈的意图 +2. 决定是否需要修改计划 + +## 当前计划 + +{plan_state} + +## 用户反馈 + +{feedback} + +## 工具 + +- **revise_plan**:修改执行计划。提供完整的步骤列表(包括不需要修改的步骤)。 + - 已完成且不需要重做的步骤:保持 title 和 description 不变 + - 需要重做的步骤:修改 description 以反映新需求 + - 系统自动处理缓存:description 未变的已完成步骤保留成果,**第一个 description 变化的步骤及其后续所有步骤**会重新执行 + - 你也可以增删步骤 + +- 如果反馈只是补充信息、不需要改计划,直接用文字回复即可(不调用工具) + +## 规则 + +- 不要为了强制重跑而无意义地改 description。只在执行内容真正需要调整时才改 +- 可以在 description 中融入反馈信息,让执行步骤能看到用户的补充说明 +- 如果用户的反馈改变了整体方向,大胆重新规划 diff --git a/src/prompts/planning.md b/src/prompts/planning.md new file mode 100644 index 0000000..010efb5 --- /dev/null +++ b/src/prompts/planning.md @@ -0,0 +1,28 @@ +你是一个 AI 智能体,正处于【规划阶段】。你拥有一个独立的工作区目录。 + +你的任务: +1. 仔细分析用户的需求 +2. 使用 list_files 和 read_file 检查工作区的现有状态 +3. 制定一个高层执行计划,调用 update_plan 提交 + +计划要求: +- 每个步骤应是一个逻辑阶段(如"搭建环境"、"实现后端 API"),而非具体命令 +- 每个步骤包含简短标题和详细描述 +- 步骤数量合理(通常 3-8 步) + +调用 update_plan 后,系统会自动进入执行阶段。 + +环境信息: +- 工作目录是独立的项目工作区,Python venv 已预先激活(.venv/) +- 可用工具:bash、git、curl、uv +- 静态文件访问:/api/projects/{project_id}/files/{filename} +- 后台服务访问:/api/projects/{project_id}/app/(反向代理,路径会被转发到应用的 /) + +【重要】反向代理注意事项: +- 用户通过 /api/projects/{project_id}/app/ 访问应用,请求被代理到应用的 / 路径 +- 因此前端 HTML 中的所有 API 请求必须使用【不带开头 / 的相对路径】 +- 正确示例:fetch('todos') 或 fetch('./todos') 错误示例:fetch('/todos') 或 fetch('/api/todos') +- HTML 中的 标签不需要设置,只要不用绝对路径就行 +- 知识库工具:kb_search(query) 搜索相关片段,kb_read() 读取全文 + +请使用中文回复。 diff --git a/src/prompts/report.md b/src/prompts/report.md new file mode 100644 index 0000000..8a37792 --- /dev/null +++ b/src/prompts/report.md @@ -0,0 +1,14 @@ +你是一个技术报告撰写者。请生成一份简洁的 Markdown 报告,总结工作流的执行结果。 + +报告应包含: +1. 标题和简要总结 +2. 关键结果和产出(从步骤输出中提取重要信息) +3. 如果启动了 Web 应用/服务(start_service),在报告顶部醒目标出应用访问地址:`/api/projects/{project_id}/app/` +4. 生成的文件(如果有),引用地址为:`/api/projects/{project_id}/files/{filename}` +5. 遇到的问题(如果有步骤失败) + +格式要求: +- 简洁明了,重点是结果而非过程 +- 使用 Markdown 格式(标题、代码块、表格、列表) +- 需要可视化时,使用 ```mermaid 代码块绘制 Mermaid 图表 +- 使用中文撰写 diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..a7aba58 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,205 @@ +use serde::{Deserialize, Serialize}; + +use crate::llm::ChatMessage; + +// --- Agent phase state machine --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentPhase { + Planning, + Executing { step: i32 }, + Completed, +} + +// --- Step --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Pending, + Running, + Done, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Step { + pub order: i32, + pub title: String, + pub description: String, + pub status: StepStatus, + /// 完成后由 LLM 填入的一句话摘要 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// 用户针对此步骤的反馈 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub user_feedbacks: Vec, + #[serde(default)] + pub db_id: String, +} + +// --- Core state --- + +/// Agent 运行时的完整状态。整个结构体可以 JSON 序列化后直接存 DB。 +/// +/// 同时也是构建 LLM API call messages 的数据源: +/// +/// Planning 阶段: +/// [ system(planning_prompt), user(requirement), ...current_step_chat_history ] +/// +/// Executing 阶段: +/// [ system(execution_prompt), user(step_context), ...current_step_chat_history ] +/// +/// step_context = requirement + plan 概览 + 当前步骤详情 + 已完成摘要 + scratchpad +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentState { + /// 当前阶段 + pub phase: AgentPhase, + /// LLM 生成的执行计划 + pub steps: Vec, + /// 当前步骤内的多轮对话历史(assistant + tool result), + /// 直接 extend 到 messages 尾部。在 step 切换时 clear。 + pub current_step_chat_history: Vec, + /// LLM 的跨步骤工作区,由 agent 自己读写,step 切换时保留 + pub scratchpad: String, +} + +impl AgentState { + pub fn new() -> Self { + Self { + phase: AgentPhase::Planning, + steps: Vec::new(), + current_step_chat_history: Vec::new(), + scratchpad: String::new(), + } + } + + /// 当前正在执行的步骤号,Planning/Completed 时返回 0。 + pub fn current_step(&self) -> i32 { + match &self.phase { + AgentPhase::Executing { step } => *step, + _ => 0, + } + } + + /// Docker-build-cache 风格的 plan diff。 + /// 比较 (title, description),user_feedbacks 不参与比较。 + /// 第一个 mismatch 开始,该步骤及后续全部 invalidate → Pending。 + pub fn apply_plan_diff(&mut self, new_steps: Vec) { + let old = &self.steps; + let mut result = Vec::new(); + let mut invalidated = false; + + for (i, new) in new_steps.into_iter().enumerate() { + if !invalidated { + if let Some(old_step) = old.get(i) { + if old_step.title == new.title && old_step.description == new.description { + // Cache hit: keep old status/summary, take new user_feedbacks + result.push(Step { + user_feedbacks: new.user_feedbacks, + ..old_step.clone() + }); + continue; + } + } + // Cache miss or new step — invalidate from here + invalidated = true; + } + result.push(Step { + status: StepStatus::Pending, + summary: None, + ..new + }); + } + + self.steps = result; + } + + /// 找到第一个需要执行的步骤 (Pending 或 Running)。 + /// 全部 Done 时返回 None。 + pub fn first_actionable_step(&self) -> Option { + self.steps.iter() + .find(|s| matches!(s.status, StepStatus::Pending | StepStatus::Running)) + .map(|s| s.order) + } + + /// 构建 Executing 阶段的 user message: + /// requirement + plan 概览 + 当前步骤详情 + 已完成摘要 + scratchpad + pub fn build_step_context(&self, requirement: &str) -> String { + let mut ctx = String::new(); + + // 需求 + ctx.push_str("## 需求\n"); + ctx.push_str(requirement); + ctx.push_str("\n\n"); + + // 计划概览 + ctx.push_str("## 计划概览\n"); + let cur = self.current_step(); + for s in &self.steps { + let marker = match s.status { + StepStatus::Done => " done", + StepStatus::Running => " >> current", + StepStatus::Failed => " FAILED", + StepStatus::Pending => "", + }; + ctx.push_str(&format!("{}. {}{}\n", s.order, s.title, marker)); + } + ctx.push('\n'); + + // 当前步骤详情 + if let Some(s) = self.steps.iter().find(|s| s.order == cur) { + ctx.push_str(&format!("## 当前步骤(步骤 {})\n", cur)); + ctx.push_str(&format!("标题:{}\n", s.title)); + ctx.push_str(&format!("描述:{}\n", s.description)); + if !s.user_feedbacks.is_empty() { + ctx.push_str("\n用户反馈:\n"); + for fb in &s.user_feedbacks { + ctx.push_str(&format!("- {}\n", fb)); + } + } + ctx.push('\n'); + } + + // 已完成步骤摘要 + let done: Vec<_> = self.steps.iter() + .filter(|s| matches!(s.status, StepStatus::Done)) + .collect(); + if !done.is_empty() { + ctx.push_str("## 已完成步骤摘要\n"); + for s in done { + let summary = s.summary.as_deref().unwrap_or("(no summary)"); + ctx.push_str(&format!("- 步骤 {}: {}\n", s.order, summary)); + } + ctx.push('\n'); + } + + // 备忘录 + if !self.scratchpad.is_empty() { + ctx.push_str("## 备忘录\n"); + ctx.push_str(&self.scratchpad); + ctx.push('\n'); + } + + ctx + } + + /// 构建传给 LLM 的完整 messages 数组。 + pub fn build_messages(&self, system_prompt: &str, requirement: &str) -> Vec { + let mut msgs = vec![ChatMessage::system(system_prompt)]; + + match &self.phase { + AgentPhase::Planning => { + msgs.push(ChatMessage::user(requirement)); + } + AgentPhase::Executing { .. } => { + msgs.push(ChatMessage::user(&self.build_step_context(requirement))); + } + AgentPhase::Completed => {} + } + + msgs.extend(self.current_step_chat_history.clone()); + msgs + } +} diff --git a/web/src/api.ts b/web/src/api.ts index 75c4c94..6f9e11c 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,4 +1,4 @@ -import type { Project, Workflow, PlanStep, Comment, Timer, KbArticle, KbArticleSummary } from './types' +import type { Project, Workflow, ExecutionLogEntry, Comment, Timer, KbArticle, KbArticleSummary } from './types' const BASE = '/api' @@ -44,7 +44,7 @@ export const api = { }), listSteps: (workflowId: string) => - request(`/workflows/${workflowId}/steps`), + request(`/workflows/${workflowId}/steps`), listComments: (workflowId: string) => request(`/workflows/${workflowId}/comments`), diff --git a/web/src/components/ExecutionSection.vue b/web/src/components/ExecutionSection.vue index 4e70056..cd6c0fb 100644 --- a/web/src/components/ExecutionSection.vue +++ b/web/src/components/ExecutionSection.vue @@ -1,10 +1,9 @@