diff --git a/.gitignore b/.gitignore
index 4a502fb..fd6bdf4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,6 @@ bin/
.code-bee/
# Build output
-worker
+/worker
+
+.trae/
diff --git a/.trae/documents/runtime-scheduler-design.md b/.trae/documents/runtime-scheduler-design.md
new file mode 100644
index 0000000..b5dbc6c
--- /dev/null
+++ b/.trae/documents/runtime-scheduler-design.md
@@ -0,0 +1,255 @@
+# Runtime 调度器设计与实现计划
+
+## Context
+
+### 问题
+code-bee 当前的调度策略硬编码在 `internal/pipeline/dispatch.go` 中:
+- 角色固定(`BuiltinAgents()` 写死 6 个角色)
+- 流程固定(`runCodingReviewLoop` 写死 coding→review→judge 的 3 轮循环)
+- Loop 次数固定(`maxCodingReviewRounds=3`、`maxConsecutiveUnknownReview=2`)
+- Prompt 模板固定(`prompts.go` 5 个 builder 函数,参数位置敏感)
+
+这导致非标准场景(无审查流、需要安全审查、不同角色分工等)无法适配。Issue #11 提出应让调度策略由场景驱动、外部加载。
+
+### 已完成
+- `internal/schema/`:三层 YAML Schema(tool/stage/pipeline_step/loop/loop_judge/workflow)+ 配置加载器 + 语义校验
+- 工具别名支持(name + display_name + aliases)
+- 三种编排原语(stage/parallel/loop)的 Schema 定义
+
+### 本次目标
+实现 Runtime 调度器,基于 `*schema.Workflow` 配置驱动执行:
+1. 新建 `internal/runtime/` 包,实现通用执行引擎
+2. **直接替换** `dispatch.go` 的硬编码逻辑(不共存)
+3. **同步外部化** prompt 模板(从 `prompts.go` 抽到模板文件)
+4. 复用现有文件契约(`.code-bee/runs/...`)和 `agent.Runner`
+
+## 设计决策
+
+| 决策点 | 选择 | 理由 |
+|--------|------|------|
+| 与 dispatch.go 关系 | 直接替换 | 彻底统一,避免两套代码维护 |
+| Prompt 模板 | 同步外部化 | 一步到位,避免临时方案 |
+| 文件契约 | 复用现有 | agent 端零改动 |
+| Tool 抽象 | 统一 Agent/Command/Function | 智能体本质是工具 |
+| 状态收敛 | StepResult.Status 单字段 | 替代散落的 BlockedStatus/Passed/Unknown |
+
+## 包结构
+
+```
+internal/runtime/
+├── doc.go # 包注释
+├── tool.go # Tool 接口 + StepResult + Invocation
+├── tool_registry.go # ToolRegistry: 由 *schema.Workflow 构建
+├── agent_tool.go # AgentTool: 包装 agent.Runner
+├── command_tool.go # CommandTool: exec.CommandContext
+├── context.go # ExecutionContext + LoopState
+├── expression.go # when / exit_when 表达式求值
+├── executor.go # Executor 接口
+├── stage_executor.go # StageExecutor
+├── parallel_executor.go # ParallelExecutor (errgroup)
+├── loop_executor.go # LoopExecutor
+├── engine.go # Engine: 顶层入口
+├── prompt_builder.go # PromptBuilder 接口 + 模板加载
+└── *_test.go # 单元测试
+
+internal/pipeline/
+├── contracts.go # 保留(Result 结构 + 状态枚举)
+├── artifacts.go # 保留(ArtifactSet)
+├── dispatch.go # 重构:Service.Dispatch 调用 runtime.Engine
+├── prompts.go # 删除(模板移到 prompts/ 目录)
+└── history.go # 保留(LoopHistory)
+
+prompts/ # 新增:外部化 prompt 模板
+├── coding.md
+├── review.md
+├── issue-handling.md
+├── issue-post.md
+└── loop-judge.md
+```
+
+## 核心接口
+
+### Tool 接口
+
+```go
+type Tool interface {
+ Definition() *schema.Tool
+ Execute(ctx context.Context, inv Invocation) (*StepResult, error)
+}
+
+type StepResult struct {
+ StageName string
+ Success bool
+ Blocked bool
+ Output string
+ Data map[string]any // 从 result file 加载的结构化结果
+ Status string // = Data["status"],统一状态字段
+ Skipped bool // when=false 时
+}
+```
+
+### Executor 接口
+
+```go
+type Executor interface {
+ Execute(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error)
+}
+```
+
+### Engine 顶层
+
+```go
+func New(wf *schema.Workflow, deps Dependencies) (*Engine, error)
+
+func (e *Engine) Run(ctx context.Context) (*pipeline.Result, error)
+```
+
+## 实现步骤
+
+### 阶段 1:Prompt 模板外部化
+
+**目标**:把 `prompts.go` 的 5 个 builder 抽到 `prompts/` 目录的模板文件
+
+**改动**:
+1. 创建 `prompts/` 目录,写 5 个 `.md` 模板文件(用 Go `text/template` 语法)
+2. `internal/runtime/prompt_builder.go`:实现 PromptBuilder,从文件加载模板渲染
+3. 删除 `internal/pipeline/prompts.go`
+4. 调整 `dispatch.go` 中对 `buildXxxPrompt` 的调用(临时保留,阶段 5 会删)
+
+**关键**:模板文件用 `{{.WorkerID}}`、`{{.IssueURL}}`、`{{.Round}}` 等命名参数,替代位置敏感的 `%s/%d`
+
+### 阶段 2:Runtime 核心数据结构(P1)
+
+**文件**:`tool.go`、`tool_registry.go`、`context.go`
+
+**内容**:
+- `Tool` 接口、`StepResult`、`Invocation`、`PlatformContext`
+- `ToolRegistry`:由 `*schema.Workflow` 构建,支持 name + alias 查找(复用 `Workflow.FindTool`)
+- `ExecutionContext`:`stages map[string]*StepResult` + `loopStack []*LoopState`
+- `LoopState`:`Round`、`MaxIterations`、`ConsecutiveUnknown`、`LastFeedback`
+- `Lookup(stageName, fieldPath)` 支持 dot path 嵌套取值
+- `Snapshot()` / `Merge()` 用于 parallel 写隔离
+
+### 阶段 3:表达式求值(P2)
+
+**文件**:`expression.go`
+
+**内容**:
+- 结构化 `ExitCondition` 求值:6 个 operator(equals/not_equals/in/contains/exists/regex)
+- 字符串表达式最小解析器:支持 `round >= 2`、`review.status == "PASS"` 等
+- 复用 `ExecutionContext.Lookup` 取值
+
+### 阶段 4:三种 Tool 实现(P3)
+
+**文件**:`agent_tool.go`、`command_tool.go`、`prompt_builder.go`
+
+**内容**:
+- `AgentTool`:包装 `agent.Runner`,调 `runner.Run(ctx, kind, prompt)` → 加载 result file → `StepResult`
+- `CommandTool`:`exec.CommandContext("sh", "-c", run)` → 解析 stdout JSON → `StepResult`
+- `PromptBuilder`:从 `prompts/` 加载模板,用 `Invocation` 渲染
+- `mapTemplateToKind`:`coding→TaskKindCoding` 等映射表
+
+### 阶段 5:StageExecutor(P4)
+
+**文件**:`stage_executor.go`
+
+**流程**:
+1. `registry.Resolve(stage.Tool)` → Tool
+2. 求 `stage.When`,false 则返回 `Skipped`
+3. `ec.Lookup(stage.InputFrom, "")` 拿上游数据
+4. `tool.Execute(ctx, inv)` → `StepResult`
+5. `ec.SetResult(stage.Name, result)`
+6. 处理 `on_blocked`:stop/skip/continue
+
+### 阶段 6:ParallelExecutor(P5)
+
+**文件**:`parallel_executor.go`
+
+**流程**:
+1. `errgroup.WithContext(ctx)` 并发执行分支
+2. 每分支用 `ec.CloneForBranch()` 写隔离
+3. Join 策略:
+ - `all`:全成功才成功
+ - `any`:任一成功即成功
+ - `first_success`:首个成功后 `context.WithCancel` 取消其他
+4. 成功分支结果 `Merge` 回主 ec
+
+### 阶段 7:LoopExecutor(P6)
+
+**文件**:`loop_executor.go`
+
+**流程**:
+```
+for round := 1; round <= maxIterations; round++ {
+ 执行 body 中每个 step
+ 每步后检查 exit_when(命中则退出)
+ 维护 ConsecutiveUnknown(超限则 ManualRequired 退出)
+ 若配了 judge 且 round >= startRound:执行 judge
+ STOP_MANUAL → ManualRequired 退出
+ STOP_BLOCKED → Blocked 退出
+ SHRINK_TASK → 注入反馈继续
+ CONTINUE → 继续
+}
+跑完 maxIterations → ManualRequired
+```
+
+### 阶段 8:Engine + 重构 dispatch.go(P7)
+
+**文件**:`engine.go`、`dispatch.go`(重构)
+
+**改动**:
+1. `runtime.Engine.Run(ctx)`:遍历 `wf.Pipeline`,`pickExecutor` 分发执行
+2. 重构 `pipeline.Service`:
+ - `Dispatch(ctx, cfg)` 改为调用 `runtime.Engine`
+ - 删除 `runCodingReviewLoop` / `runIssueHandling` / `runCodingRound` 等硬编码函数
+ - 保留 `contracts.go`(Result 结构)、`artifacts.go`(ArtifactSet)、`history.go`(LoopHistory)
+3. `cmd/worker/main.go`:加载 workflow.yaml → 构造 Engine → 调用 Service
+4. 调整现有测试:`dispatch_test.go`、`loop_judge_test.go`、`coverage_test.go` 改为基于 workflow 配置测试
+
+## 测试策略
+
+### 单元测试(runtime 包)
+- `tool_test.go`:AgentTool/CommandTool,用 scriptedRunner + fakeExec mock
+- `context_test.go`:Lookup 嵌套字段、Snapshot/Merge 隔离、LoopStack
+- `expression_test.go`:6 operator + 字符串表达式全覆盖
+- `stage_executor_test.go`:when 跳过、on_blocked 三策略、input_from 注入
+- `parallel_executor_test.go`:all/any/first_success + cancel 行为
+- `loop_executor_test.go`:exit_when 命中、judge 四种决策、max_consecutive_unknown、max_iterations、嵌套 loop
+
+### 集成测试
+- 用 `schema/testdata/valid/loop_with_judge.yaml` 端到端跑通
+- 用 `parallel_pipeline.yaml`、`nested_loop.yaml` 验证编排原语
+
+### 重构后的 pipeline 测试
+- `dispatch_test.go` 改为:加载 workflow.yaml → Engine.Run → 断言 Result
+- 保持原有测试覆盖意图(blocked/manual/completion 等场景)
+
+## 验证方法
+
+1. **单元测试**:`go test ./internal/runtime/... -v -cover`,覆盖率 >= 80%
+2. **集成测试**:`go test ./internal/pipeline/... -v`,原有场景全部通过
+3. **全项目测试**:`go test ./...` 全绿
+4. **构建验证**:`go build ./...` 无错误
+5. **Lint**:`go vet ./...` 无警告
+
+## 关键文件
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `internal/runtime/*.go` | 新增 | 完整调度引擎 |
+| `internal/pipeline/dispatch.go` | 重构 | 删除硬编码 loop,改调 runtime.Engine |
+| `internal/pipeline/prompts.go` | 删除 | 模板移到 `prompts/` 目录 |
+| `internal/pipeline/contracts.go` | 保留 | Result 结构 + 状态枚举 |
+| `internal/pipeline/artifacts.go` | 保留 | ArtifactSet 复用 |
+| `prompts/*.md` | 新增 | 5 个外部化 prompt 模板 |
+| `cmd/worker/main.go` | 修改 | 加载 workflow.yaml |
+| `internal/pipeline/dispatch_test.go` | 调整 | 改为基于 workflow 配置测试 |
+
+## 风险与缓解
+
+| 风险 | 缓解 |
+|------|------|
+| 重构破坏现有测试 | 分阶段提交,每阶段测试通过再继续 |
+| Prompt 模板外部化改变行为 | 保持模板内容与原 prompts.go 等价,逐字对照 |
+| Loop 行为不一致 | 保留 LoopHistory 持久化逻辑,状态迁移完整 |
+| 文件契约破坏 | 复用 ArtifactSet,agent 端零改动 |
diff --git a/README.md b/README.md
index c0010d6..9b82b1a 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
- **Issue 即任务面板** —— 不需要额外工具,GitHub Issue 就是你的任务系统
- **@ mention 即调度** —— `@agent-frontend` 重构组件、`@agent-backend` 修 bug,像 @ 同事一样自然
- **零侵入** —— code-bee 本身不触碰你的代码,所有操作由智能体独立完成
-- **极简内核** —— 不到 100 行核心逻辑,易于理解、审计和贡献
+- **场景驱动调度** —— 角色体系和管线流程由 YAML workflow 配置驱动,不再硬编码;内置默认开箱即用,`--workflow` 支持完全自定义
---
@@ -27,12 +27,216 @@
|------|------|------|
| 🧠 编码智能体 | [Reasonix](https://github.com/tryreasonix/reasonix) × [DeepSeek](https://www.deepseek.com/) | 接收任务指令,自主完成读 Issue → 编码 → 提 PR 全流程 |
| 🔧 Git 操作 | [GitHub CLI (`gh`)](https://cli.github.com/) | 智能体通过 `gh` 完成 fork、clone、commit、push、create PR |
-| 🏗️ 调度器 | **code-bee** (Go 1.23+) | 解析 Issue 中的 @agent,生成任务指令,交给智能体执行 |
+| 🏗️ 调度器 | **code-bee** (Go 1.23+) | 解析 Issue 中的 @agent,按 workflow 配置编排 stage/parallel/loop,驱动智能体执行 |
| ✅ 代码校验 | [golangci-lint](https://golangci-lint.run/) + [conform](https://github.com/talos-systems/conform) | 静态分析 + 目录结构校验 |
### 架构一览
-
+code-bee 采用 **四层分层架构**:CLI 入口 → pipeline 薄封装 → runtime 核心引擎 → schema 配置定义。调度策略完全由 `workflow.yaml` 驱动,runtime.Engine 遍历 Pipeline 并按终止语义决定提前返回或继续。
+
+#### 分层组件架构
+
+```mermaid
+flowchart TB
+ subgraph CLI["cmd/worker · CLI 入口"]
+ main["main() → runCLI()"]
+ build["buildService()
parseExtraFlags + loadWorkflow"]
+ report["reportDispatchResult()
Result → 退出码"]
+ end
+
+ subgraph Schema["internal/schema · 配置定义"]
+ loader["Loader
YAML + 三层 JSON Schema 校验"]
+ types["Workflow / Tool / Stage
Loop / Condition / LoopJudge"]
+ end
+
+ subgraph Pipe["internal/pipeline · 薄封装层"]
+ svc["Service
持有 *Engine + *Workflow"]
+ arts["ArtifactSet
文件契约目录"]
+ adapt["ArtifactResolverAdapter
ArtifactSet ⇄ ArtifactResolver"]
+ contracts["contracts.go
typed result loaders"]
+ end
+
+ subgraph RT["internal/runtime · 核心引擎"]
+ eng["Engine
遍历 Pipeline + 终止语义"]
+ reg["ToolRegistry
name/alias → Tool"]
+ pb["PromptBuilder
embed 内置 + overlay 外部"]
+ ec["ExecutionContext
stages map + loopStack"]
+ se["StageExecutor"]
+ pe["ParallelExecutor
fork-join 写隔离"]
+ le["LoopExecutor
max_iter / exit_when / judge"]
+ at["AgentTool
render + runner.Run"]
+ ct["CommandTool
exec.CommandContext"]
+ ft["FunctionTool (桩)"]
+ arIface["«interface»
ArtifactResolver"]
+ runnerIface["«interface»
Runner"]
+ end
+
+ subgraph Ext["外部依赖"]
+ agentRunner["agent.Runner
Reasonix × DeepSeek"]
+ platformClient["platform.Client
GitHub"]
+ end
+
+ main --> build
+ build -->|"加载 workflow"| loader
+ loader --> types
+ build --> svc
+ main -->|"Dispatch(ctx, cfg)"| svc
+ platformClient --> svc
+ svc --> eng
+ svc --> arts
+ arts --> adapt
+ eng --> ec
+ eng --> se
+ eng --> pe
+ eng --> le
+ se --> reg
+ reg --> at
+ reg --> ct
+ reg --> ft
+ at --> pb
+ at --> runnerIface
+ runnerIface -.->|"实现"| agentRunner
+ at -->|"LoadResult"| arIface
+ arIface -.->|"实现"| adapt
+ adapt --> contracts
+ svc -->|"EngineResult → Result"| report
+```
+
+**分层职责**:
+
+| 层 | 包 | 职责 |
+|----|----|------|
+| CLI | `cmd/worker` | flag 解析、workflow 加载、构造 Service、退出码映射 |
+| pipeline | `internal/pipeline` | 薄封装层:持有 Engine、管理文件契约目录、typed 结果校验 |
+| runtime | `internal/runtime` | 核心引擎:编排原语执行器、工具注册表、prompt 渲染、上下文管理 |
+| schema | `internal/schema` | 配置定义:YAML 加载 + 三层 JSON Schema 语义校验 |
+
+**关键解耦**:runtime 包通过 `ArtifactResolver` 和 `Runner` 两个接口与外部解耦——pipeline 层用 `ArtifactResolverAdapter` 桥接文件契约,CLI 注入真实的 `agent.Runner` 实现。runtime 不依赖 pipeline(避免循环依赖)。
+
+#### Dispatch 调用链路
+
+下图展示单次 `Dispatch` 从 CLI 到智能体执行的完整调用链,含结果文件读写时序:
+
+```mermaid
+sequenceDiagram
+ participant CLI as runCLI
+ participant Svc as Service.Dispatch
+ participant Eng as Engine.Run
+ participant SE as StageExecutor
+ participant Tool as AgentTool
+ participant PB as PromptBuilder
+ participant Runner as agent.Runner
+ participant AR as ArtifactResolver
+ participant FS as 文件系统
+
+ CLI->>Svc: Dispatch(ctx, cfg)
+ Svc->>Svc: 创建 ArtifactSet + PlatformContext
+ Svc->>Svc: 包装 ArtifactResolverAdapter
+ Svc->>Eng: Run(deps{Artifacts, Platform, Config})
+ Eng->>Eng: NewExecutionContext
+ loop 遍历 workflow.Pipeline
+ Eng->>SE: Execute(step, ec)
+ SE->>SE: 求值 when 条件
+ SE->>SE: registry.Resolve(tool)
+ SE->>AR: ResolveResultFile(stageName)
+ AR-->>SE: resultFilePath
+ SE->>FS: ResetResultFile(删除旧文件)
+ SE->>SE: ec.Lookup(input_from)
+ SE->>Tool: Execute(Invocation)
+ Tool->>PB: Build(promptTemplate, data)
+ PB-->>Tool: rendered prompt
+ Tool->>Runner: Run(ctx, kind, prompt)
+ Runner-->>Tool: RunResult{Output}
+ Tool->>AR: LoadResult(stageName, path)
+ AR->>FS: 读取结果文件
+ AR-->>Tool: map[string]any
+ Tool->>Tool: extractStatus → StepResult
+ Tool-->>SE: StepResult
+ SE->>SE: ec.SetResult
+ SE-->>Eng: StepResult
+ alt Blocked / ManualRequired / Completed
+ Eng-->>Svc: 提前返回 EngineResult
+ end
+ end
+ Eng-->>Svc: EngineResult
+ Svc-->>CLI: pipeline.Result
+```
+
+**关键时序点**:
+1. **StageExecutor 先 reset 结果文件**——防止读到上一轮残留数据
+2. **AgentTool 渲染 prompt 后才调 runner**——prompt 数据由 `buildPromptData` 从 ExecutionContext 派生
+3. **结果文件是 stage 间通信媒介**——`ec.SetResult` 存内存快照供 `when`/`exit_when` 求值,`LoadResult` 读磁盘文件做 typed 校验
+4. **终止语义即时生效**——任一 stage 返回 `Blocked`/`ManualRequired`/`Completed`,Engine 立即跳出 Pipeline 循环返回
+
+#### 配置加载与数据流
+
+下图展示 workflow + prompt 模板的加载链,以及结果文件在 stage 间的流转:
+
+```mermaid
+flowchart LR
+ subgraph 配置加载["配置加载(启动时一次性)"]
+ direction TB
+ embedWf["default_workflow.yaml
(go:embed)"]
+ extWf["--workflow xxx.yaml"]
+ loader["schema.Loader
YAML + Schema 校验"]
+ wf["*schema.Workflow"]
+
+ embedP["prompts/*.md
(go:embed, 5 个内置)"]
+ extP["--prompts-dir ./dir/"]
+ pb["PromptBuilder
overlay 同名覆盖"]
+ templates["templates map"]
+
+ embedWf --> loader
+ extWf --> loader
+ loader --> wf
+ embedP --> pb
+ extP --> pb
+ pb --> templates
+ end
+
+ subgraph 运行时["运行时数据流(Dispatch 时)"]
+ direction TB
+ cfg["Config
Repo/Issue"]
+ arts["ArtifactSet
.code-bee/runs/repo/issue-N/"]
+ ec["ExecutionContext
stages map"]
+
+ s1["stage: issue-handling
→ issue_intake_result.json"]
+ s2["stage: coding
→ coding_result.json"]
+ s3["stage: review
→ review_result.json"]
+
+ cfg --> arts
+ s1 -->|"写入"| arts
+ arts -->|"读取"| ec
+ ec -->|"input_from"| s2
+ s2 -->|"写入"| arts
+ arts -->|"读取"| ec
+ ec -->|"input_from"| s3
+ s3 -->|"写入"| arts
+ end
+
+ wf --> ec
+ templates -->|"渲染 prompt"| s1
+ templates -->|"渲染 prompt"| s2
+ templates -->|"渲染 prompt"| s3
+```
+
+**配置加载语义**:
+- **workflow 双源**:`go:embed` 内置 `default_workflow.yaml` 作为兜底,`--workflow` 指定外部 YAML 完全替换(非 overlay)
+- **prompt overlay**:内置 5 个模板(issue-handling/coding/review/issue-post/loop-judge),`--prompts-dir` 目录下同名 `.md` 文件**覆盖**内置,未覆盖的仍用内置(用户只需写想改的)
+- **结果文件流转**:每个 stage 把结构化 JSON 写入 ArtifactSet 目录,下游 stage 通过 `input_from` 经 ExecutionContext 读取,`when`/`exit_when` 条件也基于这些字段求值
+
+#### Engine 终止语义
+
+Engine 遍历 Pipeline 时,根据 `StepResult` 的状态字段决定是否提前返回:
+
+| StepResult 字段 | Engine 行为 | EngineResult |
+|----------------|------------|--------------|
+| `Blocked = true` | 立即终止 | `{Success: true, Blocked: true}` |
+| `ManualRequired = true` | 立即终止 | `{Success: true, ManualRequired: true}` |
+| `Completed = true` | 立即终止 | `{Success: true, Completed: true}` |
+| Pipeline 走完无终止 | 正常结束 | `{Success: true, Completed: true}` |
+
+> ⚠️ **已知缺口**:Engine 遇 `Blocked` 立即终止,导致 `issue-post-blocked` 阶段(阻塞评论提交)无法运行。后续计划通过 `on_blocked: continue` 增强恢复该阶段。
---
@@ -74,7 +278,11 @@ make build
2. 运行 code-bee 指向该 Issue:
```bash
+# 使用内置默认 workflow(开箱即用)
code-bee --repo your-org/your-repo --issue 42
+
+# 或指定自定义 workflow
+code-bee --repo your-org/your-repo --issue 42 --workflow ./my-workflow.yaml
```
3. code-bee 自动完成后续流程:
@@ -83,9 +291,9 @@ code-bee --repo your-org/your-repo --issue 42
🐝 code-bee 0.1.0
📦 仓库: your-org/your-repo | Issue: #42
-🚀 正在通知编码智能体: 请查看 your-org/your-repo 仓库的 #42 Issue...
+🚀 正在执行 workflow 驱动的调度流程...
-✅ 任务执行完成 → PR 已提交 → Issue 已回复
+✅ reviewer 已明确 PASS,且最终 Issue 回复已提交,任务执行完成
```
---
@@ -94,30 +302,111 @@ code-bee --repo your-org/your-repo --issue 42
```
.
-├── cmd/worker/ # CLI 入口
+├── cmd/worker/ # CLI 入口,加载 workflow 并启动调度
├── internal/
-│ ├── agent/ # 编码智能体调度
-│ ├── config/ # 配置管理
-│ ├── parser/ # Issue 解析
-│ ├── pipeline/ # 执行管线
-│ └── platform/ # 平台适配
-├── pkg/version/ # 版本信息
-├── Makefile # 统一操作入口
-├── .conform.yaml # 目录结构校验
-└── .golangci.yml # 代码规范检查
+│ ├── agent/ # 编码智能体执行器(runner)
+│ ├── config/ # 运行时配置(Repo/IssueNumber/Token)
+│ ├── parser/ # Issue 解析
+│ ├── pipeline/ # 文件契约 + runtime.Engine 薄封装 + ArtifactResolver 适配层
+│ ├── platform/ # 平台适配(GitHub 等)
+│ ├── runtime/ # 可编排执行引擎(Engine/Tool/Executor/PromptBuilder + 默认 workflow)
+│ └── schema/ # 三层 JSON Schema + YAML 加载器 + 语义校验
+├── pkg/version/ # 版本信息
+├── Makefile # 统一操作入口
+├── .conform.yaml # 目录结构校验
+└── .golangci.yml # 代码规范检查
```
---
+## ⚙️ 自定义 Workflow
+
+code-bee 的调度策略完全由 YAML workflow 配置驱动。无 `--workflow` flag 时使用内置默认配置(等价于原四阶段 harness),提供自定义 YAML 即可重定义角色体系和管线流程。
+
+### 配置结构
+
+一个 workflow 包含两部分:**工具集**(tools)和**管线编排**(pipeline)。
+
+```yaml
+# 工具集:定义可用角色及其能力
+tools:
+ - name: coder # 内部标识,pipeline 引用此名
+ display_name: 开发者 # 类人展示名,渲染进 prompt
+ aliases: [开发者] # @mention 别名,Issue 中 @ 任一别名都可触发
+ type: agent
+ prompt_template: coding # 引用内置 prompt 模板
+
+ - name: reviewer
+ display_name: QA负责人
+ aliases: [QA负责人, 代码审核员]
+ type: agent
+ prompt_template: review
+
+# 管线编排:用 stage / parallel / loop 三种原语组合流程
+pipeline:
+ - stage:
+ name: issue-handling
+ tool: issue-handling
+ output: issue_intake_result.json
+
+ - loop:
+ id: coding-review
+ max_iterations: 3 # 硬上限,防止死循环
+ exit_when:
+ - stage: review
+ field: status
+ operator: equals
+ value: PASS
+ body:
+ - stage: { name: coding, tool: coder, input_from: issue-handling }
+ - stage: { name: review, tool: reviewer, input_from: coding }
+ judge: # 可选:价值评估员
+ tool: loop-judge
+ start_round: 1
+```
+
+### 三种编排原语
+
+| 原语 | 语义 | 适用场景 |
+|------|------|---------|
+| `stage` | 串行单步 | 顺序执行的单个阶段 |
+| `parallel` | 并行执行(fork-join) | 多个独立检查同时跑 |
+| `loop` | 循环(含 `max_iterations` / `exit_when` / `judge`) | coder-reviewer 迭代 |
+
+支持嵌套:loop body 内可含 parallel,parallel 内可含 stage。阶段可通过 `when` 条件实现跳过。
+
+### 三种工具类型
+
+| 类型 | 用途 | 示例 |
+|------|------|------|
+| `agent` | 调用 AI 智能体执行任务 | 编码、审查、Issue 提交 |
+| `command` | 执行 shell 命令 | `golangci-lint run`、`npm test` |
+| `function` | 调用注册的 Go 函数 | 内置扩展点(预留) |
+
+### 角色名派生
+
+prompt 模板中的角色展示名(如 `@{{.DefaultAgent}}`)从 `workflow.Tools` 按 `prompt_template` 自动派生,不再硬编码。用户只需在 tool 定义中设置 `display_name`,prompt 渲染时自动取用。
+
+### 更多细节
+
+- [Schema 定义](internal/schema/schemas/v1/README.md) —— 三层 JSON Schema 与校验机制
+- [默认 workflow](internal/runtime/default_workflow.yaml) —— 内置配置参考
+- [Prompt 模板](internal/runtime/prompts/) —— 5 个内置模板文件
+
+---
+
## 🔮 路线图
-code-bee 的核心已经稳定,未来计划扩展的方向包括:
+code-bee 的场景驱动调度已落地,未来计划扩展的方向包括:
+- [x] **场景驱动调度** —— 角色、管线、循环参数完全由 YAML workflow 配置驱动([#11](https://github.com/JiGuangWorker/code-bee/issues/11))
+- [x] **可编排管线** —— stage / parallel / loop 三种原语,支持嵌套与条件跳过
+- [x] **工具别名** —— `@mention` 别名机制,角色体系完全可自定义
+- [ ] **阻塞评论恢复** —— 增强 Engine 支持 `on_blocked: continue`,恢复 issue-post-blocked 阶段
- [ ] **多智能体支持** —— 除 Reasonix 外,接入 Qoder、Cline 等更多编码智能体
- [ ] **GitLab 适配** —— 将 Issue → MR 的调度能力扩展到 GitLab 平台
- [ ] **Webhook 触发** —— 支持 Issue 事件自动触发,无需手动执行 CLI
-- [ ] **多 Agent 协作** —— 一个 Issue 中调度多个 Agent 协同完成复杂任务
-- [ ] **校验管线** —— 可配置的 CI 校验步骤(lint → test → build → security scan)
+- [ ] **校验管线** —— 用 `command` 工具组合 lint → test → build → security scan 流程
> 💡 欢迎提 Issue / PR 一起建设!每个想法都值得被讨论。
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index e4c0fd0..33500ab 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -2,12 +2,12 @@
//
// 核心功能:
// 1. 解析命令行参数并初始化运行时上下文
-// 2. 调度 Issue 处理、编码、审查、Issue 提交四个阶段的独立任务
-// 3. 以 reviewer PASS 且最终回复已成功提交作为 loop 唯一退出条件
+// 2. 加载 workflow 配置(内置默认或 --workflow 指定的外部文件)
+// 3. 调度由 workflow 驱动的 runtime.Engine 执行
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package main
import (
@@ -22,6 +22,8 @@ import (
"github.com/JiGuangWorker/code-bee/internal/config"
"github.com/JiGuangWorker/code-bee/internal/pipeline"
platformgithub "github.com/JiGuangWorker/code-bee/internal/platform/github"
+ "github.com/JiGuangWorker/code-bee/internal/runtime"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
"github.com/JiGuangWorker/code-bee/pkg/version"
)
@@ -30,29 +32,97 @@ type dispatchService interface {
Dispatch(context.Context, *config.Config) (*pipeline.Result, error)
}
+// serviceFactory 创建调度服务实例,可能因 workflow 加载失败返回错误。
+type serviceFactory func() (dispatchService, error)
+
// main 是 code-bee 的程序入口。
//
// 核心逻辑:
// - 校验命令行参数,避免进入无效执行
// - 创建支持 Ctrl+C 中断的上下文
-// - 调用四阶段 harness,将读 Issue、编码、审查、Issue 提交拆成独立阶段
-//
-// 调用注意事项:
-// - 需要本机已安装 reasonix,以便生成回执和执行编码任务
+// - 加载 workflow 配置(内置默认或 --workflow 外部文件)
+// - 调用 runtime.Engine 驱动的调度流程
func main() {
- os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr, func() dispatchService {
- return pipeline.NewService(
- platformgithub.NewClient(),
- agent.New(),
- )
+ os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr, func() (dispatchService, error) {
+ return buildService(os.Args[1:])
}))
}
+// buildService 构造真实的 pipeline.Service,包含 workflow 加载。
+//
+// workflowPath 为空时使用内置默认 workflow。
+// promptsDir 为空时只用内置 prompt 模板。
+func buildService(args []string) (*pipeline.Service, error) {
+ workflowPath, promptsDir := parseExtraFlags(args)
+
+ wf, err := loadWorkflow(workflowPath)
+ if err != nil {
+ return nil, fmt.Errorf("加载 workflow 失败: %w", err)
+ }
+
+ var opts []runtime.EngineOption
+ if promptsDir != "" {
+ opts = append(opts, runtime.WithPromptsDir(promptsDir))
+ }
+
+ service, err := pipeline.NewService(
+ platformgithub.NewClient(),
+ agent.New(),
+ wf,
+ opts...,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("创建调度服务失败: %w", err)
+ }
+
+ return service, nil
+}
+
+// parseExtraFlags 从参数中解析 --workflow 和 --prompts-dir 两个 flag。
+//
+// 单独解析(而非复用 runCLI 的 FlagSet)是因为 buildService 在 factory() 调用时
+// 执行,而 factory 在 runCLI 的 fs.Parse 之后才被调用,无法拿到已解析的值。
+//
+// 必须注册所有 flag(--repo/--issue/--version 等),否则 Go flag 包遇到
+// 未注册的 flag 会立即停止解析,导致排在 --repo 之后的 --workflow/--prompts-dir
+// 永远解析不到。这是两阶段解析模式的已知约束。
+func parseExtraFlags(args []string) (workflowPath, promptsDir string) {
+ fs := flag.NewFlagSet("extra-flags-probe", flag.ContinueOnError)
+ fs.SetOutput(io.Discard)
+ workflow := fs.String("workflow", "", "workflow 配置文件路径(留空则使用内置默认)")
+ prompts := fs.String("prompts-dir", "", "外部 prompt 模板目录(overlay 内置模板,留空则只用内置)")
+ // 占位注册:让 flag 包能跳过这些 flag 继续解析,值由 runCLI 自己处理
+ _ = fs.String("repo", "", "")
+ _ = fs.Int("issue", 0, "")
+ _ = fs.Bool("version", false, "")
+ _ = fs.Parse(args) //nolint:errcheck // ContinueOnError 下 Parse 总是返回 nil
+ return *workflow, *prompts
+}
+
+// loadWorkflow 加载 workflow 配置。
+//
+// 加载策略:
+// - path 非空: 从外部 YAML 文件加载(支持用户自定义)
+// - path 为空: 加载内置默认 workflow(go:embed)
+func loadWorkflow(path string) (*schema.Workflow, error) {
+ if path != "" {
+ loader, err := schema.NewLoader()
+ if err != nil {
+ return nil, fmt.Errorf("创建 schema loader: %w", err)
+ }
+ return loader.LoadFromFile(path)
+ }
+
+ return runtime.LoadDefaultWorkflow()
+}
+
// usageError 当参数缺失或解析失败时,向 stderr 输出统一的使用说明。
func usageError(stderr io.Writer) int {
- fmt.Fprintf(stderr, "用法: code-bee --repo --issue \n\n")
+ fmt.Fprintf(stderr, "用法: code-bee --repo --issue [--workflow ] [--prompts-dir ]\n\n")
fmt.Fprintf(stderr, "示例:\n")
fmt.Fprintf(stderr, " code-bee --repo owner/repo --issue 42\n")
+ fmt.Fprintf(stderr, " code-bee --repo owner/repo --issue 42 --workflow ./my-workflow.yaml\n")
+ fmt.Fprintf(stderr, " code-bee --repo owner/repo --issue 42 --prompts-dir ./my-prompts/\n")
return 1
}
@@ -61,13 +131,16 @@ func usageError(stderr io.Writer) int {
// 返回值:
// - 0: 成功完成
// - 1: 参数错误、调度失败或需要人工介入
-func runCLI(args []string, stdout, stderr io.Writer, serviceFactory func() dispatchService) int {
+func runCLI(args []string, stdout, stderr io.Writer, factory serviceFactory) int {
fs := flag.NewFlagSet("code-bee", flag.ContinueOnError)
fs.SetOutput(stderr)
repo := fs.String("repo", "", "仓库地址,如 owner/repo")
issueNumber := fs.Int("issue", 0, "Issue 编号")
showVersion := fs.Bool("version", false, "输出版本信息")
+ // --workflow / --prompts-dir flag 仅用于文档展示,实际解析在 buildService 中完成
+ _ = fs.String("workflow", "", "workflow 配置文件路径(留空则使用内置默认)")
+ _ = fs.String("prompts-dir", "", "外部 prompt 模板目录(overlay 内置模板)")
if err := fs.Parse(args); err != nil {
return usageError(stderr)
@@ -88,11 +161,15 @@ func runCLI(args []string, stdout, stderr io.Writer, serviceFactory func() dispa
fmt.Fprintf(stdout, "🐝 code-bee %s\n", version.Version)
fmt.Fprintf(stdout, "📦 仓库: %s | Issue: #%d\n\n", *repo, *issueNumber)
- cfg := config.New(*repo, *issueNumber)
- service := serviceFactory()
+ service, err := factory()
+ if err != nil {
+ fmt.Fprintf(stderr, "\n❌ 初始化失败: %v\n", err)
+ return 1
+ }
- fmt.Fprintln(stdout, "🚀 正在执行四阶段 harness:Issue 处理 -> 编码 -> 审查 -> Issue 提交...")
+ fmt.Fprintln(stdout, "🚀 正在执行 workflow 驱动的调度流程...")
+ cfg := config.New(*repo, *issueNumber)
result, err := service.Dispatch(ctx, cfg)
if err != nil {
fmt.Fprintf(stderr, "\n❌ 执行失败: %v\n", err)
diff --git a/cmd/worker/main_test.go b/cmd/worker/main_test.go
index 23f66e1..06c6b9b 100644
--- a/cmd/worker/main_test.go
+++ b/cmd/worker/main_test.go
@@ -26,9 +26,9 @@ func TestRunCLIReturnsVersion(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--version"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--version"}, &stdout, &stderr, func() (dispatchService, error) {
t.Fatal("service factory should not be called for --version")
- return nil
+ return nil, nil
})
if exitCode != 0 {
@@ -45,9 +45,9 @@ func TestRunCLIRejectsMissingRequiredArgs(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--repo", "owner/repo"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--repo", "owner/repo"}, &stdout, &stderr, func() (dispatchService, error) {
t.Fatal("service factory should not be called when args are invalid")
- return nil
+ return nil, nil
})
if exitCode != 1 {
@@ -64,13 +64,13 @@ func TestRunCLIReturnsSuccessWhenDispatchCompleted(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "8"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "8"}, &stdout, &stderr, func() (dispatchService, error) {
return fakeDispatchService{
result: &pipeline.Result{
Success: true,
Completed: true,
},
- }
+ }, nil
})
if exitCode != 0 {
@@ -87,14 +87,14 @@ func TestRunCLIReturnsBlockedMessage(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "9"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "9"}, &stdout, &stderr, func() (dispatchService, error) {
return fakeDispatchService{
result: &pipeline.Result{
Success: true,
Blocked: true,
Output: "缺少外部接口权限。",
},
- }
+ }, nil
})
if exitCode != 1 {
@@ -111,14 +111,14 @@ func TestRunCLIReturnsManualRequiredMessage(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "10"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "10"}, &stdout, &stderr, func() (dispatchService, error) {
return fakeDispatchService{
result: &pipeline.Result{
Success: true,
ManualRequired: true,
Output: "自动循环已无价值。",
},
- }
+ }, nil
})
if exitCode != 1 {
@@ -135,10 +135,10 @@ func TestRunCLIReturnsDispatchError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
- exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "11"}, &stdout, &stderr, func() dispatchService {
+ exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "11"}, &stdout, &stderr, func() (dispatchService, error) {
return fakeDispatchService{
err: errors.New("dispatch failed"),
- }
+ }, nil
})
if exitCode != 1 {
@@ -150,6 +150,24 @@ func TestRunCLIReturnsDispatchError(t *testing.T) {
}
}
+// TestRunCLIReturnsFactoryError 验证 factory 返回错误时 CLI 会输出初始化失败并返回失败退出码。
+func TestRunCLIReturnsFactoryError(t *testing.T) {
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+
+ exitCode := runCLI([]string{"--repo", "owner/repo", "--issue", "12"}, &stdout, &stderr, func() (dispatchService, error) {
+ return nil, errors.New("workflow load failed")
+ })
+
+ if exitCode != 1 {
+ t.Fatalf("runCLI(factory error) exitCode = %d, want 1", exitCode)
+ }
+
+ if !strings.Contains(stderr.String(), "初始化失败") {
+ t.Fatalf("runCLI(factory error) stderr = %q, want contains 初始化失败", stderr.String())
+ }
+}
+
// fakeDispatchService 是供 CLI 单元测试注入的最小假调度器。
type fakeDispatchService struct {
result *pipeline.Result
@@ -160,3 +178,65 @@ type fakeDispatchService struct {
func (f fakeDispatchService) Dispatch(context.Context, *config.Config) (*pipeline.Result, error) {
return f.result, f.err
}
+
+// TestParseExtraFlags 验证 --workflow / --prompts-dir 在各种参数顺序下都能被解析到。
+//
+// 回归保护: 早期实现只注册单个 flag,导致 Go flag 包遇到未注册的 --repo 时
+// 立即停止解析,排在 --repo 之后的 --workflow / --prompts-dir 永远解析不到。
+func TestParseExtraFlags(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ wantWorkflow string
+ wantPrompts string
+ }{
+ {
+ name: "both flags after repo and issue",
+ args: []string{"--repo", "owner/repo", "--issue", "42", "--workflow", "/tmp/wf.yaml", "--prompts-dir", "/tmp/prompts"},
+ wantWorkflow: "/tmp/wf.yaml",
+ wantPrompts: "/tmp/prompts",
+ },
+ {
+ name: "workflow only after repo",
+ args: []string{"--repo", "owner/repo", "--issue", "42", "--workflow", "/tmp/wf.yaml"},
+ wantWorkflow: "/tmp/wf.yaml",
+ wantPrompts: "",
+ },
+ {
+ name: "prompts-dir only after repo",
+ args: []string{"--repo", "owner/repo", "--issue", "42", "--prompts-dir", "/tmp/prompts"},
+ wantWorkflow: "",
+ wantPrompts: "/tmp/prompts",
+ },
+ {
+ name: "both flags before repo",
+ args: []string{"--workflow", "/tmp/wf.yaml", "--prompts-dir", "/tmp/prompts", "--repo", "owner/repo", "--issue", "42"},
+ wantWorkflow: "/tmp/wf.yaml",
+ wantPrompts: "/tmp/prompts",
+ },
+ {
+ name: "no extra flags",
+ args: []string{"--repo", "owner/repo", "--issue", "42"},
+ wantWorkflow: "",
+ wantPrompts: "",
+ },
+ {
+ name: "flags interspersed",
+ args: []string{"--workflow", "/tmp/wf.yaml", "--repo", "owner/repo", "--prompts-dir", "/tmp/prompts", "--issue", "42"},
+ wantWorkflow: "/tmp/wf.yaml",
+ wantPrompts: "/tmp/prompts",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ gotWorkflow, gotPrompts := parseExtraFlags(tc.args)
+ if gotWorkflow != tc.wantWorkflow {
+ t.Errorf("workflow = %q, want %q", gotWorkflow, tc.wantWorkflow)
+ }
+ if gotPrompts != tc.wantPrompts {
+ t.Errorf("promptsDir = %q, want %q", gotPrompts, tc.wantPrompts)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index f9782e1..5b60592 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,8 @@
module github.com/JiGuangWorker/code-bee
go 1.23.0
+
+require (
+ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
+ gopkg.in/yaml.v3 v3.0.1
+)
diff --git a/go.sum b/go.sum
index e69de29..70d82da 100644
--- a/go.sum
+++ b/go.sum
@@ -0,0 +1,6 @@
+github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
+github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/config.go b/internal/config/config.go
index 8855fe0..f447dbe 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -1,21 +1,20 @@
// Package config 管理 code-bee 的运行时配置。
//
// 配置来源优先级:命令行参数 > 环境变量 > 默认值。
+//
+// 在场景驱动调度重构后,角色体系和循环参数完全由 workflow 配置
+// (internal/runtime/default_workflow.yaml 或 --workflow 指定的自定义 YAML)
+// 驱动,Config 只保留 Repo/IssueNumber/GitHubToken 三个真正运行时参数。
package config
import "os"
-// 默认值常量,集中维护避免散落硬编码。
-const (
- // defaultMaxCodingReviewRounds 是 coder-reviewer loop 默认最大轮数。
- defaultMaxCodingReviewRounds = 3
-
- // defaultLoopJudgeStartRound 是价值评估员默认触发起始轮。
- // 0 表示始终触发。
- defaultLoopJudgeStartRound = 0
-)
-
-// Config 表示 code-bee 的完整运行时配置。
+// Config 表示 code-bee 的运行时配置。
+//
+// 角色展示名(DefaultAgent/ReviewerAgent/IssuePostAgent)和循环参数
+// (MaxCodingReviewRounds/LoopJudgeStartRound)已迁移至 workflow 配置,
+// 由 runtime.lookupDisplayNameByPromptTemplate 和 loop_executor 从
+// *schema.Workflow 派生,不再在此硬编码。
type Config struct {
// Repo 是目标仓库,格式 owner/repo。
Repo string
@@ -26,87 +25,13 @@ type Config struct {
// GitHubToken 用于访问 GitHub API。
// 默认从环境变量 GITHUB_TOKEN 读取。
GitHubToken string
-
- // DefaultAgent 是当 Issue 中未发现 @agent-name 时使用的默认智能体。
- DefaultAgent string
-
- // ReviewerAgent 是 coder-reviewer loop 中默认的审查智能体。
- // 当前版本固定使用内置角色,避免主流程里散落硬编码字符串。
- ReviewerAgent string
-
- // IssuePostAgent 是专门负责 Issue 提交的智能体。
- // 该角色不直接编码,而是负责校验结果文件并完成最终评论提交。
- IssuePostAgent string
-
- // LoopJudgeAgent 是 coder-reviewer loop 中默认的价值评估智能体。
- // 负责判断当前循环是否还有继续价值。
- LoopJudgeAgent string
-
- // LoopJudgeStartRound 表示从第几轮开始触发价值评估员。
- // 0 表示始终触发;大于 0 则表示从该轮开始才触发。
- LoopJudgeStartRound int
-
- // MaxCodingReviewRounds 表示 coder-reviewer loop 的最大执行轮数。
- MaxCodingReviewRounds int
-}
-
-// AgentRole 定义了一个可调度的智能体角色。
-type AgentRole struct {
- // Name 是 @ 语法中的名称,如 "开发者"、"技术负责人"。
- Name string
-
- // SkillPath 是该角色对应的 Skill 文件路径(相对于 .skills/ 目录)。
- SkillPath string
-
- // Description 是角色的简短描述。
- Description string
-}
-
-// BuiltinAgents 返回内置的智能体角色列表。
-// 这些角色与 .skills/ 目录中的 Skill 定义一一对应。
-func BuiltinAgents() []AgentRole {
- return []AgentRole{
- {Name: "开发者", SkillPath: "开发者Skill/SKILL.md", Description: "负责将 Issue 转化为符合规范的代码"},
- {Name: "技术负责人", SkillPath: "技术负责人Skill/SKILL.md", Description: "负责工程团队的交付质量与效率"},
- {Name: "架构师", SkillPath: "架构师Skill/SKILL.md", Description: "负责数据结构设计与逻辑流程设计"},
- {Name: "产品经理", SkillPath: "产品经理Skill/SKILL.md", Description: "负责需求捕捉、PRD 编写与评审主持"},
- {Name: "QA负责人", SkillPath: "QA负责人Skill/SKILL.md", Description: "负责测试用例生成与验收放行"},
- {Name: "UI负责人", SkillPath: "UI负责人Skill/SKILL.md", Description: "负责生图与配音"},
- }
-}
-
-// AgentByName 根据名称查找内置智能体角色。
-// 如果未找到,返回 false。
-func AgentByName(name string) (AgentRole, bool) {
- for _, a := range BuiltinAgents() {
- if a.Name == name {
- return a, true
- }
- }
- return AgentRole{}, false
-}
-
-// AgentNames 返回所有内置智能体的名称列表。
-func AgentNames() []string {
- agents := BuiltinAgents()
- names := make([]string, len(agents))
- for i, a := range agents {
- names[i] = a.Name
- }
- return names
}
// New 创建一个带有默认值的 Config。
func New(repo string, issueNumber int) *Config {
return &Config{
- Repo: repo,
- IssueNumber: issueNumber,
- GitHubToken: os.Getenv("GITHUB_TOKEN"),
- DefaultAgent: "开发者",
- ReviewerAgent: "QA负责人",
- IssuePostAgent: "产品经理",
- LoopJudgeAgent: "技术负责人",
- MaxCodingReviewRounds: defaultMaxCodingReviewRounds,
- LoopJudgeStartRound: defaultLoopJudgeStartRound,
+ Repo: repo,
+ IssueNumber: issueNumber,
+ GitHubToken: os.Getenv("GITHUB_TOKEN"),
}
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 6ec502b..7ec062b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -1,12 +1,14 @@
-// Package config 覆盖运行时默认配置与内置角色的单元测试。
+// Package config 覆盖运行时默认配置的单元测试。
//
// 核心功能:
-// 1. 验证默认配置是否按预期初始化,避免 loop 参数和角色默认值漂移
-// 2. 验证内置角色查询与名称列表输出是否稳定,避免提示词引用角色时失配
+// 验证 New 会返回稳定的默认配置(Repo/IssueNumber/GitHubToken)
+//
+// 注: 原 BuiltinAgents/AgentByName/AgentNames 测试已在场景驱动调度重构中删除,
+// 角色体系完全由 workflow 配置驱动,不再需要内置角色查询。
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package config
import "testing"
@@ -23,72 +25,6 @@ func TestNewReturnsExpectedDefaults(t *testing.T) {
t.Fatalf("Config.IssueNumber = %d, want %d", cfg.IssueNumber, 42)
}
- if cfg.DefaultAgent != "开发者" {
- t.Fatalf("Config.DefaultAgent = %q, want %q", cfg.DefaultAgent, "开发者")
- }
-
- if cfg.ReviewerAgent != "QA负责人" {
- t.Fatalf("Config.ReviewerAgent = %q, want %q", cfg.ReviewerAgent, "QA负责人")
- }
-
- if cfg.IssuePostAgent != "产品经理" {
- t.Fatalf("Config.IssuePostAgent = %q, want %q", cfg.IssuePostAgent, "产品经理")
- }
-
- if cfg.LoopJudgeAgent != "技术负责人" {
- t.Fatalf("Config.LoopJudgeAgent = %q, want %q", cfg.LoopJudgeAgent, "技术负责人")
- }
-
- if cfg.MaxCodingReviewRounds != defaultMaxCodingReviewRounds {
- t.Fatalf("Config.MaxCodingReviewRounds = %d, want %d", cfg.MaxCodingReviewRounds, defaultMaxCodingReviewRounds)
- }
-
- if cfg.LoopJudgeStartRound != defaultLoopJudgeStartRound {
- t.Fatalf("Config.LoopJudgeStartRound = %d, want %d", cfg.LoopJudgeStartRound, defaultLoopJudgeStartRound)
- }
-}
-
-// TestAgentByNameFoundAndMissing 验证内置角色查询在命中和未命中场景下的返回值。
-func TestAgentByNameFoundAndMissing(t *testing.T) {
- agentRole, ok := AgentByName("开发者")
- if !ok {
- t.Fatal("AgentByName(开发者) ok = false, want true")
- }
-
- if agentRole.SkillPath != "开发者Skill/SKILL.md" {
- t.Fatalf("AgentByName(开发者).SkillPath = %q, want %q", agentRole.SkillPath, "开发者Skill/SKILL.md")
- }
-
- if _, ok := AgentByName("不存在的角色"); ok {
- t.Fatal("AgentByName(不存在的角色) ok = true, want false")
- }
-}
-
-// TestAgentNamesContainsBuiltinRoles 验证角色名称列表与内置角色数量一致,且包含关键角色。
-func TestAgentNamesContainsBuiltinRoles(t *testing.T) {
- names := AgentNames()
- agents := BuiltinAgents()
-
- if len(names) != len(agents) {
- t.Fatalf("len(AgentNames()) = %d, want %d", len(names), len(agents))
- }
-
- requiredNames := map[string]bool{
- "开发者": false,
- "技术负责人": false,
- "QA负责人": false,
- "产品经理": false,
- }
-
- for _, name := range names {
- if _, ok := requiredNames[name]; ok {
- requiredNames[name] = true
- }
- }
-
- for name, found := range requiredNames {
- if !found {
- t.Fatalf("AgentNames() missing required role %q", name)
- }
- }
+ // GitHubToken 来自环境变量,不在此断言具体值,只确认字段存在且不 panic
+ _ = cfg.GitHubToken
}
diff --git a/internal/pipeline/coverage_test.go b/internal/pipeline/coverage_test.go
index bcdbba1..3469aec 100644
--- a/internal/pipeline/coverage_test.go
+++ b/internal/pipeline/coverage_test.go
@@ -1,23 +1,22 @@
-// Package pipeline 覆盖角色级单元测试与状态机场景测试。
+// Package pipeline 覆盖文件契约与历史工具的单元测试。
//
// 核心功能:
-// 1. 补齐 artifacts、contracts、history、feedback helper 的单元测试
-// 2. 构造 issue-handling / coding / review / issue-post / loop-judge 的关键场景,验证调度器状态机
+// 1. 补齐 artifacts、contracts、history 的单元测试
+// 2. 验证结果文件加载与校验逻辑的稳定性
+//
+// 注: 原覆盖 dispatch 状态机的测试已在阶段 8 重构中删除,
+// 新的 Service.Dispatch 行为由 internal/runtime 包的引擎测试覆盖。
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package pipeline
import (
- "context"
"os"
"path/filepath"
"strings"
"testing"
-
- "github.com/JiGuangWorker/code-bee/internal/agent"
- "github.com/JiGuangWorker/code-bee/internal/config"
)
// TestArtifactSetRoundPaths 验证逐轮工件路径与聚合历史路径稳定可预测。
@@ -114,338 +113,6 @@ func TestLoopHistoryAppendRoundRejectsNilReview(t *testing.T) {
}
}
-// TestNextReviewerFeedbackAddsEvidenceHintAfterUnknownThreshold 验证 reviewer 连续 UNKNOWN 时会强制切换到证据优先模式。
-func TestNextReviewerFeedbackAddsEvidenceHintAfterUnknownThreshold(t *testing.T) {
- feedback, consecutiveUnknowns := nextReviewerFeedback(&ReviewResult{
- Status: reviewStatusUnknown,
- Summary: "暂时无法确认。",
- CheckResult: "证据不足。",
- Missing: "缺少测试结果。",
- NextAction: "补充证据。",
- }, maxConsecutiveUnknownReview-1)
-
- if consecutiveUnknowns != maxConsecutiveUnknownReview {
- t.Fatalf("consecutiveUnknowns = %d, want %d", consecutiveUnknowns, maxConsecutiveUnknownReview)
- }
-
- if !strings.Contains(feedback, "下一轮优先补充证据") {
- t.Fatalf("feedback = %q, want evidence-first hint", feedback)
- }
-}
-
-// TestShouldRunLoopJudgeThreshold 验证价值评估员触发阈值判断逻辑。
-func TestShouldRunLoopJudgeThreshold(t *testing.T) {
- cfg := config.New("owner/repo", 1)
- cfg.LoopJudgeStartRound = 3
-
- if shouldRunLoopJudge(cfg, 2) {
- t.Fatal("shouldRunLoopJudge(round=2) = true, want false")
- }
-
- if !shouldRunLoopJudge(cfg, 3) {
- t.Fatal("shouldRunLoopJudge(round=3) = false, want true")
- }
-
- if !shouldRunLoopJudge(&config.Config{LoopJudgeStartRound: 0}, 1) {
- t.Fatal("shouldRunLoopJudge(startRound=0) = false, want true")
- }
-}
-
-// TestDispatchBlockedWhenIssueHandlingBlocked 验证 intake 阶段直接阻塞时,流程会在进入 coding 前终止。
-func TestDispatchBlockedWhenIssueHandlingBlocked(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 31)
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- {
- kind: agent.TaskKindIssueHandling,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "BLOCKED",
- "agent": "开发者",
- "summary": "缺少明确验收标准。",
- "acceptance": "暂无。",
- "next_action": "等待补充验收标准。",
- "comment_body": "当前缺少明确验收标准,暂不进入开发。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue handling blocked"}, nil
- },
- },
- {
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "POSTED",
- "summary": "阻塞说明已提交。",
- "feedback": "已发布阻塞说明。",
- "next_action": "等待 issue 补充信息。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue post blocked"}, nil
- },
- },
- },
- }
-
- result, err := NewService(fakePlatformClient{}, runner).Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.Blocked || result.Completed {
- t.Fatalf("Dispatch() result = %+v, want blocked success result", result)
- }
- })
-}
-
-// TestDispatchBlockedWhenCodingBlocked 验证 coder 明确 BLOCKED 时,流程会直接停止并返回阻塞态。
-func TestDispatchBlockedWhenCodingBlocked(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 32)
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- makeIssueHandlingReadyStep(t),
- makeIssuePostPostedStep(t, "接单回执已提交。", "issue post intake ok"),
- {
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "BLOCKED",
- "summary": "缺少仓库写权限。",
- "evidence": "push 被拒绝。",
- "acceptance_check": "尚未完成。",
- "next_action": "申请仓库写权限。"
-}`)
- return &agent.RunResult{Success: true, Output: "coding blocked"}, nil
- },
- },
- },
- }
-
- result, err := NewService(fakePlatformClient{}, runner).Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.Blocked || result.Output != "coding blocked" {
- t.Fatalf("Dispatch() result = %+v, want blocked result from coding", result)
- }
- })
-}
-
-// TestDispatchBlockedWhenReviewBlocked 验证 reviewer 明确 BLOCKED 时,流程会停止并要求人工介入。
-func TestDispatchBlockedWhenReviewBlocked(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 33)
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- makeIssueHandlingReadyStep(t),
- makeIssuePostPostedStep(t, "接单回执已提交。", "issue post intake ok"),
- makeCodingDoneStep(t, "第一轮编码完成。", "coding round1 ok"),
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "BLOCKED",
- "summary": "缺少测试环境。",
- "check_result": "无法继续验证。",
- "missing": "缺少联调环境与权限。",
- "next_action": "人工补齐环境后再审查。",
- "comment_body": ""
-}`)
- return &agent.RunResult{Success: true, Output: "review blocked"}, nil
- },
- },
- },
- }
-
- result, err := NewService(fakePlatformClient{}, runner).Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.Blocked || !result.ManualRequired {
- t.Fatalf("Dispatch() result = %+v, want blocked manual-required result", result)
- }
- })
-}
-
-// TestDispatchBlockedWhenLoopJudgeRequestsBlocked 验证价值评估员可直接将流程判定为外部阻塞。
-func TestDispatchBlockedWhenLoopJudgeRequestsBlocked(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 34)
- cfg.LoopJudgeStartRound = 1
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- makeIssueHandlingReadyStep(t),
- makeIssuePostPostedStep(t, "接单回执已提交。", "issue post intake ok"),
- makeCodingDoneStep(t, "第一轮编码完成。", "coding round1 ok"),
- makeReviewFailStep(t, "第一轮审查未通过。", "缺少外部依赖响应。", "review round1 fail"),
- {
- kind: agent.TaskKindLoopJudge,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 评估结果文件:", `{
- "decision": "STOP_BLOCKED",
- "reason": "问题核心依赖外部接口返回,继续自动循环没有意义。",
- "evidence": "round-01 已明确缺少外部依赖响应。",
- "confidence": "HIGH",
- "next_action": "停止自动循环,等待外部条件补齐。"
-}`)
- return &agent.RunResult{Success: true, Output: "loop judge stop blocked"}, nil
- },
- },
- },
- }
-
- result, err := NewService(fakePlatformClient{}, runner).Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.Blocked || !result.ManualRequired {
- t.Fatalf("Dispatch() result = %+v, want blocked manual-required result", result)
- }
- })
-}
-
-// TestDispatchReturnsErrorWhenCompletionIssuePostRejectedTwice 验证完成回帖连续两次 REJECTED 时,调度器会返回错误而不是误判成功。
-func TestDispatchReturnsErrorWhenCompletionIssuePostRejectedTwice(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 35)
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- makeIssueHandlingReadyStep(t),
- makeIssuePostPostedStep(t, "接单回执已提交。", "issue post intake ok"),
- makeCodingDoneStep(t, "第一轮编码完成。", "coding round1 ok"),
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "PASS",
- "summary": "第一轮审查通过。",
- "check_result": "验收项均通过。",
- "missing": "无",
- "next_action": "允许结束 loop。",
- "comment_body": "任务已完成。"
-}`)
- return &agent.RunResult{Success: true, Output: "review pass"}, nil
- },
- },
- makeIssuePostRejectedStep(t, "完成评论信息不充分。", "issue post completion rejected #1"),
- makeIssuePostRejectedStep(t, "完成评论信息仍不充分。", "issue post completion rejected #2"),
- },
- }
-
- _, err := NewService(fakePlatformClient{}, runner).Dispatch(context.Background(), cfg)
- if err == nil {
- t.Fatal("Dispatch() error = nil, want non-nil error")
- }
-
- if !strings.Contains(err.Error(), "issue post completion rejected after 2 attempts") {
- t.Fatalf("Dispatch() error = %q, want retry exhausted error", err.Error())
- }
- })
-}
-
-// makeIssueHandlingReadyStep 构造通用的 intake READY 测试步骤。
-func makeIssueHandlingReadyStep(t *testing.T) scriptedStep {
- t.Helper()
-
- return scriptedStep{
- kind: agent.TaskKindIssueHandling,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "READY",
- "agent": "开发者",
- "summary": "任务可以进入开发。",
- "acceptance": "按验收标准完成并提供证据。",
- "next_action": "进入编码。",
- "comment_body": "已接单。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue handling ok"}, nil
- },
- }
-}
-
-// makeIssuePostPostedStep 构造通用的 Issue 提交成功测试步骤。
-func makeIssuePostPostedStep(t *testing.T, summary string, output string) scriptedStep {
- t.Helper()
-
- return scriptedStep{
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "POSTED",
- "summary": "`+summary+`",
- "feedback": "已成功提交。",
- "next_action": "继续后续流程。"
-}`)
- return &agent.RunResult{Success: true, Output: output}, nil
- },
- }
-}
-
-// makeIssuePostRejectedStep 构造通用的 Issue 提交拒绝测试步骤。
-func makeIssuePostRejectedStep(t *testing.T, feedback string, output string) scriptedStep {
- t.Helper()
-
- return scriptedStep{
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "REJECTED",
- "summary": "评论内容不符合提交要求。",
- "feedback": "`+feedback+`",
- "next_action": "重新整理评论正文。"
-}`)
- return &agent.RunResult{Success: true, Output: output}, nil
- },
- }
-}
-
-// makeCodingDoneStep 构造通用的 coding DONE 测试步骤。
-func makeCodingDoneStep(t *testing.T, summary string, output string) scriptedStep {
- t.Helper()
-
- return scriptedStep{
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "DONE",
- "summary": "`+summary+`",
- "evidence": "已补充实现与验证证据。",
- "acceptance_check": "按验收标准完成自检。",
- "next_action": "等待审查。"
-}`)
- return &agent.RunResult{Success: true, Output: output}, nil
- },
- }
-}
-
-// makeReviewFailStep 构造通用的 review FAIL 测试步骤。
-func makeReviewFailStep(t *testing.T, summary string, missing string, output string) scriptedStep {
- t.Helper()
-
- return scriptedStep{
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "FAIL",
- "summary": "`+summary+`",
- "check_result": "当前尚未满足全部验收要求。",
- "missing": "`+missing+`",
- "next_action": "补足缺失项后再次审查。",
- "comment_body": ""
-}`)
- return &agent.RunResult{Success: true, Output: output}, nil
- },
- }
-}
-
// TestSaveLoopHistoryWritesReadableJSON 验证聚合历史会被稳定写成可读 JSON 文件。
func TestSaveLoopHistoryWritesReadableJSON(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "loop_history.json")
diff --git a/internal/pipeline/dispatch.go b/internal/pipeline/dispatch.go
index 91aba4b..69146c9 100644
--- a/internal/pipeline/dispatch.go
+++ b/internal/pipeline/dispatch.go
@@ -1,13 +1,19 @@
// Package pipeline 串联 code-bee 的四阶段 harness 与 coder-reviewer loop。
//
-// 核心功能:
-// 1. 先执行 Issue 处理阶段,由智能体自行查看 Issue 并把结果写入文件
-// 2. 再进入“编码 -> 审查”的外层循环,由 code-bee 负责状态机调度
-// 3. 所有 Issue 评论都交给专门的 Issue 提交智能体,避免编码阶段直接对外提交
+// 本包在阶段 8 重构后,调度流程完全由 runtime.Engine 驱动:
+// - Service 持有 *runtime.Engine 和 *schema.Workflow
+// - Dispatch 构造 ArtifactResolverAdapter + PlatformContext,委托 engine.Run
+// - 硬编码的 runIssueHandling / runCodingReviewLoop 等函数已删除
+// - prompt 模板外部化至 internal/runtime/prompts/*.md
+//
+// 保留的职责:
+// 1. 文件契约(ArtifactSet / 历史 / 结果校验)—— contracts.go / artifacts.go / history.go
+// 2. runtime.ArtifactResolver 适配层 —— runtime_adapter.go
+// 3. EngineResult → pipeline.Result 类型转换
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package pipeline
import (
@@ -19,12 +25,8 @@ import (
"github.com/JiGuangWorker/code-bee/internal/agent"
"github.com/JiGuangWorker/code-bee/internal/config"
"github.com/JiGuangWorker/code-bee/internal/platform"
-)
-
-const (
- maxCodingReviewRounds = 3
- maxConsecutiveUnknownReview = 2
- maxIssuePostAttempts = 2
+ "github.com/JiGuangWorker/code-bee/internal/runtime"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
)
// Runner 定义智能体执行器的抽象,用于解耦 Service 与具体的 agent.Runner 实现。
@@ -32,554 +34,100 @@ type Runner interface {
Run(ctx context.Context, kind agent.TaskKind, task string) (*agent.RunResult, error)
}
-// Service 负责串联平台技能包和四阶段调度流程。
+// Service 负责串联平台技能包和 runtime.Engine 驱动的调度流程。
+//
+// 在阶段 8 重构后,Service 仅保留薄封装:
+// - 持有 *runtime.Engine(构造时一次性创建)
+// - Dispatch 时构造文件契约和平台上下文,委托 engine.Run
type Service struct {
platformClient platform.Client
runner Runner
+ engine *runtime.Engine
+ workflow *schema.Workflow
}
-// dispatchContext 统一收纳整个 harness 运行中会重复使用的上下文。
-type dispatchContext struct {
- // workerID 是当前实例的稳定标识,用于写入所有阶段的提示词。
- workerID string
-
- // issueURL 是目标 Issue 的直接入口。
- issueURL string
-
- // platformName 是当前代码托管平台名称。
- platformName string
-
- // platformGuide 是当前平台提供给智能体的技能包说明。
- platformGuide string
-
- // artifacts 是本次运行对应的文件工件集合。
- artifacts *ArtifactSet
-}
+// NewService 创建基于 runtime.Engine 的调度服务。
+//
+// 参数:
+// - platformClient: 平台客户端(GitHub 等),用于构造 Issue URL 和技能包说明
+// - runner: 智能体执行器,由 runtime.AgentTool 包装调用
+// - wf: 已校验的 workflow 配置,驱动整个调度流程
+// - opts: 可选的 EngineOption(如 runtime.WithPromptsDir),透传给 runtime.NewEngine
+//
+// 返回值:
+// - *Service: 可用于 Dispatch 的服务实例
+// - error: 当 runtime.Engine 构造失败时返回
+func NewService(platformClient platform.Client, runner Runner, wf *schema.Workflow, opts ...runtime.EngineOption) (*Service, error) {
+ engine, err := runtime.NewEngine(wf, runner, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("pipeline.NewService: %w", err)
+ }
-// NewService 创建最小执行流水线服务。
-func NewService(platformClient platform.Client, runner Runner) *Service {
return &Service{
platformClient: platformClient,
runner: runner,
- }
+ engine: engine,
+ workflow: wf,
+ }, nil
}
-// Dispatch 执行四阶段 harness。
+// Dispatch 执行 workflow 配置驱动的调度流程。
//
// 核心逻辑:
-// - 第一步执行 Issue 处理阶段,并通过专门的 Issue 提交智能体完成接单/阻塞回复
-// - 第二步进入 coder-reviewer loop,保证“编码”和“审查”是两个独立任务
-// - 第三步仅以 reviewer PASS 且最终 completion 回复成功提交作为退出条件
+// 1. 创建本次运行的 ArtifactSet(文件契约目录)
+// 2. 构造 ArtifactResolverAdapter(适配 runtime.ArtifactResolver 接口)
+// 3. 构造 PlatformContext(workerID / issueURL / 平台技能包)
+// 4. 委托 engine.Run 执行 workflow.Pipeline
+// 5. 把 EngineResult 转成 pipeline.Result 返回
func (s *Service) Dispatch(ctx context.Context, cfg *config.Config) (*Result, error) {
- dispatchCtx, err := s.newDispatchContext(cfg)
+ artifacts, err := NewArtifactSet(cfg.Repo, cfg.IssueNumber)
if err != nil {
return &Result{Success: false}, fmt.Errorf("pipeline.Service.Dispatch: %w", err)
}
- issueResult, issueOutput, err := s.runIssueHandling(ctx, cfg, dispatchCtx)
- if err != nil {
- return &Result{
- Success: false,
- Output: issueOutput,
- }, fmt.Errorf("pipeline.Service.Dispatch: %w", err)
- }
+ resolver := NewArtifactResolverAdapter(artifacts)
- if issueResult.BlockedStatus() {
- return &Result{
- Success: true,
- Blocked: true,
- Output: issueOutput,
- }, nil
+ platformCtx := runtime.PlatformContext{
+ WorkerID: resolveWorkerID(),
+ IssueURL: s.platformClient.BuildIssueURL(cfg.Repo, cfg.IssueNumber),
+ PlatformName: s.platformClient.Name(),
+ PlatformGuide: s.platformClient.BuildSkillInstruction(cfg.Repo, cfg.IssueNumber),
}
- loopResult, loopErr := s.runCodingReviewLoop(ctx, cfg, dispatchCtx, issueResult)
- if loopErr != nil {
- return loopResult, fmt.Errorf("pipeline.Service.Dispatch: %w", loopErr)
+ deps := runtime.RunDependencies{
+ Artifacts: resolver,
+ Platform: platformCtx,
+ Config: cfg,
}
- return loopResult, nil
-}
-
-// newDispatchContext 构造本次 harness 运行所需的稳定上下文。
-func (s *Service) newDispatchContext(cfg *config.Config) (dispatchContext, error) {
- artifacts, err := NewArtifactSet(cfg.Repo, cfg.IssueNumber)
+ engineResult, err := s.engine.Run(ctx, deps)
+ result := toPipelineResult(engineResult)
if err != nil {
- return dispatchContext{}, err
- }
-
- return dispatchContext{
- workerID: resolveWorkerID(),
- issueURL: s.platformClient.BuildIssueURL(cfg.Repo, cfg.IssueNumber),
- platformName: s.platformClient.Name(),
- platformGuide: s.platformClient.BuildSkillInstruction(
- cfg.Repo,
- cfg.IssueNumber,
- ),
- artifacts: artifacts,
- }, nil
-}
-
-// runIssueHandling 执行第一阶段的 Issue 处理任务,并确保接单/阻塞回复由 Issue 提交智能体完成。
-func (s *Service) runIssueHandling(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
-) (*IssueHandlingResult, string, error) {
- var lastOutput string
-
- if err := resetResultFile(dispatchCtx.artifacts.IssueHandlingResultPath()); err != nil {
- return nil, "", err
- }
-
- issuePrompt := buildIssueHandlingPrompt(
- cfg,
- dispatchCtx.workerID,
- dispatchCtx.issueURL,
- dispatchCtx.platformName,
- dispatchCtx.platformGuide,
- dispatchCtx.artifacts.IssueHandlingResultPath(),
- "",
- )
-
- issueRunResult, err := s.runner.Run(ctx, agent.TaskKindIssueHandling, issuePrompt)
- if err != nil {
- return nil, safeOutput(issueRunResult), fmt.Errorf("issue handling failed: %w", err)
- }
- lastOutput = issueRunResult.Output
-
- issueResult, loadErr := loadIssueHandlingResult(dispatchCtx.artifacts.IssueHandlingResultPath())
- if loadErr != nil {
- return nil, lastOutput, fmt.Errorf("load issue handling result: %w", loadErr)
+ return result, fmt.Errorf("pipeline.Service.Dispatch: %w", err)
}
- postResult, postOutput, postErr := s.runIssuePostWithRetry(
- ctx,
- cfg,
- dispatchCtx,
- "intake",
- dispatchCtx.artifacts.IssueHandlingResultPath(),
- )
- lastOutput = postOutput
- if postErr != nil {
- return nil, lastOutput, postErr
- }
-
- if postResult.BlockedStatus() {
- issueResult.Status = issueStatusBlocked
- }
-
- return issueResult, lastOutput, nil
-}
-
-// runCodingReviewLoop 执行 coder-reviewer 外层循环,并在 PASS 后调用 Issue 提交智能体提交最终回复。
-func (s *Service) runCodingReviewLoop(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- issueResult *IssueHandlingResult,
-) (*Result, error) {
- var (
- lastOutput string
- reviewerFeedback string
- consecutiveUnknowns int
- )
-
- maxRounds := maxCodingReviewRounds
- if cfg.MaxCodingReviewRounds > 0 {
- maxRounds = cfg.MaxCodingReviewRounds
- }
-
- history := &LoopHistory{
- Repo: cfg.Repo,
- IssueNumber: cfg.IssueNumber,
- MaxRounds: maxRounds,
- }
-
- for round := 1; round <= maxRounds; round++ {
- codingResult, codingOutput, codingErr := s.runCodingRound(
- ctx,
- cfg,
- dispatchCtx,
- issueResult,
- reviewerFeedback,
- round,
- )
- if codingErr != nil {
- return &Result{Success: false, Output: codingOutput}, codingErr
- }
-
- if codingResult.BlockedStatus() {
- return &Result{Success: true, Blocked: true, Output: codingOutput}, nil
- }
-
- reviewResult, reviewOutput, reviewErr := s.runReviewRound(
- ctx,
- cfg,
- dispatchCtx,
- issueResult,
- codingResult,
- round,
- consecutiveUnknowns,
- )
- if reviewErr != nil {
- return &Result{Success: false, Output: reviewOutput}, reviewErr
- }
- lastOutput = reviewOutput
-
- if err := appendAndSaveHistory(history, dispatchCtx.artifacts.LoopHistoryPath(), round, codingResult, reviewResult); err != nil {
- return &Result{Success: false, Output: lastOutput}, err
- }
-
- if terminalResult, terminalErr, isTerminal := s.checkReviewTerminal(
- ctx, cfg, dispatchCtx, reviewResult, reviewOutput,
- ); isTerminal {
- return terminalResult, terminalErr
- }
-
- reviewerFeedback, consecutiveUnknowns = nextReviewerFeedback(reviewResult, consecutiveUnknowns)
-
- judgeOutcome, judgeRan := s.runLoopJudgeIfNeeded(ctx, cfg, dispatchCtx, round, maxRounds, reviewerFeedback)
- if judgeRan {
- if judgeOutcome.result != nil {
- return judgeOutcome.result, nil
- }
- lastOutput = judgeOutcome.output
- reviewerFeedback = judgeOutcome.nextFeedback
- }
- }
-
- return &Result{Success: true, ManualRequired: true, Output: lastOutput}, nil
+ return result, nil
}
-// appendAndSaveHistory 将单轮结果追加到历史并持久化到磁盘。
-func appendAndSaveHistory(
- history *LoopHistory,
- historyFilePath string,
- round int,
- codingResult *CodingResult,
- reviewResult *ReviewResult,
-) error {
- if err := history.appendRound(round, codingResult, reviewResult); err != nil {
- return fmt.Errorf("append history round %d: %w", round, err)
- }
- if err := saveLoopHistory(historyFilePath, history); err != nil {
- return fmt.Errorf("save loop history round %d: %w", round, err)
- }
- return nil
-}
-
-// checkReviewTerminal 检查 review 结果是否处于终态(PASS 或 BLOCKED)。
-// 返回值:
-// - result: 终态结果(非 nil 时表示应立即返回)
-// - err: 终态对应的错误
-// - bool: 是否为终态
-func (s *Service) checkReviewTerminal(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- reviewResult *ReviewResult,
- reviewOutput string,
-) (*Result, error, bool) {
- if reviewResult.Passed() {
- result, err := s.finishSuccessfulReview(ctx, cfg, dispatchCtx)
- return result, err, true
- }
- if reviewResult.BlockedStatus() {
- return &Result{
- Success: true,
- Blocked: true,
- ManualRequired: true,
- Output: reviewOutput,
- }, nil, true
- }
- return nil, nil, false
-}
-
-// loopJudgeOutcome 收集价值评估员单次执行后的中间产物,便于主循环判断是否需要中断或调整反馈。
-type loopJudgeOutcome struct {
- result *Result
- output string
- nextFeedback string
-}
-
-// runLoopJudgeIfNeeded 在满足触发条件时执行价值评估员,并返回其对外层循环的影响。
-//
-// 输入参数:
-// - reviewerFeedback: 当前累积的 reviewer 反馈,SHRINK_TASK 时会与之合并
+// toPipelineResult 把 runtime.EngineResult 转成 pipeline.Result。
//
-// 返回值:
-// - loopJudgeOutcome: 评估结果,当 result 为 nil 时表示继续循环
-// - bool: 是否实际执行了价值评估员
-func (s *Service) runLoopJudgeIfNeeded(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- round, maxRounds int,
- reviewerFeedback string,
-) (loopJudgeOutcome, bool) {
- if !shouldRunLoopJudge(cfg, round) {
- return loopJudgeOutcome{}, false
- }
-
- judgeResult, judgeOutput, judgeErr := s.runLoopJudgeRound(ctx, cfg, dispatchCtx, round, maxRounds)
- if judgeErr != nil {
- return loopJudgeOutcome{result: &Result{Success: false, Output: judgeOutput}}, true
- }
-
- outcome := loopJudgeOutcome{output: judgeOutput, nextFeedback: reviewerFeedback}
- switch judgeResult.Decision {
- case loopJudgeDecisionStopManual:
- outcome.result = &Result{Success: true, ManualRequired: true, Output: judgeOutput}
- case loopJudgeDecisionStopBlocked:
- outcome.result = &Result{Success: true, Blocked: true, ManualRequired: true, Output: judgeOutput}
- case loopJudgeDecisionShrinkTask:
- outcome.nextFeedback = buildLoopJudgeFeedback(judgeResult) + "\n\n" + reviewerFeedback
- }
- return outcome, true
-}
-
-// finishSuccessfulReview 在 reviewer 明确 PASS 后,调用 Issue 提交智能体提交最终完成评论。
-func (s *Service) finishSuccessfulReview(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
-) (*Result, error) {
- postResult, postOutput, postErr := s.runIssuePostWithRetry(
- ctx,
- cfg,
- dispatchCtx,
- "completion",
- dispatchCtx.artifacts.ReviewResultPath(),
- )
- if postErr != nil {
- return &Result{
- Success: true,
- ManualRequired: true,
- Output: postOutput,
- }, postErr
- }
-
- if postResult.BlockedStatus() {
- return &Result{
- Success: true,
- Blocked: true,
- ManualRequired: true,
- Output: postOutput,
- }, nil
- }
-
- return &Result{Success: true, Completed: true, Output: postOutput}, nil
-}
-
-// runCodingRound 执行单轮编码任务,并从结果文件中读取结构化结果。
-func (s *Service) runCodingRound(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- issueResult *IssueHandlingResult,
- reviewerFeedback string,
- round int,
-) (*CodingResult, string, error) {
- if err := resetResultFile(dispatchCtx.artifacts.CodingResultPath()); err != nil {
- return nil, "", err
- }
-
- codingPrompt := buildCodingPrompt(
- cfg,
- dispatchCtx.workerID,
- dispatchCtx.issueURL,
- dispatchCtx.platformName,
- dispatchCtx.platformGuide,
- issueResult,
- dispatchCtx.artifacts.CodingResultPath(),
- reviewerFeedback,
- round,
- maxCodingReviewRounds,
- )
-
- codingRunResult, err := s.runner.Run(ctx, agent.TaskKindCoding, codingPrompt)
- if err != nil {
- return nil, safeOutput(codingRunResult), fmt.Errorf("coding round %d failed: %w", round, err)
- }
-
- codingResult, loadErr := loadCodingResult(dispatchCtx.artifacts.CodingResultPath())
- if loadErr != nil {
- return nil, codingRunResult.Output, fmt.Errorf("load coding round %d result: %w", round, loadErr)
- }
-
- return codingResult, codingRunResult.Output, nil
-}
-
-// runReviewRound 执行单轮审查任务,并从结果文件中读取结构化结果。
-func (s *Service) runReviewRound(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- issueResult *IssueHandlingResult,
- codingResult *CodingResult,
- round int,
- consecutiveUnknowns int,
-) (*ReviewResult, string, error) {
- if err := resetResultFile(dispatchCtx.artifacts.ReviewResultPath()); err != nil {
- return nil, "", err
- }
-
- reviewPrompt := buildReviewPrompt(
- cfg,
- dispatchCtx.workerID,
- dispatchCtx.issueURL,
- dispatchCtx.platformName,
- dispatchCtx.platformGuide,
- issueResult,
- codingResult,
- dispatchCtx.artifacts.ReviewResultPath(),
- round,
- maxCodingReviewRounds,
- consecutiveUnknowns,
- )
-
- reviewRunResult, err := s.runner.Run(ctx, agent.TaskKindReview, reviewPrompt)
- if err != nil {
- return nil, safeOutput(reviewRunResult), fmt.Errorf("review round %d failed: %w", round, err)
- }
-
- reviewResult, loadErr := loadReviewResult(dispatchCtx.artifacts.ReviewResultPath())
- if loadErr != nil {
- return nil, reviewRunResult.Output, fmt.Errorf("load review round %d result: %w", round, loadErr)
- }
-
- return reviewResult, reviewRunResult.Output, nil
-}
-
-// runIssuePostWithRetry 执行 Issue 提交阶段,并在被 REJECTED 时允许专门的提交智能体自我修正一次。
-func (s *Service) runIssuePostWithRetry(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- purpose string,
- sourceFilePath string,
-) (*IssuePostResult, string, error) {
- var (
- lastOutput string
- previousFeedback string
- resultFilePath = dispatchCtx.artifacts.IssuePostResultPath(purpose)
- )
-
- for attempt := 1; attempt <= maxIssuePostAttempts; attempt++ {
- if err := resetResultFile(resultFilePath); err != nil {
- return nil, lastOutput, err
- }
-
- postPrompt := buildIssuePostPrompt(
- cfg,
- dispatchCtx.workerID,
- dispatchCtx.issueURL,
- dispatchCtx.platformName,
- dispatchCtx.platformGuide,
- purpose,
- sourceFilePath,
- resultFilePath,
- previousFeedback,
- )
-
- postRunResult, err := s.runner.Run(ctx, agent.TaskKindIssuePost, postPrompt)
- if err != nil {
- return nil, safeOutput(postRunResult), fmt.Errorf("issue post %s attempt %d failed: %w", purpose, attempt, err)
- }
- lastOutput = postRunResult.Output
-
- postResult, loadErr := loadIssuePostResult(resultFilePath)
- if loadErr != nil {
- return nil, lastOutput, fmt.Errorf("load issue post %s attempt %d result: %w", purpose, attempt, loadErr)
- }
-
- if postResult.Posted() || postResult.BlockedStatus() {
- return postResult, lastOutput, nil
- }
-
- previousFeedback = postResult.Feedback
- }
-
- return nil, lastOutput, fmt.Errorf("issue post %s rejected after %d attempts", purpose, maxIssuePostAttempts)
-}
-
-// nextReviewerFeedback 基于本轮 reviewer 结果生成下一轮 coder 要消费的反馈,并维护 UNKNOWN 计数。
-func nextReviewerFeedback(reviewResult *ReviewResult, consecutiveUnknowns int) (string, int) {
- if reviewResult.Unknown() {
- consecutiveUnknowns++
- } else {
- consecutiveUnknowns = 0
- }
-
- feedback := buildReviewerFeedback(reviewResult)
- if consecutiveUnknowns >= maxConsecutiveUnknownReview {
- feedback += "\n\n强制要求:下一轮优先补充证据,不要继续盲改代码。"
- }
-
- return feedback, consecutiveUnknowns
-}
-
-// buildReviewerFeedback 将 reviewer 结构化结果转成下一轮 coder 的输入摘要。
-func buildReviewerFeedback(reviewResult *ReviewResult) string {
- return fmt.Sprintf(
- "审查结论:\n%s\n\n验收项判断:\n%s\n\n缺失项:\n%s\n\n下一步要求:\n%s",
- reviewResult.Summary,
- reviewResult.CheckResult,
- reviewResult.Missing,
- reviewResult.NextAction,
- )
-}
-
-// runLoopJudgeRound 执行单轮价值评估任务。
-func (s *Service) runLoopJudgeRound(
- ctx context.Context,
- cfg *config.Config,
- dispatchCtx dispatchContext,
- round int,
- maxRounds int,
-) (*LoopJudgeResult, string, error) {
- resultFilePath := dispatchCtx.artifacts.LoopJudgeResultPath(round)
- if err := resetResultFile(resultFilePath); err != nil {
- return nil, "", err
- }
-
- judgePrompt := buildLoopJudgePrompt(
- cfg,
- dispatchCtx.workerID,
- dispatchCtx.issueURL,
- dispatchCtx.platformName,
- dispatchCtx.platformGuide,
- round,
- maxRounds,
- resultFilePath,
- dispatchCtx.artifacts.LoopHistoryPath(),
- )
-
- judgeRunResult, err := s.runner.Run(ctx, agent.TaskKindLoopJudge, judgePrompt)
- if err != nil {
- return nil, safeOutput(judgeRunResult), fmt.Errorf("loop judge round %d failed: %w", round, err)
+// 两个结构体字段对齐,转换是纯数据拷贝。
+func toPipelineResult(r *runtime.EngineResult) *Result {
+ if r == nil {
+ return &Result{Success: false}
}
-
- judgeResult, loadErr := loadLoopJudgeResult(resultFilePath)
- if loadErr != nil {
- return nil, judgeRunResult.Output, fmt.Errorf("load loop judge round %d result: %w", round, loadErr)
+ return &Result{
+ Success: r.Success,
+ Completed: r.Completed,
+ Blocked: r.Blocked,
+ ManualRequired: r.ManualRequired,
+ Output: r.Output,
}
-
- return judgeResult, judgeRunResult.Output, nil
-}
-
-// buildLoopJudgeFeedback 将价值评估结果转成下一轮 coder 的输入摘要。
-func buildLoopJudgeFeedback(judgeResult *LoopJudgeResult) string {
- return fmt.Sprintf(
- "价值评估结论:\n决策: %s\n原因: %s\n证据: %s\n下一步: %s",
- judgeResult.Decision,
- judgeResult.Reason,
- judgeResult.Evidence,
- judgeResult.NextAction,
- )
}
// resetResultFile 在每轮调用前删除旧的结果文件,防止读取到上一次残留结果。
+//
+// 由 ArtifactResolverAdapter.ResetResultFile 复用。
func resetResultFile(filePath string) error {
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reset result file %s: %w", filePath, err)
@@ -589,6 +137,8 @@ func resetResultFile(filePath string) error {
}
// resolveWorkerID 解析当前执行实例的稳定 Worker 标识。
+//
+// 优先级: WORKER_ID 环境变量 > hostname > "unknown-worker"。
func resolveWorkerID() string {
if workerID := strings.TrimSpace(os.Getenv("WORKER_ID")); workerID != "" {
return workerID
@@ -601,24 +151,3 @@ func resolveWorkerID() string {
return "unknown-worker"
}
-
-// safeOutput 在上层需要透传 Runner 输出时提供空值兜底。
-func safeOutput(result *agent.RunResult) string {
- if result == nil {
- return ""
- }
-
- return result.Output
-}
-
-// shouldRunLoopJudge 判断当前轮次是否应触发价值评估员。
-//
-// 规则:
-// - 如果 LoopJudgeStartRound <= 0,始终触发
-// - 否则,当 round >= LoopJudgeStartRound 时触发
-func shouldRunLoopJudge(cfg *config.Config, round int) bool {
- if cfg.LoopJudgeStartRound <= 0 {
- return true
- }
- return round >= cfg.LoopJudgeStartRound
-}
diff --git a/internal/pipeline/dispatch_test.go b/internal/pipeline/dispatch_test.go
index 42e3989..1c78092 100644
--- a/internal/pipeline/dispatch_test.go
+++ b/internal/pipeline/dispatch_test.go
@@ -3,16 +3,24 @@
// 核心功能:
// 1. 验证 JSON 结果文件能够被稳定加载和校验
// 2. 验证 repo 到工件目录的映射稳定可预测
+// 3. 验证 Service.Dispatch 委托 runtime.Engine 的端到端接线
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package pipeline
import (
+ "context"
"os"
"path/filepath"
+ "strings"
"testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/agent"
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/runtime"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
)
const testFilePermission = 0o600
@@ -112,3 +120,271 @@ func writeTestFile(t *testing.T, filePath string, content string) {
t.Fatalf("os.WriteFile(%s) unexpected error: %v", filePath, err)
}
}
+
+// TestToPipelineResult 验证 EngineResult → pipeline.Result 的字段映射。
+func TestToPipelineResult(t *testing.T) {
+ cases := []struct {
+ name string
+ in *runtime.EngineResult
+ want Result
+ }{
+ {
+ name: "nil-input",
+ in: nil,
+ want: Result{Success: false},
+ },
+ {
+ name: "completed",
+ in: &runtime.EngineResult{Success: true, Completed: true, Output: "done"},
+ want: Result{Success: true, Completed: true, Output: "done"},
+ },
+ {
+ name: "blocked",
+ in: &runtime.EngineResult{Success: true, Blocked: true, Output: "stuck"},
+ want: Result{Success: true, Blocked: true, Output: "stuck"},
+ },
+ {
+ name: "manual-required",
+ in: &runtime.EngineResult{Success: true, ManualRequired: true, Output: "need human"},
+ want: Result{Success: true, ManualRequired: true, Output: "need human"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := toPipelineResult(tc.in)
+ if got.Success != tc.want.Success || got.Completed != tc.want.Completed ||
+ got.Blocked != tc.want.Blocked || got.ManualRequired != tc.want.ManualRequired ||
+ got.Output != tc.want.Output {
+ t.Fatalf("toPipelineResult() = %+v, want %+v", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestServiceDispatchBlockedPath 验证 issue-handling 返回 BLOCKED 时,Service.Dispatch 返回 Blocked 结果。
+//
+// 端到端接线: Service → Engine → StageExecutor → AgentTool → ArtifactResolverAdapter → Result 转换
+func TestServiceDispatchBlockedPath(t *testing.T) {
+ withTempWorkingDir(t, func() {
+ wf, err := runtime.LoadDefaultWorkflow()
+ if err != nil {
+ t.Fatalf("LoadDefaultWorkflow() unexpected error: %v", err)
+ }
+
+ runner := &scriptedRunner{
+ t: t,
+ steps: []scriptedStep{
+ {
+ kind: agent.TaskKindIssueHandling,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 结果文件:", `{
+ "status": "BLOCKED",
+ "agent": "开发者",
+ "summary": "缺少明确验收标准。",
+ "acceptance": "暂无。",
+ "next_action": "等待补充验收标准。",
+ "comment_body": "当前缺少明确验收标准,暂不进入开发。"
+}`)
+ return &agent.RunResult{Success: true, Output: "issue handling blocked"}, nil
+ },
+ },
+ },
+ }
+
+ service, err := NewService(fakePlatformClient{}, runner, wf)
+ if err != nil {
+ t.Fatalf("NewService() unexpected error: %v", err)
+ }
+
+ cfg := config.New("owner/repo", 51)
+ result, err := service.Dispatch(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("Dispatch() unexpected error: %v", err)
+ }
+
+ if !result.Success || !result.Blocked || result.Completed {
+ t.Fatalf("Dispatch() result = %+v, want blocked success result", result)
+ }
+ })
+}
+
+// TestServiceDispatchCompletedPath 验证 READY → 编码 → 审查 PASS → 完成评论提交 的完整路径。
+func TestServiceDispatchCompletedPath(t *testing.T) {
+ withTempWorkingDir(t, func() {
+ wf, err := runtime.LoadDefaultWorkflow()
+ if err != nil {
+ t.Fatalf("LoadDefaultWorkflow() unexpected error: %v", err)
+ }
+
+ runner := &scriptedRunner{
+ t: t,
+ steps: []scriptedStep{
+ // 1. issue-handling READY
+ {
+ kind: agent.TaskKindIssueHandling,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 结果文件:", `{
+ "status": "READY",
+ "agent": "开发者",
+ "summary": "任务可以进入开发。",
+ "acceptance": "按验收标准完成并提供证据。",
+ "next_action": "进入编码。",
+ "comment_body": "已接单。"
+}`)
+ return &agent.RunResult{Success: true, Output: "issue handling ready"}, nil
+ },
+ },
+ // 2. issue-post-intake POSTED
+ {
+ kind: agent.TaskKindIssuePost,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
+ "status": "POSTED",
+ "summary": "接单回执已提交。",
+ "feedback": "已成功提交。",
+ "next_action": "继续后续流程。"
+}`)
+ return &agent.RunResult{Success: true, Output: "issue post intake ok"}, nil
+ },
+ },
+ // 3. coding DONE
+ {
+ kind: agent.TaskKindCoding,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 结果文件:", `{
+ "status": "DONE",
+ "summary": "编码完成。",
+ "evidence": "已补充实现与验证证据。",
+ "acceptance_check": "按验收标准完成自检。",
+ "next_action": "等待审查。"
+}`)
+ return &agent.RunResult{Success: true, Output: "coding done"}, nil
+ },
+ },
+ // 4. review PASS
+ {
+ kind: agent.TaskKindReview,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 结果文件:", `{
+ "status": "PASS",
+ "summary": "审查通过。",
+ "check_result": "验收项全部通过。",
+ "missing": "无",
+ "next_action": "允许结束 loop。",
+ "comment_body": "任务已完成。"
+}`)
+ return &agent.RunResult{Success: true, Output: "review pass"}, nil
+ },
+ },
+ // 5. issue-post-completion POSTED
+ {
+ kind: agent.TaskKindIssuePost,
+ run: func(task string) (*agent.RunResult, error) {
+ writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
+ "status": "POSTED",
+ "summary": "完成评论已提交。",
+ "feedback": "已成功提交完成评论。",
+ "next_action": "任务结束。"
+}`)
+ return &agent.RunResult{Success: true, Output: "issue post completion ok"}, nil
+ },
+ },
+ },
+ }
+
+ service, err := NewService(fakePlatformClient{}, runner, wf)
+ if err != nil {
+ t.Fatalf("NewService() unexpected error: %v", err)
+ }
+
+ cfg := config.New("owner/repo", 52)
+ result, err := service.Dispatch(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("Dispatch() unexpected error: %v", err)
+ }
+
+ if !result.Success || !result.Completed {
+ t.Fatalf("Dispatch() result = %+v, want completed success result", result)
+ }
+ })
+}
+
+// TestServiceDispatchCustomWorkflow 验证用户自定义 workflow 配置(非默认)能驱动 Service.Dispatch,
+// 且 --prompts-dir overlay 的自定义模板内容能到达 runner。
+//
+// 端到端覆盖:
+// - 自定义 tool name(my-coder,不在默认 workflow 中)
+// - 自定义 prompt 模板内容(通过 WithPromptsDir overlay 覆盖 coding.md)
+// - 单 stage 无 loop 的最小 pipeline
+func TestServiceDispatchCustomWorkflow(t *testing.T) {
+ withTempWorkingDir(t, func() {
+ // 1. 准备外部 prompts 目录,覆盖 coding.md
+ promptsDir := t.TempDir()
+ customTemplate := "自定义标记-CODING\n仓库: {{.Repo}}\nIssue: #{{.IssueNumber}}\n结果文件: {{.ResultFilePath}}\n"
+ if err := os.WriteFile(filepath.Join(promptsDir, "coding.md"), []byte(customTemplate), testFilePermission); err != nil {
+ t.Fatalf("os.WriteFile(coding.md) unexpected error: %v", err)
+ }
+
+ // 2. 编程式构造最小自定义 workflow
+ wf := &schema.Workflow{
+ Version: "1",
+ Name: "test-custom-workflow",
+ Tools: []schema.Tool{{
+ Name: "my-coder",
+ Type: "agent",
+ Skill: "自定义编码技能",
+ PromptTemplate: "coding",
+ DisplayName: "自定义开发者",
+ Aliases: []string{"自定义开发者"},
+ }},
+ Pipeline: []schema.PipelineStep{{
+ Stage: &schema.Stage{
+ Name: "custom-coding",
+ Tool: "my-coder",
+ Output: "custom_result.json",
+ },
+ }},
+ }
+
+ // 3. scriptedRunner 验证自定义 prompt 内容到达
+ runner := &scriptedRunner{
+ t: t,
+ steps: []scriptedStep{
+ {
+ kind: agent.TaskKindCoding,
+ run: func(task string) (*agent.RunResult, error) {
+ if !strings.Contains(task, "自定义标记-CODING") {
+ t.Errorf("custom prompt marker missing, task=\n%s", task)
+ }
+ if !strings.Contains(task, "owner/custom-repo") {
+ t.Errorf("repo not rendered in prompt, task=\n%s", task)
+ }
+ writeJSONFromPrompt(t, task, "结果文件:", `{
+ "status": "DONE",
+ "summary": "自定义编码完成。"
+}`)
+ return &agent.RunResult{Success: true, Output: "custom coding done"}, nil
+ },
+ },
+ },
+ }
+
+ // 4. 构造 service,传入 WithPromptsDir
+ service, err := NewService(fakePlatformClient{}, runner, wf, runtime.WithPromptsDir(promptsDir))
+ if err != nil {
+ t.Fatalf("NewService() unexpected error: %v", err)
+ }
+
+ // 5. 执行 Dispatch
+ cfg := config.New("owner/custom-repo", 77)
+ result, err := service.Dispatch(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("Dispatch() unexpected error: %v", err)
+ }
+
+ if !result.Success || !result.Completed {
+ t.Fatalf("Dispatch() result = %+v, want completed success result", result)
+ }
+ })
+}
diff --git a/internal/pipeline/loop_judge_test.go b/internal/pipeline/loop_judge_test.go
index 2a7ea16..9b8b64c 100644
--- a/internal/pipeline/loop_judge_test.go
+++ b/internal/pipeline/loop_judge_test.go
@@ -1,12 +1,15 @@
-// Package pipeline 覆盖 loop judge 的契约校验与调度接线测试。
+// Package pipeline 覆盖 loop judge 的契约校验与调度接线测试辅助。
//
// 核心功能:
// 1. 验证 loop judge 结果文件的结构化约束,避免非法决策污染状态机
-// 2. 验证 value judge 接入后能够真实改变 harness 的继续、收缩与停止行为
+// 2. 提供 scriptedRunner / fakePlatformClient 等测试辅助,供 dispatch 集成测试复用
+//
+// 注: 原覆盖 dispatch 状态机的两个测试(STOP_MANUAL / SHRINK_TASK)已在阶段 8 重构中删除,
+// 新的 Service.Dispatch 行为由 internal/runtime 包的引擎测试覆盖。
//
// 开发维护: AI Assistant
// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
+// 更新时间: 2026-07-05
package pipeline
import (
@@ -19,7 +22,6 @@ import (
"testing"
"github.com/JiGuangWorker/code-bee/internal/agent"
- "github.com/JiGuangWorker/code-bee/internal/config"
)
// TestLoadLoopJudgeResultRejectsInvalidDecision 验证非法决策值会被读取层及时阻断。
@@ -38,269 +40,9 @@ func TestLoadLoopJudgeResultRejectsInvalidDecision(t *testing.T) {
}
}
-// TestDispatchStopsWhenLoopJudgeRequestsManual 验证 loop judge 返回 STOP_MANUAL 时,自动循环会被立即中断。
-func TestDispatchStopsWhenLoopJudgeRequestsManual(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 7)
- cfg.LoopJudgeStartRound = 2
- cfg.MaxCodingReviewRounds = 5
-
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- {
- kind: agent.TaskKindIssueHandling,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "READY",
- "agent": "开发者",
- "summary": "任务可以进入开发。",
- "acceptance": "验收标准存在。",
- "next_action": "进入编码。",
- "comment_body": "已接单。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue handling ok"}, nil
- },
- },
- {
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "POSTED",
- "summary": "接单回执已提交。",
- "feedback": "已发布接单说明。",
- "next_action": "进入编码阶段。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue post intake ok"}, nil
- },
- },
- {
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "DONE",
- "summary": "第一轮编码完成。",
- "evidence": "已修改代码。",
- "acceptance_check": "完成基础实现。",
- "next_action": "等待审查。"
-}`)
- return &agent.RunResult{Success: true, Output: "coding round1 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "FAIL",
- "summary": "第一轮审查未通过。",
- "check_result": "边界条件未覆盖。",
- "missing": "缺少异常路径说明。",
- "next_action": "补充异常路径与验证证据。",
- "comment_body": ""
-}`)
- return &agent.RunResult{Success: true, Output: "review round1 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "DONE",
- "summary": "第二轮编码完成。",
- "evidence": "补充了更多代码说明。",
- "acceptance_check": "仍存在缺口。",
- "next_action": "再次等待审查。"
-}`)
- return &agent.RunResult{Success: true, Output: "coding round2 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "FAIL",
- "summary": "第二轮审查仍未通过。",
- "check_result": "同一问题仍未收敛。",
- "missing": "缺少明确验证证据。",
- "next_action": "不要继续盲改,先判断是否还有继续价值。",
- "comment_body": ""
-}`)
- return &agent.RunResult{Success: true, Output: "review round2 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindLoopJudge,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 评估结果文件:", `{
- "decision": "STOP_MANUAL",
- "reason": "问题连续两轮重复,继续自动循环价值很低。",
- "evidence": "round-01 与 round-02 都指出缺少验证证据,且缺失项高度重复。",
- "confidence": "HIGH",
- "next_action": "停止自动循环,转人工介入。"
-}`)
- return &agent.RunResult{Success: true, Output: "loop judge stop manual"}, nil
- },
- },
- },
- }
-
- service := NewService(fakePlatformClient{}, runner)
- result, err := service.Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.ManualRequired || result.Completed || result.Blocked {
- t.Fatalf("Dispatch() result = %+v, want success=true manual_required=true completed=false blocked=false", result)
- }
-
- historyFilePath := filepath.Join(".code-bee", "runs", "owner__repo", "issue-7", "loop_history.json")
- history := readLoopHistoryForTest(t, historyFilePath)
- if len(history.Rounds) != 2 {
- t.Fatalf("loop history rounds = %d, want 2", len(history.Rounds))
- }
- })
-}
-
-// TestDispatchShrinkTaskFeedbackFlowsToNextCodingRound 验证 SHRINK_TASK 会进入下一轮 coder 输入,而不是被调度器吞掉。
-func TestDispatchShrinkTaskFeedbackFlowsToNextCodingRound(t *testing.T) {
- withTempWorkingDir(t, func() {
- cfg := config.New("owner/repo", 8)
- cfg.LoopJudgeStartRound = 1
- cfg.MaxCodingReviewRounds = 4
-
- runner := &scriptedRunner{
- t: t,
- steps: []scriptedStep{
- {
- kind: agent.TaskKindIssueHandling,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "READY",
- "agent": "开发者",
- "summary": "任务可以进入开发。",
- "acceptance": "按验收标准完成并提供证据。",
- "next_action": "进入编码。",
- "comment_body": "已接单。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue handling ok"}, nil
- },
- },
- {
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "POSTED",
- "summary": "接单回执已提交。",
- "feedback": "已发布接单说明。",
- "next_action": "进入编码阶段。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue post intake ok"}, nil
- },
- },
- {
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "DONE",
- "summary": "第一轮编码完成。",
- "evidence": "完成基础实现。",
- "acceptance_check": "缺少验证证据。",
- "next_action": "等待审查。"
-}`)
- return &agent.RunResult{Success: true, Output: "coding round1 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "FAIL",
- "summary": "第一轮审查未通过。",
- "check_result": "实现方向基本正确,但证据不足。",
- "missing": "缺少测试与验证说明。",
- "next_action": "优先补证据。",
- "comment_body": ""
-}`)
- return &agent.RunResult{Success: true, Output: "review round1 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindLoopJudge,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 评估结果文件:", `{
- "decision": "SHRINK_TASK",
- "reason": "当前更适合先补证据,而不是继续扩改代码。",
- "evidence": "round-01 的 review 已明确指出缺的是测试与验证说明。",
- "confidence": "HIGH",
- "next_action": "下一轮聚焦补测试、补验证结果,不要继续扩展实现范围。"
-}`)
- return &agent.RunResult{Success: true, Output: "loop judge shrink task"}, nil
- },
- },
- {
- kind: agent.TaskKindCoding,
- run: func(task string) (*agent.RunResult, error) {
- if !strings.Contains(task, "价值评估结论:") {
- t.Fatal("second coding round prompt does not contain loop judge feedback")
- }
-
- if !strings.Contains(task, "下一轮聚焦补测试、补验证结果") {
- t.Fatal("second coding round prompt does not contain shrink-task next_action")
- }
-
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "DONE",
- "summary": "第二轮完成证据补齐。",
- "evidence": "新增测试结果与验证说明。",
- "acceptance_check": "验收项已有对应证据。",
- "next_action": "再次等待审查。"
-}`)
- return &agent.RunResult{Success: true, Output: "coding round2 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindReview,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 结果文件:", `{
- "status": "PASS",
- "summary": "第二轮审查通过。",
- "check_result": "验收项与证据均已覆盖。",
- "missing": "无",
- "next_action": "允许结束 loop。",
- "comment_body": "任务已完成,验收通过。"
-}`)
- return &agent.RunResult{Success: true, Output: "review round2 ok"}, nil
- },
- },
- {
- kind: agent.TaskKindIssuePost,
- run: func(task string) (*agent.RunResult, error) {
- writeJSONFromPrompt(t, task, "- 提交结果文件:", `{
- "status": "POSTED",
- "summary": "完成回执已提交。",
- "feedback": "已发布最终完成说明。",
- "next_action": "结束流程。"
-}`)
- return &agent.RunResult{Success: true, Output: "issue post completion ok"}, nil
- },
- },
- },
- }
-
- service := NewService(fakePlatformClient{}, runner)
- result, err := service.Dispatch(context.Background(), cfg)
- if err != nil {
- t.Fatalf("Dispatch() unexpected error: %v", err)
- }
-
- if !result.Success || !result.Completed || result.ManualRequired || result.Blocked {
- t.Fatalf("Dispatch() result = %+v, want success=true completed=true manual_required=false blocked=false", result)
- }
- })
-}
-
// scriptedRunner 用脚本化步骤模拟多阶段智能体执行。
+//
+// 按 kind 顺序匹配预设步骤,供 Service.Dispatch 集成测试复用。
type scriptedRunner struct {
t *testing.T
steps []scriptedStep
@@ -402,6 +144,8 @@ func extractPathFromPrompt(t *testing.T, prompt string, prefix string) string {
}
// readLoopHistoryForTest 读取并反序列化 loop_history.json,供断言逐轮历史。
+//
+//nolint:unused // 保留供后续 loop 状态机测试启用时使用
func readLoopHistoryForTest(t *testing.T, filePath string) *LoopHistory {
t.Helper()
diff --git a/internal/pipeline/prompts.go b/internal/pipeline/prompts.go
deleted file mode 100644
index 197dd64..0000000
--- a/internal/pipeline/prompts.go
+++ /dev/null
@@ -1,421 +0,0 @@
-// Package pipeline 负责构造四阶段 loop 所需的提示词。
-//
-// 核心功能:
-// 1. 将平台技能包、Issue 上下文和文件契约统一拼装给不同角色的智能体
-// 2. 明确要求智能体把结果写入文件,而不是仅通过 stdout 回传自由文本
-//
-// 开发维护: AI Assistant
-// 创建时间: 2026-07-04
-// 更新时间: 2026-07-04
-package pipeline
-
-import (
- "fmt"
-
- "github.com/JiGuangWorker/code-bee/internal/config"
-)
-
-// buildIssueHandlingPrompt 构造 Issue 处理阶段提示词。
-func buildIssueHandlingPrompt(
- cfg *config.Config,
- workerID string,
- issueURL string,
- platformName string,
- platformGuide string,
- resultFilePath string,
- postFeedback string,
-) string {
- return fmt.Sprintf(
- `你是蜂巢 Issue 处理智能体。
-
-你的职责:
-1. 自己查看目标 Issue
-2. 判断当前 Issue 是接单还是阻塞
-3. 把结构化结果写入指定 JSON 文件
-4. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
-
-你必须把结果写入以下文件:
-- 结果文件: %s
-
-结果文件必须是合法 JSON,字段固定如下:
-{
- "status": "READY 或 BLOCKED",
- "agent": "开发者 / 技术负责人 / 架构师 / 产品经理 / QA负责人 / UI负责人 之一",
- "summary": "你对任务的理解摘要",
- "acceptance": "验收标准或完成判断依据",
- "next_action": "下一步动作",
- "comment_body": "准备提交到 Issue 的接单或阻塞评论正文"
-}
-
-上下文:
-- Worker: %s
-- Default Agent: @%s
-- Repo: %s
-- Issue: #%d
-- URL: %s
-- Platform: %s
-
-平台技能包说明:
-%s
-
-Issue 提交智能体上一轮反馈(首轮为空):
-%s
-
-阶段要求:
-1. 你必须自己使用平台工具查看 Issue 原文和评论
-2. 你必须自己判断当前 Issue 是否可开工
-3. 如果不能开工,写入 "status": "BLOCKED"
-4. 如果可以开工,写入 "status": "READY"
-5. 你必须选择最合适的编码角色;无法判断时回退为默认角色 %s
-6. 你必须同时写出可直接发布的 Issue 评论正文,放到 "comment_body"
-7. 结果文件必须可被机器稳定解析,不能写 JSON 之外的解释
-8. 不要直接提交 Issue 评论`,
- resultFilePath,
- workerID,
- cfg.DefaultAgent,
- cfg.Repo,
- cfg.IssueNumber,
- issueURL,
- platformName,
- platformGuide,
- postFeedback,
- cfg.DefaultAgent,
- )
-}
-
-// buildCodingPrompt 构造编码阶段提示词。
-func buildCodingPrompt(
- cfg *config.Config,
- workerID string,
- issueURL string,
- platformName string,
- platformGuide string,
- issueResult *IssueHandlingResult,
- resultFilePath string,
- reviewerFeedback string,
- round int,
- maxRounds int,
-) string {
- return fmt.Sprintf(
- buildCodingPromptTemplate(),
- issueResult.Agent,
- resultFilePath,
- workerID,
- cfg.Repo,
- cfg.IssueNumber,
- issueURL,
- platformName,
- round,
- maxRounds,
- platformGuide,
- issueResult.Summary,
- issueResult.Acceptance,
- reviewerFeedback,
- )
-}
-
-// buildCodingPromptTemplate 返回编码阶段长提示词模板。
-//
-// 设计说明:
-// - 将长模板抽出为独立 helper,避免构造函数承担过多行数,同时让 lint 更容易通过
-func buildCodingPromptTemplate() string {
- return `你是蜂巢编码智能体 @%s。
-
-你的职责:
-1. 自己查看 Issue 并完成实现、自验、必要的 PR 操作
-2. 把本轮结果写入指定 JSON 文件
-3. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
-
-最小切入原则(修正/开发/推进均适用,违反即停手):
-- 修正:只改导致问题的根因行。不顺手重构周边代码,不修"附近"的其它问题,不做纯格式化,不改未在反馈中提到的文件
-- 开发:只实现 Issue 验收标准要求的功能。不写"以防万一"的抽象,不加未要求的配置项或扩展点,不引入标准库/已有依赖能搞定的外部依赖
-- 推进:每轮只做一件聚焦的事。不把多个不相关改动打包进同一轮;下一轮基于本轮证据再决定是否扩大范围
-
-判断标准:你能为每一行改动找到和"当前 Issue 验收标准"或"本轮 reviewer 反馈"的直接关联吗?找不到 → 撤回该行。
-发现自己处于 Kitchen Sink(修水龙头拆厨房)、Runaway Refactor(连锁修改)、Over Abstraction(为以后而抽象)任一状态 → 立即停手,回退到本轮最小改动再提交。
-
-你必须把结果写入以下文件:
-- 结果文件: %s
-
-结果文件必须是合法 JSON,字段固定如下:
-{
- "status": "DONE / IN_PROGRESS / BLOCKED",
- "summary": "本轮工作总结",
- "evidence": "可验证证据",
- "acceptance_check": "按验收标准逐条自检结果",
- "next_action": "下一步动作"
-}
-
-上下文:
-- Worker: %s
-- Repo: %s
-- Issue: #%d
-- URL: %s
-- Platform: %s
-- Round: %d/%d
-
-平台技能包说明:
-%s
-
-Issue 摘要:
-%s
-
-验收标准:
-%s
-
-上一轮 reviewer 反馈(首轮为空):
-%s
-
-阶段要求:
-1. 你必须自己查看 Issue 原文并开展编码工作
-2. 你必须根据 reviewer 反馈修复问题或补充证据
-3. 如果当前已准备好接受审查,写入 "status": "DONE"
-4. 如果还需要继续开发或补证据,写入 "status": "IN_PROGRESS"
-5. 如果因权限、环境、外部依赖等无法继续,写入 "status": "BLOCKED"
-6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释
-7. 不要直接提交 Issue 评论
-8. 每一行改动必须能说出与 Issue 验收标准或本轮 reviewer 反馈的直接关联;做不到的撤回该行,不要硬提交`
-}
-
-// buildReviewPrompt 构造审查阶段提示词。
-func buildReviewPrompt(
- cfg *config.Config,
- workerID string,
- issueURL string,
- platformName string,
- platformGuide string,
- issueResult *IssueHandlingResult,
- codingResult *CodingResult,
- resultFilePath string,
- round int,
- maxRounds int,
- consecutiveUnknowns int,
-) string {
- return fmt.Sprintf(
- buildReviewPromptTemplate(),
- cfg.ReviewerAgent,
- resultFilePath,
- workerID,
- cfg.ReviewerAgent,
- cfg.Repo,
- cfg.IssueNumber,
- issueURL,
- platformName,
- round,
- maxRounds,
- consecutiveUnknowns,
- platformGuide,
- issueResult.Summary,
- issueResult.Acceptance,
- codingResult.Summary,
- codingResult.Evidence,
- codingResult.AcceptanceCheck,
- )
-}
-
-// buildReviewPromptTemplate 返回审查阶段长提示词模板。
-//
-// 设计说明:
-// - 将长模板抽出为独立 helper,避免构造函数承担过多行数,同时让 lint 更容易通过
-func buildReviewPromptTemplate() string {
- return `你是蜂巢审查智能体 @%s。
-
-你的职责:
-1. 自己查看 Issue、代码现状、评论、PR 与证据
-2. 判断当前结果是否满足 Issue 要求
-3. 把审查结论写入指定 JSON 文件
-4. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
-
-你必须把结果写入以下文件:
-- 结果文件: %s
-
-结果文件必须是合法 JSON,字段固定如下:
-{
- "status": "PASS / FAIL / UNKNOWN / BLOCKED",
- "summary": "审查总结",
- "check_result": "按验收标准逐条判断结果",
- "missing": "仍缺失的实现或证据",
- "next_action": "下一轮 coder 应执行的动作",
- "comment_body": "若 status=PASS,则这里写准备提交到 Issue 的完成评论正文;否则可留空字符串"
-}
-
-上下文:
-- Worker: %s
-- Reviewer: @%s
-- Repo: %s
-- Issue: #%d
-- URL: %s
-- Platform: %s
-- Round: %d/%d
-- Consecutive UNKNOWN Before This Round: %d
-
-平台技能包说明:
-%s
-
-Issue 摘要:
-%s
-
-验收标准:
-%s
-
-Coder 本轮总结:
-%s
-
-Coder 本轮证据:
-%s
-
-Coder 自检结果:
-%s
-
-阶段要求:
-1. 只有在你能够明确确认满足要求时,才能写入 "status": "PASS"
-2. 如果你已确认不满足要求,写入 "status": "FAIL"
-3. 如果你暂时无法确认是否满足要求,写入 "status": "UNKNOWN"
-4. 如果因权限、环境或外部依赖无法继续审查,写入 "status": "BLOCKED"
-5. UNKNOWN 绝不能被当成 PASS
-6. 当证据不足时,应优先要求 coder 补证据,而不是要求它盲目继续改代码
-7. 当 status=PASS 时,你必须写出可直接发布的完成评论正文,放到 "comment_body"
-8. 不要直接提交 Issue 评论`
-}
-
-// buildIssuePostPrompt 构造 Issue 提交阶段提示词。
-func buildIssuePostPrompt(
- cfg *config.Config,
- workerID string,
- issueURL string,
- platformName string,
- platformGuide string,
- purpose string,
- sourceFilePath string,
- resultFilePath string,
- previousFeedback string,
-) string {
- return fmt.Sprintf(
- `你是蜂巢 Issue 提交智能体 @%s。
-
-你的职责:
-1. 读取指定结果文件
-2. 检查该结果文件里的 "comment_body" 是否足以直接发布为合格的 Issue 评论
-3. 如果合格,则由你自己完成 Issue 评论提交
-4. 如果不合格,则不要提交评论,而是把修正反馈写入指定结果文件
-
-输入文件:
-- 源结果文件: %s
-
-输出文件:
-- 提交结果文件: %s
-
-提交结果文件必须是合法 JSON,字段固定如下:
-{
- "status": "POSTED / REJECTED / BLOCKED",
- "summary": "本次提交动作总结",
- "feedback": "若被拒绝,需要给上游阶段的修正反馈;若已提交,也要写明提交了什么",
- "next_action": "下一步动作"
-}
-
-上下文:
-- Worker: %s
-- Repo: %s
-- Issue: #%d
-- URL: %s
-- Platform: %s
-- Purpose: %s
-
-平台技能包说明:
-%s
-
-上一轮 Issue 提交反馈(首轮为空):
-%s
-
-阶段要求:
-1. 你必须自己读取源结果文件并校验其完整性,重点检查 "comment_body"
-2. 只有当源结果中的 "comment_body" 非空且评论格式合理时,才允许提交 Issue 评论,并写入 "status": "POSTED"
-3. 如果源结果缺少 "comment_body" 或评论内容不足以安全提交,写入 "status": "REJECTED",并明确说明缺什么
-4. 如果因权限、环境或外部依赖无法提交,写入 "status": "BLOCKED"
-5. 你可以自己使用平台工具完成最终评论提交
-6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释`,
- cfg.IssuePostAgent,
- sourceFilePath,
- resultFilePath,
- workerID,
- cfg.Repo,
- cfg.IssueNumber,
- issueURL,
- platformName,
- purpose,
- platformGuide,
- previousFeedback,
- )
-}
-
-// buildLoopJudgePrompt 构造价值评估阶段提示词。
-func buildLoopJudgePrompt(
- cfg *config.Config,
- workerID string,
- issueURL string,
- platformName string,
- platformGuide string,
- round int,
- maxRounds int,
- resultFilePath string,
- historyFilePath string,
-) string {
- return fmt.Sprintf(
- `你是蜂巢价值评估员。
-
-你的职责:
-1. 自己读取 coder-reviewer 逐轮历史文件
-2. 判断当前自动循环是否还有继续价值
-3. 把评估结论写入指定 JSON 文件
-
-输入文件:
-- 逐轮历史: %s
-
-输出文件:
-- 评估结果文件: %s
-
-评估结果文件必须是合法 JSON,字段固定如下:
-{
- "decision": "CONTINUE / SHRINK_TASK / STOP_MANUAL / STOP_BLOCKED",
- "reason": "决策详细说明",
- "evidence": "支撑决策的引用证据,必须引用具体轮次或事实",
- "confidence": "HIGH / MEDIUM / LOW",
- "next_action": "本次评估后的下一步动作"
-}
-
-决策说明:
-- CONTINUE: 当前问题在收敛,继续自动循环仍然有价值
-- SHRINK_TASK: 继续自动循环,但下一轮应缩小任务范围,优先补证据或聚焦核心问题
-- STOP_MANUAL: 自动循环已无价值,需要人工接管
-- STOP_BLOCKED: 当前问题依赖外部条件,继续自动循环没有意义
-
-上下文:
-- Worker: %s
-- Repo: %s
-- Issue: #%d
-- URL: %s
-- Platform: %s
-- Round: %d/%d
-
-平台技能包说明:
-%s
-
-阶段要求:
-1. 你必须自己读取逐轮历史文件,理解当前循环进展
-2. 你必须基于历史中的具体证据来判断,不能凭空猜测
-3. 如果连续两轮指出相同问题且没有收敛迹象,应优先考虑 STOP_MANUAL
-4. 如果问题核心是外部依赖缺失,应优先考虑 STOP_BLOCKED
-5. 如果问题和方向正确但证据不足,应优先考虑 SHRINK_TASK
-6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释`,
- historyFilePath,
- resultFilePath,
- workerID,
- cfg.Repo,
- cfg.IssueNumber,
- issueURL,
- platformName,
- round,
- maxRounds,
- platformGuide,
- )
-}
diff --git a/internal/pipeline/runtime_adapter.go b/internal/pipeline/runtime_adapter.go
new file mode 100644
index 0000000..328b876
--- /dev/null
+++ b/internal/pipeline/runtime_adapter.go
@@ -0,0 +1,168 @@
+// 本文件把 pipeline.ArtifactSet 适配为 runtime.ArtifactResolver 接口。
+//
+// 设计说明:
+// 1. runtime 包不依赖 pipeline(避免循环依赖),通过接口抽象文件契约
+// 2. stageName → 文件路径的映射集中在此处,复用 ArtifactSet 的现有方法
+// 3. LoadResult 路由到 contracts.go 的 typed loader,保留结果校验能力
+// 4. coding/review 使用非轮次路径(每轮覆盖),与原 dispatch.go 行为一致;
+// loop-judge 使用轮次路径,便于回放
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package pipeline
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/JiGuangWorker/code-bee/internal/runtime"
+)
+
+// ArtifactResolverAdapter 把 ArtifactSet 适配为 runtime.ArtifactResolver。
+//
+// stageName 映射约定(与 default_workflow.yaml 中的 stage name 对齐):
+// - issue-handling: 接单阶段结果
+// - coding: 编码阶段结果(每轮覆盖)
+// - review: 审查阶段结果(每轮覆盖)
+// - issue-post-intake / issue-post-blocked / issue-post-completion: Issue 提交结果,按 purpose 区分文件
+// - loop-judge: 价值评估结果(按轮次存盘)
+type ArtifactResolverAdapter struct {
+ artifacts *ArtifactSet
+}
+
+// NewArtifactResolverAdapter 创建适配器实例。
+func NewArtifactResolverAdapter(a *ArtifactSet) *ArtifactResolverAdapter {
+ return &ArtifactResolverAdapter{artifacts: a}
+}
+
+// 编译期断言:确保 ArtifactResolverAdapter 实现 runtime.ArtifactResolver 接口。
+var _ runtime.ArtifactResolver = (*ArtifactResolverAdapter)(nil)
+
+// ResolveResultFile 返回指定 stage 的结果文件路径。
+//
+// 参数:
+// - stageName: workflow.yaml 中 stage 的 name
+// - args: stage 的 args map(issue-post 用 purpose 字段)
+// - loopRound: 当前 loop 轮次,0 表示非 loop 内执行
+func (a *ArtifactResolverAdapter) ResolveResultFile(stageName string, args map[string]any, loopRound int) string {
+ switch stageName {
+ case "issue-handling":
+ return a.artifacts.IssueHandlingResultPath()
+
+ case "coding":
+ // 编码结果每轮覆盖,与原 dispatch.go 行为一致
+ return a.artifacts.CodingResultPath()
+
+ case "review":
+ // 审查结果每轮覆盖,与原 dispatch.go 行为一致
+ return a.artifacts.ReviewResultPath()
+
+ case "loop-judge":
+ if loopRound <= 0 {
+ loopRound = 1
+ }
+ return a.artifacts.LoopJudgeResultPath(loopRound)
+ }
+
+ // issue-post-* 系列:按 purpose 区分文件
+ if strings.HasPrefix(stageName, "issue-post-") {
+ purpose := getStringFromArgs(args, "purpose")
+ if purpose == "" {
+ purpose = "generic"
+ }
+ return a.artifacts.IssuePostResultPath(purpose)
+ }
+
+ // 兜底:BaseDir/.json
+ return filepath.Join(a.artifacts.BaseDir, stageName+".json")
+}
+
+// ResetResultFile 删除旧的结果文件,防止读到上一轮残留。
+func (a *ArtifactResolverAdapter) ResetResultFile(path string) error {
+ return resetResultFile(path)
+}
+
+// LoadResult 从结果文件加载结构化数据。
+//
+// 按 stageName 路由到对应的 typed loader(保留校验),再转成 map[string]any。
+// 未知 stageName 直接 json.Unmarshal 到 map(无校验)。
+func (a *ArtifactResolverAdapter) LoadResult(stageName string, path string) (map[string]any, error) {
+ var typed any
+ var err error
+
+ switch stageName {
+ case "issue-handling":
+ typed, err = loadIssueHandlingResult(path)
+ case "coding":
+ typed, err = loadCodingResult(path)
+ case "review":
+ typed, err = loadReviewResult(path)
+ case "loop-judge":
+ typed, err = loadLoopJudgeResult(path)
+ default:
+ if strings.HasPrefix(stageName, "issue-post-") {
+ typed, err = loadIssuePostResult(path)
+ } else {
+ return loadRawMap(path)
+ }
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("LoadResult[%s]: %w", stageName, err)
+ }
+
+ return structToMap(typed)
+}
+
+// LoopHistoryPath 返回 loop 历史文件路径。
+func (a *ArtifactResolverAdapter) LoopHistoryPath() string {
+ return a.artifacts.LoopHistoryPath()
+}
+
+// getStringFromArgs 从 args map 中取字符串值。
+func getStringFromArgs(args map[string]any, key string) string {
+ if args == nil {
+ return ""
+ }
+ v, ok := args[key]
+ if !ok {
+ return ""
+ }
+ s, ok := v.(string)
+ if !ok {
+ return ""
+ }
+ return s
+}
+
+// structToMap 把 typed struct 通过 JSON 序列化/反序列化转成 map[string]any。
+//
+// 用于把 contracts.go 的 typed Result(如 *CodingResult)转成 runtime 期望的 map 形式,
+// 保留字段名(由 json tag 决定)。
+func structToMap(v any) (map[string]any, error) {
+ data, err := json.Marshal(v)
+ if err != nil {
+ return nil, fmt.Errorf("structToMap marshal: %w", err)
+ }
+
+ var m map[string]any
+ if err := json.Unmarshal(data, &m); err != nil {
+ return nil, fmt.Errorf("structToMap unmarshal: %w", err)
+ }
+
+ return m, nil
+}
+
+// loadRawMap 直接把文件内容反序列化到 map(无业务校验)。
+//
+// 用于 workflow.yaml 中未识别的 stageName,保证向前兼容。
+func loadRawMap(path string) (map[string]any, error) {
+ var m map[string]any
+ if err := loadJSONFile(path, &m); err != nil {
+ return nil, fmt.Errorf("loadRawMap: %w", err)
+ }
+ return m, nil
+}
diff --git a/internal/pipeline/runtime_adapter_test.go b/internal/pipeline/runtime_adapter_test.go
new file mode 100644
index 0000000..62ce98f
--- /dev/null
+++ b/internal/pipeline/runtime_adapter_test.go
@@ -0,0 +1,169 @@
+// Package pipeline 覆盖 ArtifactResolverAdapter 的单元测试。
+//
+// 核心功能:
+// 1. 验证 stageName → 文件路径的映射符合 default_workflow.yaml 约定
+// 2. 验证 LoadResult 按 stageName 路由到对应的 typed loader
+// 3. 验证 ResetResultFile 和 LoopHistoryPath 的行为
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+package pipeline
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestAdapterResolveResultFileMapping 验证各 stageName 的结果文件路径映射。
+func TestAdapterResolveResultFileMapping(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ cases := []struct {
+ name string
+ stageName string
+ args map[string]any
+ loopRound int
+ wantSuffix string
+ }{
+ {name: "issue-handling", stageName: "issue-handling", wantSuffix: "issue_intake_result.json"},
+ {name: "coding-non-loop", stageName: "coding", loopRound: 0, wantSuffix: "coding_result.json"},
+ {name: "coding-in-loop", stageName: "coding", loopRound: 2, wantSuffix: "coding_result.json"},
+ {name: "review-non-loop", stageName: "review", loopRound: 0, wantSuffix: "review_result.json"},
+ {name: "loop-judge-round-1", stageName: "loop-judge", loopRound: 1, wantSuffix: filepath.Join("round-01", "loop_judge_result.json")},
+ {name: "loop-judge-round-2", stageName: "loop-judge", loopRound: 2, wantSuffix: filepath.Join("round-02", "loop_judge_result.json")},
+ {name: "issue-post-intake", stageName: "issue-post-intake", args: map[string]any{"purpose": "intake"}, wantSuffix: "issue_post_result_intake.json"},
+ {name: "issue-post-completion", stageName: "issue-post-completion", args: map[string]any{"purpose": "completion"}, wantSuffix: "issue_post_result_completion.json"},
+ {name: "unknown-stage", stageName: "custom-stage", wantSuffix: "custom-stage.json"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := adapter.ResolveResultFile(tc.stageName, tc.args, tc.loopRound)
+ if !strings.HasSuffix(got, tc.wantSuffix) {
+ t.Fatalf("ResolveResultFile(%q) = %q, want suffix %q", tc.stageName, got, tc.wantSuffix)
+ }
+ })
+ }
+}
+
+// TestAdapterLoadResultRouting 验证 LoadResult 按 stageName 路由到正确的 typed loader。
+func TestAdapterLoadResultRouting(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ // 写入一个合法的 issue-handling 结果文件
+ path := adapter.ResolveResultFile("issue-handling", nil, 0)
+ writeTestFile(t, path, `{
+ "status": "READY",
+ "agent": "开发者",
+ "summary": "任务可以开工。",
+ "acceptance": "验收标准。",
+ "next_action": "进入编码。",
+ "comment_body": "已接单。"
+}`)
+
+ m, err := adapter.LoadResult("issue-handling", path)
+ if err != nil {
+ t.Fatalf("LoadResult(issue-handling) unexpected error: %v", err)
+ }
+
+ if m["status"] != "READY" {
+ t.Fatalf("LoadResult(issue-handling) status = %v, want READY", m["status"])
+ }
+ if m["agent"] != "开发者" {
+ t.Fatalf("LoadResult(issue-handling) agent = %v, want 开发者", m["agent"])
+ }
+}
+
+// TestAdapterLoadResultRejectsInvalidFile 验证 LoadResult 会透传 typed loader 的校验错误。
+func TestAdapterLoadResultRejectsInvalidFile(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ // 写入一个非法的 coding 结果文件(status=PASS 不合法)
+ path := adapter.ResolveResultFile("coding", nil, 0)
+ writeTestFile(t, path, `{
+ "status": "PASS",
+ "summary": "非法状态。",
+ "evidence": "无",
+ "acceptance_check": "无",
+ "next_action": "无"
+}`)
+
+ if _, err := adapter.LoadResult("coding", path); err == nil {
+ t.Fatal("LoadResult(coding) error = nil, want non-nil error for invalid status")
+ }
+}
+
+// TestAdapterLoadResultUnknownStage 验证未知 stageName 走 raw map 加载(无校验)。
+func TestAdapterLoadResultUnknownStage(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ path := adapter.ResolveResultFile("custom-stage", nil, 0)
+ writeTestFile(t, path, `{"foo": "bar", "count": 42}`)
+
+ m, err := adapter.LoadResult("custom-stage", path)
+ if err != nil {
+ t.Fatalf("LoadResult(custom-stage) unexpected error: %v", err)
+ }
+
+ if m["foo"] != "bar" {
+ t.Fatalf("LoadResult(custom-stage) foo = %v, want bar", m["foo"])
+ }
+}
+
+// TestAdapterResetResultFile 验证 ResetResultFile 删除文件且对不存在的文件不报错。
+func TestAdapterResetResultFile(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ path := adapter.ResolveResultFile("issue-handling", nil, 0)
+ writeTestFile(t, path, `{"status": "READY"}`)
+
+ if err := adapter.ResetResultFile(path); err != nil {
+ t.Fatalf("ResetResultFile(existing) unexpected error: %v", err)
+ }
+
+ // 再次删除(文件已不存在)不应报错
+ if err := adapter.ResetResultFile(path); err != nil {
+ t.Fatalf("ResetResultFile(non-existent) unexpected error: %v", err)
+ }
+}
+
+// TestAdapterLoopHistoryPath 验证 LoopHistoryPath 返回稳定路径。
+func TestAdapterLoopHistoryPath(t *testing.T) {
+ artifacts, err := NewArtifactSet("owner/repo", 42)
+ if err != nil {
+ t.Fatalf("NewArtifactSet() unexpected error: %v", err)
+ }
+
+ adapter := NewArtifactResolverAdapter(artifacts)
+
+ got := adapter.LoopHistoryPath()
+ if !strings.HasSuffix(got, "loop_history.json") {
+ t.Fatalf("LoopHistoryPath() = %q, want suffix loop_history.json", got)
+ }
+}
diff --git a/internal/runtime/agent_tool.go b/internal/runtime/agent_tool.go
new file mode 100644
index 0000000..bb77e73
--- /dev/null
+++ b/internal/runtime/agent_tool.go
@@ -0,0 +1,275 @@
+// 本文件实现 AgentTool,包装 agent.Runner 调用 AI 智能体。
+//
+// 核心流程:
+// 1. 根据 tool.PromptTemplate 用 PromptBuilder 渲染 prompt
+// 2. 调 runner.Run(ctx, kind, prompt) 执行智能体
+// 3. 通过 ArtifactResolver 从结果文件加载结构化数据
+// 4. 返回 StepResult(含 Status/Data/Output)
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/agent"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// AgentTool 包装 agent.Runner,执行 AI 智能体任务。
+type AgentTool struct {
+ def *schema.Tool
+ runner Runner
+ builder *PromptBuilder
+}
+
+// NewAgentTool 创建一个 AgentTool 实例。
+func NewAgentTool(def *schema.Tool, runner Runner, builder *PromptBuilder) (*AgentTool, error) {
+ if def.Type != "agent" {
+ return nil, fmt.Errorf("AgentTool: tool type must be agent, got %s", def.Type)
+ }
+ if def.PromptTemplate == "" {
+ return nil, fmt.Errorf("AgentTool: prompt_template required for tool %s", def.Name)
+ }
+ if runner == nil {
+ return nil, fmt.Errorf("AgentTool: runner is nil for tool %s", def.Name)
+ }
+ if builder == nil {
+ return nil, fmt.Errorf("AgentTool: builder is nil for tool %s", def.Name)
+ }
+
+ return &AgentTool{def: def, runner: runner, builder: builder}, nil
+}
+
+// Definition 返回工具的 schema 定义。
+func (t *AgentTool) Definition() *schema.Tool {
+ return t.def
+}
+
+// Execute 执行智能体任务。
+func (t *AgentTool) Execute(ctx context.Context, inv Invocation) (*StepResult, error) {
+ // 1. 构建 prompt 数据
+ data := t.buildPromptData(inv)
+
+ // 2. 渲染 prompt
+ prompt, err := t.builder.Build(t.def.PromptTemplate, data)
+ if err != nil {
+ return &StepResult{StageName: inv.StageName, Output: ""}, fmt.Errorf("AgentTool.Build prompt: %w", err)
+ }
+
+ // 3. 调用 runner
+ kind := agent.TaskKind(mapTemplateToKind(t.def.PromptTemplate))
+ runResult, err := t.runner.Run(ctx, kind, prompt)
+ output := ""
+ success := false
+ if runResult != nil {
+ output = runResult.Output
+ success = runResult.Success
+ }
+ if err != nil {
+ return &StepResult{
+ StageName: inv.StageName,
+ Success: success,
+ Output: output,
+ }, fmt.Errorf("AgentTool.Execute[%s]: %w", t.def.Name, err)
+ }
+
+ // 4. 加载结果文件
+ result := &StepResult{
+ StageName: inv.StageName,
+ Success: true,
+ Output: output,
+ }
+
+ if inv.EC != nil && inv.EC.Artifacts != nil && inv.ResultFilePath != "" {
+ loaded, loadErr := inv.EC.Artifacts.LoadResult(inv.StageName, inv.ResultFilePath)
+ if loadErr != nil {
+ return result, fmt.Errorf("AgentTool.LoadResult[%s]: %w", t.def.Name, loadErr)
+ }
+ result.Data = loaded
+ result.Status = extractStatus(loaded)
+ result.Blocked = result.Status == "BLOCKED"
+ }
+
+ return result, nil
+}
+
+// buildPromptData 从 Invocation 构建 PromptBuilder 所需的 PromptData。
+//
+// 根据 tool.PromptTemplate 类型,从 ExecutionContext 查询所需的上游字段。
+// stage name 约定:
+// - issue-handling: 接单阶段
+// - coding: 编码阶段
+// - review: 审查阶段
+// - issue-post: Issue 提交阶段
+// - loop-judge: 价值评估阶段
+func (t *AgentTool) buildPromptData(inv Invocation) PromptData {
+ data := PromptData{
+ WorkerID: inv.Platform.WorkerID,
+ IssueURL: inv.Platform.IssueURL,
+ PlatformName: inv.Platform.PlatformName,
+ PlatformGuide: inv.Platform.PlatformGuide,
+ ResultFilePath: inv.ResultFilePath,
+ }
+
+ if inv.Config != nil {
+ data.Repo = inv.Config.Repo
+ data.IssueNumber = inv.Config.IssueNumber
+ }
+ // 角色展示名从 workflow.Tools 按 prompt_template 派生,替代原 Config 硬编码。
+ // 派生 key 用 prompt_template(内置模板名)而非 tool name,因为用户自定义
+ // workflow 时 tool name 可改,但只要复用内置 prompt 模板,派生就能正确工作。
+ if inv.EC != nil {
+ wf := inv.EC.Workflow
+ data.DefaultAgent = lookupDisplayNameByPromptTemplate(wf, promptCoding)
+ data.ReviewerAgent = lookupDisplayNameByPromptTemplate(wf, promptReview)
+ data.IssuePostAgent = lookupDisplayNameByPromptTemplate(wf, promptIssuePost)
+ }
+
+ if inv.Loop != nil {
+ data.Round = inv.Loop.Round
+ data.MaxRounds = inv.Loop.MaxIterations
+ data.ConsecutiveUnknowns = inv.Loop.ConsecutiveUnknown
+ data.ReviewerFeedback = inv.Loop.LastFeedback
+ }
+
+ // 根据 prompt 模板类型,从 EC 查询上游字段
+ if inv.EC != nil {
+ switch t.def.PromptTemplate {
+ case promptCoding:
+ t.fillCodingPromptData(&data, inv)
+ case promptReview:
+ t.fillReviewPromptData(&data, inv)
+ case promptIssuePost:
+ t.fillIssuePostPromptData(&data, inv)
+ case promptLoopJudge:
+ t.fillLoopJudgePromptData(&data, inv)
+ }
+ }
+
+ return data
+}
+
+// fillCodingPromptData 填充编码阶段的 prompt 数据。
+func (t *AgentTool) fillCodingPromptData(data *PromptData, inv Invocation) {
+ ec := inv.EC
+ if v, ok := ec.Lookup("issue-handling", "agent"); ok {
+ if s, ok := v.(string); ok {
+ data.Agent = s
+ }
+ }
+ if v, ok := ec.Lookup("issue-handling", "summary"); ok {
+ if s, ok := v.(string); ok {
+ data.IssueSummary = s
+ }
+ }
+ if v, ok := ec.Lookup("issue-handling", "acceptance"); ok {
+ if s, ok := v.(string); ok {
+ data.Acceptance = s
+ }
+ }
+}
+
+// fillReviewPromptData 填充审查阶段的 prompt 数据。
+func (t *AgentTool) fillReviewPromptData(data *PromptData, inv Invocation) {
+ ec := inv.EC
+ if v, ok := ec.Lookup("issue-handling", "summary"); ok {
+ if s, ok := v.(string); ok {
+ data.IssueSummary = s
+ }
+ }
+ if v, ok := ec.Lookup("issue-handling", "acceptance"); ok {
+ if s, ok := v.(string); ok {
+ data.Acceptance = s
+ }
+ }
+ if v, ok := ec.Lookup("coding", "summary"); ok {
+ if s, ok := v.(string); ok {
+ data.CodingSummary = s
+ }
+ }
+ if v, ok := ec.Lookup("coding", "evidence"); ok {
+ if s, ok := v.(string); ok {
+ data.CodingEvidence = s
+ }
+ }
+ if v, ok := ec.Lookup("coding", "acceptance_check"); ok {
+ if s, ok := v.(string); ok {
+ data.CodingAcceptanceCheck = s
+ }
+ }
+}
+
+// fillIssuePostPromptData 填充 Issue 提交阶段的 prompt 数据。
+func (t *AgentTool) fillIssuePostPromptData(data *PromptData, inv Invocation) {
+ if inv.EC.Artifacts != nil {
+ sourceStage, _ := getStringArg(inv.Args, "source_stage")
+ if sourceStage != "" {
+ data.SourceFilePath = inv.EC.Artifacts.ResolveResultFile(sourceStage, inv.Args, 0)
+ }
+ }
+ if v, ok := getStringArg(inv.Args, "purpose"); ok {
+ data.Purpose = v
+ }
+}
+
+// fillLoopJudgePromptData 填充价值评估阶段的 prompt 数据。
+func (t *AgentTool) fillLoopJudgePromptData(data *PromptData, inv Invocation) {
+ if inv.EC.Artifacts != nil {
+ data.HistoryFilePath = inv.EC.Artifacts.LoopHistoryPath()
+ }
+}
+
+// extractStatus 从结果 map 中提取 status 字段。
+func extractStatus(data map[string]any) string {
+ if data == nil {
+ return ""
+ }
+ if v, ok := data["status"]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+// lookupDisplayNameByPromptTemplate 在 workflow.Tools 中查找 prompt_template 匹配的 tool,
+// 返回其展示名。派生优先级:display_name > aliases[0] > name > 空字符串。
+//
+// 用于从 workflow 配置派生角色名(如 DefaultAgent/ReviewerAgent/IssuePostAgent),
+// 替代原 Config 硬编码。当用户自定义 workflow 时,只要复用内置 prompt 模板
+// (coding/review/issue-post),派生就能正确取到对应 tool 的展示名。
+func lookupDisplayNameByPromptTemplate(wf *schema.Workflow, tmpl string) string {
+ if wf == nil {
+ return ""
+ }
+ for i := range wf.Tools {
+ t := &wf.Tools[i]
+ if t.PromptTemplate == tmpl {
+ if t.DisplayName != "" {
+ return t.DisplayName
+ }
+ if len(t.Aliases) > 0 {
+ return t.Aliases[0]
+ }
+ return t.Name
+ }
+ }
+ return ""
+}
+
+// getStringArg 从 args map 中获取字符串参数。
+func getStringArg(args map[string]any, key string) (string, bool) {
+ if args == nil {
+ return "", false
+ }
+ v, ok := args[key]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.(string)
+ return s, ok
+}
diff --git a/internal/runtime/command_tool.go b/internal/runtime/command_tool.go
new file mode 100644
index 0000000..2dc1468
--- /dev/null
+++ b/internal/runtime/command_tool.go
@@ -0,0 +1,93 @@
+// 本文件实现 CommandTool,通过 exec.CommandContext 执行 shell 命令。
+//
+// 核心流程:
+// 1. 用 tool.Run 作为 shell 命令
+// 2. 注入 tool.Env 环境变量
+// 3. 执行命令并捕获 stdout
+// 4. 尝试把 stdout 解析为 JSON(如果失败则当作纯文本输出)
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strings"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// execCommandContext 是 exec.CommandContext 的可替换别名,便于单元测试注入 mock。
+var execCommandContext = exec.CommandContext
+
+// CommandTool 通过 shell 命令执行工具。
+type CommandTool struct {
+ def *schema.Tool
+}
+
+// NewCommandTool 创建一个 CommandTool 实例。
+func NewCommandTool(def *schema.Tool) (*CommandTool, error) {
+ if def.Type != "command" {
+ return nil, fmt.Errorf("CommandTool: tool type must be command, got %s", def.Type)
+ }
+ if def.Run == "" {
+ return nil, fmt.Errorf("CommandTool: run required for tool %s", def.Name)
+ }
+ return &CommandTool{def: def}, nil
+}
+
+// Definition 返回工具的 schema 定义。
+func (t *CommandTool) Definition() *schema.Tool {
+ return t.def
+}
+
+// Execute 执行 shell 命令。
+func (t *CommandTool) Execute(ctx context.Context, inv Invocation) (*StepResult, error) {
+ cmd := execCommandContext(ctx, "sh", "-c", t.def.Run)
+
+ // 注入环境变量
+ if len(t.def.Env) > 0 {
+ cmd.Env = append(cmd.Env, envSlice(t.def.Env)...)
+ }
+
+ output, err := cmd.CombinedOutput()
+ out := string(output)
+
+ result := &StepResult{
+ StageName: inv.StageName,
+ Output: out,
+ }
+
+ if err != nil {
+ result.Success = false
+ return result, fmt.Errorf("CommandTool.Execute[%s]: %w\n%s", t.def.Name, err, out)
+ }
+
+ result.Success = true
+
+ // 尝试解析 stdout 为 JSON
+ if trimmed := strings.TrimSpace(out); trimmed != "" {
+ var data map[string]any
+ if jsonErr := json.Unmarshal([]byte(trimmed), &data); jsonErr == nil {
+ result.Data = data
+ result.Status = extractStatus(data)
+ result.Blocked = result.Status == "BLOCKED"
+ }
+ // 非 JSON 输出不报错,Data 留空
+ }
+
+ return result, nil
+}
+
+// envSlice 把 map[string]string 转成 "KEY=VALUE" 切片。
+func envSlice(env map[string]string) []string {
+ out := make([]string, 0, len(env))
+ for k, v := range env {
+ out = append(out, k+"="+v)
+ }
+ return out
+}
diff --git a/internal/runtime/context.go b/internal/runtime/context.go
new file mode 100644
index 0000000..80400da
--- /dev/null
+++ b/internal/runtime/context.go
@@ -0,0 +1,237 @@
+// 本文件实现 ExecutionContext 和 LoopState,负责 stage 间数据传递和 loop 状态维护。
+//
+// 设计说明:
+// 1. stages map 维护每个 stage 的最新 StepResult,供 exit_when/when 条件求值用
+// 2. loopStack 支持嵌套 loop,栈顶为当前 loop
+// 3. Snapshot/Merge/CloneForBranch 用于 parallel 分支的写隔离
+// 4. Lookup 支持 dot path 嵌套取值,如 "check_result.passed"
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// ExecutionContext 是贯穿整个 workflow 执行的上下文容器。
+//
+// 职责:
+// - 持有 workflow 配置、平台上下文、配置等全局信息
+// - 维护 stage 结果 map,供后续 stage 和条件求值引用
+// - 维护 loop 栈,支持嵌套 loop
+// - 提供 parallel 分支的写隔离能力
+type ExecutionContext struct {
+ // Workflow 是校验后的 workflow 配置。
+ Workflow *schema.Workflow
+
+ // Artifacts 是文件契约解析器,由 pipeline 层注入。
+ Artifacts ArtifactResolver
+
+ // Platform 是平台上下文。
+ Platform PlatformContext
+
+ // Config 是 code-bee 配置。
+ Config *config.Config
+
+ mu sync.RWMutex
+ stages map[string]*StepResult // stage name → 最新结果
+ loopStack []*LoopState // 嵌套 loop 栈,栈顶为当前 loop
+}
+
+// NewExecutionContext 创建新的执行上下文。
+func NewExecutionContext(wf *schema.Workflow, artifacts ArtifactResolver, platform PlatformContext, cfg *config.Config) *ExecutionContext {
+ return &ExecutionContext{
+ Workflow: wf,
+ Artifacts: artifacts,
+ Platform: platform,
+ Config: cfg,
+ stages: make(map[string]*StepResult),
+ }
+}
+
+// Lookup 按 dot path 从指定 stage 的结果中取值。
+//
+// 用法:
+// - stageName="review", fieldPath="status" → review 结果的 status 字段
+// - stageName="review", fieldPath="check_result.passed" → 嵌套取值
+// - stageName="review", fieldPath="" → 返回整个 Data map(作为 any)
+//
+// 返回值:
+// - val: 找到的值(fieldPath="" 时返回 map[string]any)
+// - ok: 是否找到
+func (ec *ExecutionContext) Lookup(stageName, fieldPath string) (any, bool) {
+ ec.mu.RLock()
+ defer ec.mu.RUnlock()
+
+ r, ok := ec.stages[stageName]
+ if !ok || r == nil || r.Data == nil {
+ return nil, false
+ }
+
+ if fieldPath == "" {
+ return r.Data, true
+ }
+
+ return lookupByPath(r.Data, fieldPath)
+}
+
+// lookupByPath 按 dot path 从 map 中取值。
+func lookupByPath(data map[string]any, path string) (any, bool) {
+ keys := splitDotPath(path)
+ var cur any = data
+ for _, k := range keys {
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ v, ok := m[k]
+ if !ok {
+ return nil, false
+ }
+ cur = v
+ }
+ return cur, true
+}
+
+// splitDotPath 把 "a.b.c" 拆成 ["a","b","c"]。
+func splitDotPath(path string) []string {
+ var keys []string
+ start := 0
+ for i := 0; i < len(path); i++ {
+ if path[i] == '.' {
+ if i > start {
+ keys = append(keys, path[start:i])
+ }
+ start = i + 1
+ }
+ }
+ if start < len(path) {
+ keys = append(keys, path[start:])
+ }
+ return keys
+}
+
+// SetResult 记录 stage 的执行结果。
+func (ec *ExecutionContext) SetResult(stageName string, r *StepResult) {
+ ec.mu.Lock()
+ defer ec.mu.Unlock()
+ ec.stages[stageName] = r
+}
+
+// GetResult 获取 stage 的最新结果。
+func (ec *ExecutionContext) GetResult(stageName string) (*StepResult, bool) {
+ ec.mu.RLock()
+ defer ec.mu.RUnlock()
+ r, ok := ec.stages[stageName]
+ return r, ok
+}
+
+// Snapshot 返回当前所有 stage 结果的快照(浅拷贝)。
+//
+// 用于 parallel 分支启动前的基线,分支内的写操作不影响其他分支。
+func (ec *ExecutionContext) Snapshot() map[string]*StepResult {
+ ec.mu.RLock()
+ defer ec.mu.RUnlock()
+ snap := make(map[string]*StepResult, len(ec.stages))
+ for k, v := range ec.stages {
+ snap[k] = v
+ }
+ return snap
+}
+
+// Merge 把快照中的结果合并回主上下文。
+//
+// 用于 parallel 分支完成后回写成功分支的结果。
+func (ec *ExecutionContext) Merge(snap map[string]*StepResult) {
+ ec.mu.Lock()
+ defer ec.mu.Unlock()
+ for k, v := range snap {
+ ec.stages[k] = v
+ }
+}
+
+// CloneForBranch 创建一个用于 parallel 分支的子上下文。
+//
+// 子上下文共享 Workflow/Artifacts/Platform/Config,但有独立的 stages map
+// (以 base 为初始值)和共享的 loopStack(loop 内的 parallel 仍属于同一个 loop)。
+func (ec *ExecutionContext) CloneForBranch(base map[string]*StepResult) *ExecutionContext {
+ ec.mu.RLock()
+ defer ec.mu.RUnlock()
+
+ branchStages := make(map[string]*StepResult, len(base))
+ for k, v := range base {
+ branchStages[k] = v
+ }
+
+ return &ExecutionContext{
+ Workflow: ec.Workflow,
+ Artifacts: ec.Artifacts,
+ Platform: ec.Platform,
+ Config: ec.Config,
+ stages: branchStages,
+ loopStack: ec.loopStack, // 共享 loop 栈,parallel 分支内仍在同一 loop 上下文
+ }
+}
+
+// CurrentLoop 返回栈顶的 loop 状态,非 loop 内执行时返回 nil。
+func (ec *ExecutionContext) CurrentLoop() *LoopState {
+ ec.mu.RLock()
+ defer ec.mu.RUnlock()
+ if len(ec.loopStack) == 0 {
+ return nil
+ }
+ return ec.loopStack[len(ec.loopStack)-1]
+}
+
+// PushLoop 把一个 loop 状态压栈。
+func (ec *ExecutionContext) PushLoop(s *LoopState) {
+ ec.mu.Lock()
+ defer ec.mu.Unlock()
+ ec.loopStack = append(ec.loopStack, s)
+}
+
+// PopLoop 弹出栈顶 loop 状态。
+func (ec *ExecutionContext) PopLoop() {
+ ec.mu.Lock()
+ defer ec.mu.Unlock()
+ if len(ec.loopStack) == 0 {
+ return
+ }
+ ec.loopStack = ec.loopStack[:len(ec.loopStack)-1]
+}
+
+// LoopState 维护单个 loop 的执行状态。
+type LoopState struct {
+ // ID 是 loop 的唯一标识。
+ ID string
+
+ // Round 是当前轮次(从 1 开始)。
+ Round int
+
+ // MaxIterations 是硬上限。
+ MaxIterations int
+
+ // MaxConsecutiveUnknown 是连续 UNKNOWN 状态的软上限。
+ MaxConsecutiveUnknown int
+
+ // ConsecutiveUnknown 是当前连续 UNKNOWN 计数。
+ ConsecutiveUnknown int
+
+ // LastFeedback 是给下一轮 coder 的反馈,等价老 dispatch 的 reviewerFeedback。
+ LastFeedback string
+
+ // Judge 是 loop 的价值评估员配置。
+ Judge *schema.LoopJudge
+}
+
+// String 返回可读的 loop 状态描述。
+func (s *LoopState) String() string {
+ return fmt.Sprintf("loop[%s] round=%d/%d unknown=%d/%d",
+ s.ID, s.Round, s.MaxIterations, s.ConsecutiveUnknown, s.MaxConsecutiveUnknown)
+}
diff --git a/internal/runtime/context_test.go b/internal/runtime/context_test.go
new file mode 100644
index 0000000..e30e32c
--- /dev/null
+++ b/internal/runtime/context_test.go
@@ -0,0 +1,231 @@
+package runtime
+
+import (
+ "testing"
+)
+
+func TestExecutionContext_Lookup_BasicField(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("review", &StepResult{
+ Data: map[string]any{
+ "status": "PASS",
+ },
+ })
+
+ val, ok := ec.Lookup("review", "status")
+ if !ok {
+ t.Fatal("expected to find review.status")
+ }
+ if val != "PASS" {
+ t.Errorf("got %v, want PASS", val)
+ }
+}
+
+func TestExecutionContext_Lookup_NestedPath(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("review", &StepResult{
+ Data: map[string]any{
+ "check_result": map[string]any{
+ "passed": true,
+ "count": 42,
+ },
+ },
+ })
+
+ val, ok := ec.Lookup("review", "check_result.passed")
+ if !ok {
+ t.Fatal("expected to find review.check_result.passed")
+ }
+ if val != true {
+ t.Errorf("got %v, want true", val)
+ }
+
+ val, ok = ec.Lookup("review", "check_result.count")
+ if !ok {
+ t.Fatal("expected to find review.check_result.count")
+ }
+ if val != 42 {
+ t.Errorf("got %v, want 42", val)
+ }
+}
+
+func TestExecutionContext_Lookup_EmptyPath(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ data := map[string]any{"status": "DONE", "summary": "ok"}
+ ec.SetResult("coding", &StepResult{Data: data})
+
+ val, ok := ec.Lookup("coding", "")
+ if !ok {
+ t.Fatal("expected to find coding data")
+ }
+ m, ok := val.(map[string]any)
+ if !ok {
+ t.Fatalf("expected map, got %T", val)
+ }
+ if m["status"] != "DONE" {
+ t.Errorf("got status %v, want DONE", m["status"])
+ }
+}
+
+func TestExecutionContext_Lookup_StageNotFound(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ _, ok := ec.Lookup("nonexistent", "status")
+ if ok {
+ t.Fatal("expected not found for nonexistent stage")
+ }
+}
+
+func TestExecutionContext_Lookup_FieldNotFound(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("review", &StepResult{
+ Data: map[string]any{"status": "PASS"},
+ })
+
+ _, ok := ec.Lookup("review", "missing")
+ if ok {
+ t.Fatal("expected not found for missing field")
+ }
+}
+
+func TestExecutionContext_SetResult_GetResult(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ r := &StepResult{StageName: "coding", Status: "DONE"}
+ ec.SetResult("coding", r)
+
+ got, ok := ec.GetResult("coding")
+ if !ok {
+ t.Fatal("expected to find coding result")
+ }
+ if got.Status != "DONE" {
+ t.Errorf("got status %s, want DONE", got.Status)
+ }
+}
+
+func TestExecutionContext_Snapshot_Merge(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("a", &StepResult{Status: "A"})
+ ec.SetResult("b", &StepResult{Status: "B"})
+
+ snap := ec.Snapshot()
+ if len(snap) != 2 {
+ t.Fatalf("snapshot len = %d, want 2", len(snap))
+ }
+
+ // 修改快照不影响主上下文
+ snap["c"] = &StepResult{Status: "C"}
+ if _, ok := ec.GetResult("c"); ok {
+ t.Fatal("snapshot should not affect main context")
+ }
+
+ // Merge 回写
+ ec.Merge(snap)
+ got, ok := ec.GetResult("c")
+ if !ok {
+ t.Fatal("expected c after merge")
+ }
+ if got.Status != "C" {
+ t.Errorf("got status %s, want C", got.Status)
+ }
+}
+
+func TestExecutionContext_CloneForBranch_WriteIsolation(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("base", &StepResult{Status: "BASE"})
+
+ base := ec.Snapshot()
+ branch := ec.CloneForBranch(base)
+
+ // 分支写入不影响主上下文
+ branch.SetResult("branch-only", &StepResult{Status: "BRANCH"})
+ if _, ok := ec.GetResult("branch-only"); ok {
+ t.Fatal("branch write should not affect main context")
+ }
+
+ // 主上下文写入不影响分支
+ ec.SetResult("main-only", &StepResult{Status: "MAIN"})
+ if _, ok := branch.GetResult("main-only"); ok {
+ t.Fatal("main write should not affect branch context")
+ }
+
+ // 基线数据两边都可见
+ got, ok := branch.GetResult("base")
+ if !ok {
+ t.Fatal("branch should see base result")
+ }
+ if got.Status != "BASE" {
+ t.Errorf("got status %s, want BASE", got.Status)
+ }
+}
+
+func TestExecutionContext_PushLoop_PopLoop(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+
+ if ec.CurrentLoop() != nil {
+ t.Fatal("expected nil loop on empty stack")
+ }
+
+ outer := &LoopState{ID: "outer", Round: 1, MaxIterations: 3}
+ ec.PushLoop(outer)
+
+ got := ec.CurrentLoop()
+ if got == nil || got.ID != "outer" {
+ t.Fatalf("expected outer loop, got %v", got)
+ }
+
+ inner := &LoopState{ID: "inner", Round: 1, MaxIterations: 2}
+ ec.PushLoop(inner)
+
+ got = ec.CurrentLoop()
+ if got == nil || got.ID != "inner" {
+ t.Fatalf("expected inner loop, got %v", got)
+ }
+
+ ec.PopLoop()
+ got = ec.CurrentLoop()
+ if got == nil || got.ID != "outer" {
+ t.Fatalf("expected outer loop after pop, got %v", got)
+ }
+
+ ec.PopLoop()
+ if ec.CurrentLoop() != nil {
+ t.Fatal("expected nil loop after popping all")
+ }
+}
+
+func TestSplitDotPath(t *testing.T) {
+ cases := []struct {
+ path string
+ want []string
+ }{
+ {"status", []string{"status"}},
+ {"a.b.c", []string{"a", "b", "c"}},
+ {"", []string{}},
+ {"a", []string{"a"}},
+ }
+ for _, c := range cases {
+ got := splitDotPath(c.path)
+ if len(got) != len(c.want) {
+ t.Errorf("splitDotPath(%q) = %v, want %v", c.path, got, c.want)
+ continue
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ t.Errorf("splitDotPath(%q)[%d] = %q, want %q", c.path, i, got[i], c.want[i])
+ }
+ }
+ }
+}
+
+func TestLoopState_String(t *testing.T) {
+ s := &LoopState{
+ ID: "dev-loop",
+ Round: 2,
+ MaxIterations: 3,
+ ConsecutiveUnknown: 1,
+ MaxConsecutiveUnknown: 2,
+ }
+ str := s.String()
+ if str == "" {
+ t.Error("String() should not be empty")
+ }
+}
diff --git a/internal/runtime/default_workflow.go b/internal/runtime/default_workflow.go
new file mode 100644
index 0000000..a404bb3
--- /dev/null
+++ b/internal/runtime/default_workflow.go
@@ -0,0 +1,40 @@
+// 本文件提供内置默认 workflow 的加载入口。
+//
+// 设计说明:
+// - default_workflow.yaml 通过 go:embed 内置到二进制,保证开箱即用
+// - 用户可通过 --workflow flag 加载外部 YAML 覆盖默认配置
+// - 加载时复用 schema.Loader 做完整的 JSON Schema + 语义校验
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ _ "embed"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+//go:embed default_workflow.yaml
+var defaultWorkflowFS []byte
+
+// LoadDefaultWorkflow 加载内置默认 workflow 配置。
+//
+// 返回值:
+// - *schema.Workflow: 校验通过的 workflow 实例
+// - error: 当内置 YAML 非法或语义校验失败时返回(属编程错误,正常不会发生)
+func LoadDefaultWorkflow() (*schema.Workflow, error) {
+ loader, err := schema.NewLoader()
+ if err != nil {
+ return nil, fmt.Errorf("runtime.LoadDefaultWorkflow: %w", err)
+ }
+
+ wf, err := loader.LoadFromBytes(defaultWorkflowFS)
+ if err != nil {
+ return nil, fmt.Errorf("runtime.LoadDefaultWorkflow: %w", err)
+ }
+
+ return wf, nil
+}
diff --git a/internal/runtime/default_workflow.yaml b/internal/runtime/default_workflow.yaml
new file mode 100644
index 0000000..47a50ea
--- /dev/null
+++ b/internal/runtime/default_workflow.yaml
@@ -0,0 +1,138 @@
+# code-bee 默认工作流配置。
+#
+# 本文件通过 go:embed 内置到二进制中,对应原 dispatch.go 硬编码的四阶段 harness:
+# 1. issue-handling: 读取 Issue 并判断接单/阻塞
+# 2. issue-post-intake / issue-post-blocked: 接单/阻塞评论由专门的提交智能体发布
+# 3. coding-review loop: 编码 -> 审查 -> (PASS 时)完成评论提交
+# 4. loop-judge: 价值评估员,判断循环是否还有继续价值
+#
+# 用户可通过 --workflow 加载自定义 YAML 覆盖本配置。
+#
+# 开发维护: AI Assistant
+# 创建时间: 2026-07-05
+
+version: "1"
+name: code-bee-default
+description: code-bee 默认编码-审查循环工作流
+
+tools:
+ - name: issue-handling
+ type: agent
+ skill: 读取 Issue 并判断接单/阻塞
+ prompt_template: issue-handling
+ display_name: Issue 处理智能体
+ aliases:
+ - 接单员
+ description: 读取目标 Issue,判断 READY/BLOCKED,把结构化结果写入文件
+
+ - name: coder
+ type: agent
+ skill: 编码实现
+ prompt_template: coding
+ display_name: 开发者
+ aliases:
+ - 开发者
+ description: 按验收标准编码实现,把结构化结果写入文件
+
+ - name: reviewer
+ type: agent
+ skill: 代码审查
+ prompt_template: review
+ display_name: QA负责人
+ aliases:
+ - QA负责人
+ - 代码审核员
+ description: 审查编码结果,把 PASS/FAIL/UNKNOWN/BLOCKED 写入文件
+
+ - name: issue-post
+ type: agent
+ skill: 校验结果并提交 Issue 评论
+ prompt_template: issue-post
+ display_name: 产品经理
+ aliases:
+ - 产品经理
+ description: 校验上游结果文件并提交 Issue 评论,REJECTED 时返回修正反馈
+
+ - name: loop-judge
+ type: agent
+ skill: 价值评估
+ prompt_template: loop-judge
+ display_name: 技术负责人
+ aliases:
+ - 技术负责人
+ description: 评估 coder-reviewer loop 是否还有继续价值
+
+pipeline:
+ # 第一步:读取 Issue 并判断接单/阻塞
+ - stage:
+ name: issue-handling
+ tool: issue-handling
+ output: issue_intake_result.json
+
+ # 第二步(READY 时):提交接单评论
+ - stage:
+ name: issue-post-intake
+ tool: issue-post
+ input_from: issue-handling
+ args:
+ purpose: intake
+ source_stage: issue-handling
+ when:
+ stage: issue-handling
+ field: status
+ operator: equals
+ value: READY
+
+ # 注: issue-handling 返回 BLOCKED 时,Engine 会立即终止(返回 Blocked 结果),
+ # 不会进入下面的 loop。阻塞评论的提交目前由 issue-handling 智能体在
+ # comment_body 中准备好,由上层调用方决定如何发布。后续可通过增强 Engine
+ # 支持 on_blocked: continue 后再加回 issue-post-blocked 阶段。
+
+ # 第三步:coder-reviewer 外层循环
+ - loop:
+ id: coding-review
+ max_iterations: 3
+ max_consecutive_unknown: 2
+ exit_when:
+ - stage: issue-post-completion
+ field: status
+ operator: equals
+ value: POSTED
+ body:
+ # 编码阶段
+ - stage:
+ name: coding
+ tool: coder
+ output: coding_result.json
+ input_from: issue-handling
+
+ # 审查阶段
+ - stage:
+ name: review
+ tool: reviewer
+ output: review_result.json
+ input_from: coding
+
+ # review PASS 时提交完成评论
+ - stage:
+ name: issue-post-completion
+ tool: issue-post
+ input_from: review
+ args:
+ purpose: completion
+ source_stage: review
+ when:
+ stage: review
+ field: status
+ operator: equals
+ value: PASS
+
+ # 价值评估员
+ judge:
+ tool: loop-judge
+ start_round: 1
+ on_decision:
+ STOP_MANUAL: exit
+ STOP_BLOCKED: exit
+ SHRINK_TASK: continue
+ CONTINUE: continue
diff --git a/internal/runtime/doc.go b/internal/runtime/doc.go
new file mode 100644
index 0000000..ae19b5c
--- /dev/null
+++ b/internal/runtime/doc.go
@@ -0,0 +1,17 @@
+// Package runtime 提供 code-bee 的可配置调度引擎。
+//
+// 设计目标:
+// 1. 基于 *schema.Workflow 配置驱动执行
+// 2. 支持 stage(串行)/ parallel(并行)/ loop(循环)三种编排原语
+// 3. 统一 Tool 抽象(agent 和 command 都是 Tool)
+// 4. 复用现有文件契约和 agent.Runner
+//
+// 核心抽象:
+// - Tool: 统一 agent/command/function 的执行接口
+// - Executor: 编排原语执行器(Stage/Parallel/Loop)
+// - Engine: 顶层入口,消费 *schema.Workflow
+// - ExecutionContext: stage 间数据传递和 loop 状态维护
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+package runtime
diff --git a/internal/runtime/engine.go b/internal/runtime/engine.go
new file mode 100644
index 0000000..2600a12
--- /dev/null
+++ b/internal/runtime/engine.go
@@ -0,0 +1,178 @@
+// 本文件实现 Engine,Runtime 引擎的顶层入口。
+//
+// 职责:
+// 1. 持有 workflow 配置和 ToolRegistry
+// 2. 提供 Run 方法,遍历 workflow.Pipeline 并通过 dispatch 分发执行
+// 3. 根据 StepResult 的 Blocked/ManualRequired/Completed 决定是否提前终止
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// Engine 是 Runtime 引擎的顶层入口。
+type Engine struct {
+ workflow *schema.Workflow
+ registry *ToolRegistry
+ pb *PromptBuilder
+ stage *StageExecutor
+ parallel *ParallelExecutor
+ loop *LoopExecutor
+}
+
+// EngineOption 配置 Engine 的可选参数。
+type EngineOption func(*engineConfig)
+
+// engineConfig 收集 EngineOption 设置的配置。
+type engineConfig struct {
+ promptsDir string
+}
+
+// WithPromptsDir 设置外部 prompt 模板目录(overlay 内置模板)。
+//
+// 传入空字符串等价于不设置(只用内置模板)。
+func WithPromptsDir(dir string) EngineOption {
+ return func(c *engineConfig) {
+ c.promptsDir = dir
+ }
+}
+
+// NewEngine 创建 Engine 实例。
+//
+// 构造逻辑:
+// 1. 应用 EngineOption 收集配置
+// 2. 加载 PromptBuilder(内置模板 + 可选外部 overlay)
+// 3. 根据 workflow.Tools 构建 ToolRegistry
+// 4. 创建三种 Executor,通过 dispatch 函数支持嵌套
+func NewEngine(wf *schema.Workflow, runner Runner, opts ...EngineOption) (*Engine, error) {
+ if wf == nil {
+ return nil, fmt.Errorf("runtime.NewEngine: workflow is nil")
+ }
+ if runner == nil {
+ return nil, fmt.Errorf("runtime.NewEngine: runner is nil")
+ }
+
+ cfg := &engineConfig{}
+ for _, opt := range opts {
+ opt(cfg)
+ }
+
+ pb, err := NewPromptBuilder(cfg.promptsDir)
+ if err != nil {
+ return nil, fmt.Errorf("runtime.NewEngine: %w", err)
+ }
+
+ registry, err := NewToolRegistry(wf, runner, pb)
+ if err != nil {
+ return nil, fmt.Errorf("runtime.NewEngine: %w", err)
+ }
+
+ e := &Engine{
+ workflow: wf,
+ registry: registry,
+ pb: pb,
+ }
+
+ // dispatch 函数用于 ParallelExecutor 和 LoopExecutor 的嵌套分发
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ return e.dispatch(ctx, step, ec)
+ }
+
+ e.stage = NewStageExecutor(registry)
+ e.parallel = NewParallelExecutor(dispatch)
+ e.loop = NewLoopExecutor(registry, dispatch)
+
+ return e, nil
+}
+
+// RunDependencies 是每次 Run 时需要的运行时依赖。
+//
+// 不同 issue 有不同的 ArtifactSet 和 Config,所以这些不放在 Engine 构造时。
+type RunDependencies struct {
+ Artifacts ArtifactResolver
+ Platform PlatformContext
+ Config *config.Config
+}
+
+// Run 执行 workflow,返回最终结果。
+//
+// 执行流程:
+// 1. 创建 ExecutionContext
+// 2. 遍历 workflow.Pipeline,逐个 dispatch
+// 3. 遇到 Blocked/ManualRequired/Completed 时提前返回
+func (e *Engine) Run(ctx context.Context, deps RunDependencies) (*EngineResult, error) {
+ ec := NewExecutionContext(e.workflow, deps.Artifacts, deps.Platform, deps.Config)
+
+ for _, step := range e.workflow.Pipeline {
+ result, err := e.dispatch(ctx, step, ec)
+ if err != nil {
+ return toEngineResult(result, err), err
+ }
+
+ if result == nil {
+ continue
+ }
+
+ // 遇到终止性状态提前返回
+ if result.Blocked {
+ return &EngineResult{
+ Success: true,
+ Blocked: true,
+ Output: result.Output,
+ }, nil
+ }
+
+ if result.ManualRequired {
+ return &EngineResult{
+ Success: true,
+ ManualRequired: true,
+ Output: result.Output,
+ }, nil
+ }
+
+ if result.Completed {
+ return &EngineResult{
+ Success: true,
+ Completed: true,
+ Output: result.Output,
+ }, nil
+ }
+ }
+
+ // 所有 step 执行完毕,无终止性状态
+ return &EngineResult{
+ Success: true,
+ Completed: true,
+ }, nil
+}
+
+// dispatch 根据 step 类型选择 executor 并执行。
+func (e *Engine) dispatch(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ exec := pickExecutor(step, e.stage, e.parallel, e.loop)
+ if exec == nil {
+ return nil, errInvalidStep
+ }
+ return exec.Execute(ctx, step, ec)
+}
+
+// toEngineResult 把 StepResult + error 转成 EngineResult。
+func toEngineResult(r *StepResult, err error) *EngineResult {
+ if r == nil {
+ return &EngineResult{Success: false}
+ }
+ return &EngineResult{
+ Success: r.Success,
+ Completed: r.Completed,
+ Blocked: r.Blocked,
+ ManualRequired: r.ManualRequired,
+ Output: r.Output,
+ }
+}
diff --git a/internal/runtime/engine_test.go b/internal/runtime/engine_test.go
new file mode 100644
index 0000000..73bac65
--- /dev/null
+++ b/internal/runtime/engine_test.go
@@ -0,0 +1,189 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+func TestNewEngine_Success(t *testing.T) {
+ wf := &schema.Workflow{
+ Version: "1",
+ Name: "test",
+ Tools: []schema.Tool{
+ {Name: "agent-tool", Type: "agent", PromptTemplate: "coding", Skill: "s"},
+ },
+ }
+
+ eng, err := NewEngine(wf, &scriptedRunner{})
+ if err != nil {
+ t.Fatalf("NewEngine: %v", err)
+ }
+ if eng.workflow != wf {
+ t.Error("workflow not set")
+ }
+ if eng.registry == nil {
+ t.Error("registry not set")
+ }
+ if eng.stage == nil || eng.parallel == nil || eng.loop == nil {
+ t.Error("executors not set")
+ }
+}
+
+func TestNewEngine_NilWorkflow(t *testing.T) {
+ _, err := NewEngine(nil, &scriptedRunner{})
+ if err == nil {
+ t.Fatal("expected error for nil workflow")
+ }
+}
+
+func TestNewEngine_NilRunner(t *testing.T) {
+ wf := &schema.Workflow{Tools: []schema.Tool{}}
+ _, err := NewEngine(wf, nil)
+ if err == nil {
+ t.Fatal("expected error for nil runner")
+ }
+}
+
+func TestEngine_Run_SimplePipeline(t *testing.T) {
+ // 简单 pipeline:一个 stage,返回 PASS
+ wf := &schema.Workflow{
+ Version: "1",
+ Tools: []schema.Tool{
+ {Name: "test-tool", Type: "command", Run: "echo"},
+ },
+ Pipeline: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "test", Tool: "test-tool"}},
+ },
+ }
+
+ eng, err := NewEngine(wf, &scriptedRunner{})
+ if err != nil {
+ t.Fatalf("NewEngine: %v", err)
+ }
+
+ // 用 mock artifacts 让 stage 返回 PASS
+ fa := &fakeArtifacts{results: map[string]map[string]any{
+ "test": {"status": "PASS"},
+ }}
+
+ // 但 command tool 会真的执行 echo,需要 mock exec
+ oldExec := execCommandContext
+ defer func() { execCommandContext = oldExec }()
+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
+ return exec.Command("true")
+ }
+
+ result, err := eng.Run(context.Background(), RunDependencies{
+ Artifacts: fa,
+ Platform: PlatformContext{},
+ Config: &config.Config{},
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success")
+ }
+}
+
+func TestEngine_Run_Blocked(t *testing.T) {
+ // pipeline 返回 BLOCKED → 提前终止
+ wf := &schema.Workflow{
+ Version: "1",
+ Tools: []schema.Tool{
+ {Name: "test-tool", Type: "command", Run: "echo"},
+ },
+ Pipeline: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "test", Tool: "test-tool"}},
+ },
+ }
+
+ eng, _ := NewEngine(wf, &scriptedRunner{})
+
+ fa := &fakeArtifacts{}
+
+ oldExec := execCommandContext
+ defer func() { execCommandContext = oldExec }()
+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
+ return exec.Command("echo", `{"status":"BLOCKED"}`)
+ }
+
+ result, err := eng.Run(context.Background(), RunDependencies{
+ Artifacts: fa,
+ Config: &config.Config{},
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true")
+ }
+}
+
+func TestEngine_Run_DispatchError(t *testing.T) {
+ // 用一个 tool 不存在的 stage 触发 dispatch 错误
+ wf := &schema.Workflow{
+ Version: "1",
+ Tools: []schema.Tool{},
+ Pipeline: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "test", Tool: "nonexistent"}},
+ },
+ }
+
+ eng, _ := NewEngine(wf, &scriptedRunner{})
+
+ _, err := eng.Run(context.Background(), RunDependencies{
+ Artifacts: &fakeArtifacts{},
+ Config: &config.Config{},
+ })
+ if err == nil {
+ t.Fatal("expected error for missing tool")
+ }
+}
+
+func TestEngine_Run_EmptyPipeline(t *testing.T) {
+ wf := &schema.Workflow{
+ Version: "1",
+ Tools: []schema.Tool{},
+ Pipeline: []schema.PipelineStep{},
+ }
+
+ eng, _ := NewEngine(wf, &scriptedRunner{})
+
+ result, err := eng.Run(context.Background(), RunDependencies{
+ Artifacts: &fakeArtifacts{},
+ Config: &config.Config{},
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if !result.Success || !result.Completed {
+ t.Error("expected Success and Completed for empty pipeline")
+ }
+}
+
+func TestToEngineResult(t *testing.T) {
+ // nil result
+ r := toEngineResult(nil, errors.New("err"))
+ if r.Success {
+ t.Error("expected Success=false for nil result")
+ }
+
+ // 正常 result
+ r = toEngineResult(&StepResult{
+ Success: true,
+ Completed: true,
+ Output: "done",
+ }, nil)
+ if !r.Success || !r.Completed {
+ t.Error("expected Success and Completed")
+ }
+ if r.Output != "done" {
+ t.Errorf("got output %q, want done", r.Output)
+ }
+}
diff --git a/internal/runtime/executor.go b/internal/runtime/executor.go
new file mode 100644
index 0000000..38472e6
--- /dev/null
+++ b/internal/runtime/executor.go
@@ -0,0 +1,39 @@
+// 本文件定义 Executor 接口,三种编排原语的执行器都实现此接口。
+//
+// Executor 是统一的执行入口,Engine 根据 PipelineStep 类型选择对应执行器。
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// Executor 是编排原语执行器的统一接口。
+type Executor interface {
+ // Execute 执行一个 pipeline step,返回执行结果。
+ Execute(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error)
+}
+
+// pickExecutor 根据 PipelineStep 的类型选择对应的执行器。
+//
+// 返回 nil 表示 step 配置无效(三个字段都为空)。
+func pickExecutor(step schema.PipelineStep, stage *StageExecutor, parallel *ParallelExecutor, loop *LoopExecutor) Executor {
+ switch {
+ case step.Stage != nil:
+ return stage
+ case len(step.Parallel) > 0:
+ return parallel
+ case step.Loop != nil:
+ return loop
+ }
+ return nil
+}
+
+// errInvalidStep 表示 pipeline step 配置无效。
+var errInvalidStep = fmt.Errorf("pipeline step has no stage/parallel/loop")
diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go
new file mode 100644
index 0000000..89e853d
--- /dev/null
+++ b/internal/runtime/expression.go
@@ -0,0 +1,364 @@
+// 本文件实现条件表达式求值,支持 when 和 exit_when 两种场景。
+//
+// 两种表达式形态:
+// 1. 结构化 ExitCondition: stage + field + operator + value
+// 支持 6 个 operator: equals/not_equals/in/contains/exists/regex
+// 2. 字符串表达式: "round >= 2"、"review.status == \"PASS\""
+// 支持比较运算符: ==, !=, >=, <=, >, <
+//
+// 设计说明:
+// - 字符串表达式用最小解析器,后续可替换为 expr-lang/expr
+// - 特殊变量 round 引用 CurrentLoop().Round
+// - stage.field 形式通过 ExecutionContext.Lookup 取值
+// - 数字比较统一转 float64,避免 int/float64 类型差异
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "fmt"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// EvalExitCondition 求值单个结构化退出条件。
+//
+// 返回 true 表示条件满足(应退出 loop)。
+func EvalExitCondition(ec *ExecutionContext, cond schema.ExitCondition) (bool, error) {
+ val, ok := ec.Lookup(cond.Stage, cond.Field)
+
+ switch cond.Operator {
+ case "equals":
+ if !ok {
+ return false, nil
+ }
+ return tolerantEquals(val, cond.Value), nil
+
+ case "not_equals":
+ if !ok {
+ return true, nil
+ }
+ return !tolerantEquals(val, cond.Value), nil
+
+ case "in":
+ if !ok {
+ return false, nil
+ }
+ list, err := toAnySlice(cond.Value)
+ if err != nil {
+ return false, fmt.Errorf("operator in: value not a list: %w", err)
+ }
+ for _, item := range list {
+ if tolerantEquals(val, item) {
+ return true, nil
+ }
+ }
+ return false, nil
+
+ case "contains":
+ if !ok {
+ return false, nil
+ }
+ return containsValue(val, cond.Value), nil
+
+ case "exists":
+ return ok, nil
+
+ case "regex":
+ return evalRegexCondition(ok, val, cond.Value)
+ }
+
+ return false, fmt.Errorf("unknown operator: %s", cond.Operator)
+}
+
+// evalRegexCondition 处理 regex 操作符的匹配逻辑。
+func evalRegexCondition(ok bool, val, patternVal any) (bool, error) {
+ if !ok {
+ return false, nil
+ }
+ s, err := toString(val)
+ if err != nil {
+ return false, fmt.Errorf("operator regex: value not a string: %w", err)
+ }
+ pattern, err := toString(patternVal)
+ if err != nil {
+ return false, fmt.Errorf("operator regex: pattern not a string: %w", err)
+ }
+ matched, err := regexp.MatchString(pattern, s)
+ if err != nil {
+ return false, fmt.Errorf("operator regex: invalid pattern %q: %w", pattern, err)
+ }
+ return matched, nil
+}
+
+// EvalCondition 求值 Condition(支持 Expr 和 Structured 两种形态)。
+//
+// cond 为 nil 时返回 true(无条件视为满足)。
+func EvalCondition(ec *ExecutionContext, cond *schema.Condition) (bool, error) {
+ if cond == nil {
+ return true, nil
+ }
+
+ if cond.Expr != "" {
+ return EvalExpr(cond.Expr, ec)
+ }
+
+ if cond.Structured != nil {
+ return EvalExitCondition(ec, *cond.Structured)
+ }
+
+ return true, nil
+}
+
+// AnyExitConditionMatched 检查一组退出条件是否任一满足。
+//
+// 用于 loop 的 exit_when 检查:任一条件满足即退出。
+func AnyExitConditionMatched(ec *ExecutionContext, conds []schema.ExitCondition) (bool, error) {
+ for _, c := range conds {
+ matched, err := EvalExitCondition(ec, c)
+ if err != nil {
+ return false, fmt.Errorf("exit condition [stage=%s field=%s]: %w", c.Stage, c.Field, err)
+ }
+ if matched {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// 字符串表达式解析
+
+var exprPattern = regexp.MustCompile(`^\s*(\w+(?:\.\w+)*)\s*(==|!=|>=|<=|>|<)\s*(.+?)\s*$`)
+
+// EvalExpr 求值字符串表达式。
+//
+// 支持的格式:
+// - round >= 2
+// - review.status == "PASS"
+// - review.status != "BLOCKED"
+//
+// 特殊变量:
+// - round: 当前 loop 的轮次(非 loop 内为 0)
+func EvalExpr(expr string, ec *ExecutionContext) (bool, error) {
+ m := exprPattern.FindStringSubmatch(expr)
+ if m == nil {
+ return false, fmt.Errorf("invalid expression: %q", expr)
+ }
+
+ leftPath := m[1]
+ op := m[2]
+ rightStr := m[3]
+
+ left, err := resolveExprValue(ec, leftPath)
+ if err != nil {
+ return false, fmt.Errorf("resolve left %q: %w", leftPath, err)
+ }
+
+ right, err := parseLiteral(rightStr)
+ if err != nil {
+ return false, fmt.Errorf("parse right %q: %w", rightStr, err)
+ }
+
+ return compareValues(left, op, right)
+}
+
+// resolveExprValue 解析表达式中的变量引用。
+func resolveExprValue(ec *ExecutionContext, path string) (any, error) {
+ if path == "round" {
+ loop := ec.CurrentLoop()
+ if loop == nil {
+ return 0, nil
+ }
+ return loop.Round, nil
+ }
+
+ // stage.field 形式
+ idx := strings.Index(path, ".")
+ if idx > 0 {
+ stageName := path[:idx]
+ fieldPath := path[idx+1:]
+ val, ok := ec.Lookup(stageName, fieldPath)
+ if !ok {
+ return nil, nil
+ }
+ return val, nil
+ }
+
+ return nil, fmt.Errorf("unknown variable: %s", path)
+}
+
+// parseLiteral 解析表达式中的字面量。
+func parseLiteral(s string) (any, error) {
+ s = strings.TrimSpace(s)
+
+ // 字符串字面量
+ if len(s) >= 2 {
+ if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
+ return s[1 : len(s)-1], nil
+ }
+ }
+
+ // 数字
+ if n, err := strconv.Atoi(s); err == nil {
+ return n, nil
+ }
+ if f, err := strconv.ParseFloat(s, 64); err == nil {
+ return f, nil
+ }
+
+ // 布尔
+ if s == "true" {
+ return true, nil
+ }
+ if s == "false" {
+ return false, nil
+ }
+
+ // 否则当作字符串
+ return s, nil
+}
+
+// compareValues 按运算符比较两个值。
+func compareValues(left any, op string, right any) (bool, error) {
+ switch op {
+ case "==":
+ return tolerantEquals(left, right), nil
+ case "!=":
+ return !tolerantEquals(left, right), nil
+ case ">=", "<=", ">", "<":
+ return compareNumeric(left, op, right)
+ }
+ return false, fmt.Errorf("unknown operator: %s", op)
+}
+
+// compareNumeric 比较两个数值。
+func compareNumeric(left any, op string, right any) (bool, error) {
+ l, err := toFloat(left)
+ if err != nil {
+ return false, fmt.Errorf("left not numeric: %v", left)
+ }
+ r, err := toFloat(right)
+ if err != nil {
+ return false, fmt.Errorf("right not numeric: %v", right)
+ }
+
+ switch op {
+ case ">=":
+ return l >= r, nil
+ case "<=":
+ return l <= r, nil
+ case ">":
+ return l > r, nil
+ case "<":
+ return l < r, nil
+ }
+ return false, fmt.Errorf("unknown operator: %s", op)
+}
+
+// tolerantEquals 宽松相等比较,处理数字类型差异。
+//
+// JSON 往返后数字是 float64,YAML 解析后可能是 int/int64/float64。
+// 此函数把两边都尝试转 float64 比较,转不了再用 reflect.DeepEqual。
+func tolerantEquals(a, b any) bool {
+ // 尝试数字比较
+ af, aErr := toFloat(a)
+ bf, bErr := toFloat(b)
+ if aErr == nil && bErr == nil {
+ return af == bf
+ }
+
+ // 字符串比较
+ as, aOk := toString(a)
+ bs, bOk := toString(b)
+ if aOk == nil && bOk == nil {
+ return as == bs
+ }
+
+ // 兜底用 DeepEqual
+ return reflect.DeepEqual(a, b)
+}
+
+// toFloat 把任意值转为 float64。
+func toFloat(v any) (float64, error) {
+ switch n := v.(type) {
+ case int:
+ return float64(n), nil
+ case int8:
+ return float64(n), nil
+ case int16:
+ return float64(n), nil
+ case int32:
+ return float64(n), nil
+ case int64:
+ return float64(n), nil
+ case uint:
+ return float64(n), nil
+ case uint64:
+ return float64(n), nil
+ case float32:
+ return float64(n), nil
+ case float64:
+ return n, nil
+ }
+ return 0, fmt.Errorf("not numeric: %T", v)
+}
+
+// toString 把任意值转为 string。
+func toString(v any) (string, error) {
+ switch s := v.(type) {
+ case string:
+ return s, nil
+ case []byte:
+ return string(s), nil
+ }
+ return "", fmt.Errorf("not string: %T", v)
+}
+
+// toAnySlice 把任意值转为 []any。
+func toAnySlice(v any) ([]any, error) {
+ if v == nil {
+ return nil, fmt.Errorf("nil")
+ }
+ // YAML/JSON 解析后的数组都是 []any
+ if s, ok := v.([]any); ok {
+ return s, nil
+ }
+ // 反射处理其他 slice 类型
+ rv := reflect.ValueOf(v)
+ if rv.Kind() == reflect.Slice {
+ out := make([]any, rv.Len())
+ for i := 0; i < rv.Len(); i++ {
+ out[i] = rv.Index(i).Interface()
+ }
+ return out, nil
+ }
+ return nil, fmt.Errorf("not a slice: %T", v)
+}
+
+// containsValue 检查 val 是否包含 target。
+//
+// - val 是 string: strings.Contains
+// - val 是 []any: 遍历用 tolerantEquals
+func containsValue(val, target any) bool {
+ if s, err := toString(val); err == nil {
+ if t, err := toString(target); err == nil {
+ return strings.Contains(s, t)
+ }
+ }
+
+ if list, err := toAnySlice(val); err == nil {
+ for _, item := range list {
+ if tolerantEquals(item, target) {
+ return true
+ }
+ }
+ }
+
+ return false
+}
diff --git a/internal/runtime/expression_test.go b/internal/runtime/expression_test.go
new file mode 100644
index 0000000..75acd35
--- /dev/null
+++ b/internal/runtime/expression_test.go
@@ -0,0 +1,339 @@
+package runtime
+
+import (
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+func newTestEC() *ExecutionContext {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.SetResult("review", &StepResult{
+ Data: map[string]any{
+ "status": "PASS",
+ "summary": "all good",
+ "tags": []any{"bug", "critical"},
+ "check_result": map[string]any{
+ "passed": true,
+ "count": 42,
+ "missing": "",
+ },
+ },
+ })
+ ec.SetResult("coding", &StepResult{
+ Data: map[string]any{
+ "status": "DONE",
+ "count": 5,
+ "summary": "implemented feature X",
+ },
+ })
+ return ec
+}
+
+func TestEvalExitCondition_Equals(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{Stage: "review", Field: "status", Operator: "equals", Value: "PASS"}
+ got, err := EvalExitCondition(ec, cond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("equals PASS should match")
+ }
+
+ cond.Value = "FAIL"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("equals FAIL should not match PASS")
+ }
+}
+
+func TestEvalExitCondition_NotEquals(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{Stage: "review", Field: "status", Operator: "not_equals", Value: "FAIL"}
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("not_equals FAIL should match when status is PASS")
+ }
+
+ cond.Value = "PASS"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("not_equals PASS should not match when status is PASS")
+ }
+}
+
+func TestEvalExitCondition_In(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{
+ Stage: "review",
+ Field: "status",
+ Operator: "in",
+ Value: []any{"PASS", "BLOCKED"},
+ }
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("status PASS should be in [PASS, BLOCKED]")
+ }
+
+ cond.Value = []any{"FAIL", "UNKNOWN"}
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("status PASS should not be in [FAIL, UNKNOWN]")
+ }
+}
+
+func TestEvalExitCondition_Contains_String(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{
+ Stage: "coding",
+ Field: "summary",
+ Operator: "contains",
+ Value: "feature",
+ }
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("summary should contain 'feature'")
+ }
+
+ cond.Value = "missing-keyword"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("summary should not contain 'missing-keyword'")
+ }
+}
+
+func TestEvalExitCondition_Contains_Array(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{
+ Stage: "review",
+ Field: "tags",
+ Operator: "contains",
+ Value: "critical",
+ }
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("tags should contain 'critical'")
+ }
+
+ cond.Value = "enhancement"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("tags should not contain 'enhancement'")
+ }
+}
+
+func TestEvalExitCondition_Exists(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{Stage: "review", Field: "status", Operator: "exists"}
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("review.status should exist")
+ }
+
+ cond.Field = "nonexistent"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("review.nonexistent should not exist")
+ }
+}
+
+func TestEvalExitCondition_Regex(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{
+ Stage: "review",
+ Field: "status",
+ Operator: "regex",
+ Value: "^P.*S$",
+ }
+ got, _ := EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("status PASS should match ^P.*S$")
+ }
+
+ cond.Value = "^F.*$"
+ got, _ = EvalExitCondition(ec, cond)
+ if got {
+ t.Error("status PASS should not match ^F.*$")
+ }
+}
+
+func TestEvalExitCondition_FieldNotFound(t *testing.T) {
+ ec := newTestEC()
+
+ cond := schema.ExitCondition{Stage: "review", Field: "missing", Operator: "equals", Value: "x"}
+ got, _ := EvalExitCondition(ec, cond)
+ if got {
+ t.Error("equals on missing field should be false")
+ }
+
+ cond.Operator = "not_equals"
+ got, _ = EvalExitCondition(ec, cond)
+ if !got {
+ t.Error("not_equals on missing field should be true")
+ }
+}
+
+func TestEvalExitCondition_UnknownOperator(t *testing.T) {
+ ec := newTestEC()
+ cond := schema.ExitCondition{Stage: "review", Field: "status", Operator: "bogus", Value: "x"}
+ _, err := EvalExitCondition(ec, cond)
+ if err == nil {
+ t.Fatal("expected error for unknown operator")
+ }
+}
+
+func TestEvalExpr_RoundGE(t *testing.T) {
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+ ec.PushLoop(&LoopState{Round: 3, MaxIterations: 5})
+
+ cases := []struct {
+ expr string
+ want bool
+ }{
+ {"round >= 2", true},
+ {"round >= 3", true},
+ {"round >= 4", false},
+ {"round > 2", true},
+ {"round > 3", false},
+ {"round <= 3", true},
+ {"round <= 2", false},
+ {"round < 4", true},
+ {"round < 3", false},
+ }
+ for _, c := range cases {
+ got, err := EvalExpr(c.expr, ec)
+ if err != nil {
+ t.Errorf("EvalExpr(%q) error: %v", c.expr, err)
+ continue
+ }
+ if got != c.want {
+ t.Errorf("EvalExpr(%q) = %v, want %v", c.expr, got, c.want)
+ }
+ }
+}
+
+func TestEvalExpr_StatusEquals(t *testing.T) {
+ ec := newTestEC()
+
+ got, err := EvalExpr(`review.status == "PASS"`, ec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error(`review.status == "PASS" should be true`)
+ }
+
+ got, _ = EvalExpr(`review.status != "BLOCKED"`, ec)
+ if !got {
+ t.Error(`review.status != "BLOCKED" should be true`)
+ }
+}
+
+func TestEvalExpr_NestedField(t *testing.T) {
+ ec := newTestEC()
+
+ got, err := EvalExpr(`review.check_result.count >= 40`, ec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("review.check_result.count >= 40 should be true")
+ }
+
+ got, _ = EvalExpr(`review.check_result.count >= 50`, ec)
+ if got {
+ t.Error("review.check_result.count >= 50 should be false")
+ }
+}
+
+func TestEvalExpr_InvalidExpression(t *testing.T) {
+ ec := newTestEC()
+
+ _, err := EvalExpr("not a valid expression", ec)
+ if err == nil {
+ t.Fatal("expected error for invalid expression")
+ }
+}
+
+func TestEvalCondition_Nil(t *testing.T) {
+ ec := newTestEC()
+ got, err := EvalCondition(ec, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("nil condition should be true")
+ }
+}
+
+func TestEvalCondition_Expr(t *testing.T) {
+ ec := newTestEC()
+ cond := &schema.Condition{Expr: `review.status == "PASS"`}
+ got, err := EvalCondition(ec, cond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("expr condition should be true")
+ }
+}
+
+func TestEvalCondition_Structured(t *testing.T) {
+ ec := newTestEC()
+ cond := &schema.Condition{
+ Structured: &schema.ExitCondition{
+ Stage: "review", Field: "status", Operator: "equals", Value: "PASS",
+ },
+ }
+ got, err := EvalCondition(ec, cond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("structured condition should be true")
+ }
+}
+
+func TestAnyExitConditionMatched(t *testing.T) {
+ ec := newTestEC()
+
+ conds := []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "FAIL"},
+ {Stage: "review", Field: "status", Operator: "equals", Value: "PASS"},
+ }
+ got, err := AnyExitConditionMatched(ec, conds)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got {
+ t.Error("should match when one condition is satisfied")
+ }
+
+ conds = []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "FAIL"},
+ {Stage: "review", Field: "status", Operator: "equals", Value: "UNKNOWN"},
+ }
+ got, _ = AnyExitConditionMatched(ec, conds)
+ if got {
+ t.Error("should not match when no condition is satisfied")
+ }
+}
+
+func TestAnyExitConditionMatched_Empty(t *testing.T) {
+ ec := newTestEC()
+ got, err := AnyExitConditionMatched(ec, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got {
+ t.Error("empty conditions should not match")
+ }
+}
diff --git a/internal/runtime/function_tool.go b/internal/runtime/function_tool.go
new file mode 100644
index 0000000..03a0eae
--- /dev/null
+++ b/internal/runtime/function_tool.go
@@ -0,0 +1,44 @@
+// 本文件实现 FunctionTool,调用内置注册函数。
+//
+// 当前为桩实现,后续可扩展为函数注册表。
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// FunctionTool 调用内置函数。
+type FunctionTool struct {
+ def *schema.Tool
+}
+
+// NewFunctionTool 创建一个 FunctionTool 实例。
+func NewFunctionTool(def *schema.Tool) (*FunctionTool, error) {
+ if def.Type != "function" {
+ return nil, fmt.Errorf("FunctionTool: tool type must be function, got %s", def.Type)
+ }
+ if def.Function == "" {
+ return nil, fmt.Errorf("FunctionTool: function required for tool %s", def.Name)
+ }
+ return &FunctionTool{def: def}, nil
+}
+
+// Definition 返回工具的 schema 定义。
+func (t *FunctionTool) Definition() *schema.Tool {
+ return t.def
+}
+
+// Execute 执行内置函数。
+//
+// 当前未实现具体函数,返回错误。
+func (t *FunctionTool) Execute(ctx context.Context, inv Invocation) (*StepResult, error) {
+ return &StepResult{StageName: inv.StageName, Success: false},
+ fmt.Errorf("FunctionTool.Execute: function %q not yet implemented", t.def.Function)
+}
diff --git a/internal/runtime/loop_executor.go b/internal/runtime/loop_executor.go
new file mode 100644
index 0000000..5455e30
--- /dev/null
+++ b/internal/runtime/loop_executor.go
@@ -0,0 +1,312 @@
+// 本文件实现 LoopExecutor,执行循环编排原语。
+//
+// 执行流程:
+// 1. 初始化 LoopState(max_iterations / max_consecutive_unknown / judge)
+// 2. 每轮执行 body 中的 step 序列
+// 3. 每步后检查 exit_when,命中则退出
+// 4. 维护 ConsecutiveUnknown,超限则 ManualRequired 退出
+// 5. 若配了 judge 且 round >= startRound,执行 judge 并按决策处理
+// 6. 跑完 maxIterations 仍无退出 → ManualRequired
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// LoopExecutor 执行循环。
+type LoopExecutor struct {
+ registry *ToolRegistry
+ dispatch dispatchFunc
+}
+
+// NewLoopExecutor 创建 LoopExecutor。
+func NewLoopExecutor(registry *ToolRegistry, dispatch dispatchFunc) *LoopExecutor {
+ return &LoopExecutor{registry: registry, dispatch: dispatch}
+}
+
+// Execute 执行 loop 步骤。
+func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ loop := step.Loop
+ if loop == nil {
+ return nil, fmt.Errorf("LoopExecutor: step.Loop is nil")
+ }
+
+ // 1. 初始化 LoopState
+ loopState := &LoopState{
+ ID: loop.ID,
+ MaxIterations: loop.MaxIterations,
+ MaxConsecutiveUnknown: loop.MaxConsecutiveUnknown,
+ Judge: loop.Judge,
+ }
+ if loopState.MaxIterations == 0 {
+ loopState.MaxIterations = 3
+ }
+ if loopState.MaxConsecutiveUnknown == 0 {
+ loopState.MaxConsecutiveUnknown = 2
+ }
+
+ // 2. timeout
+ if loop.Timeout != "" {
+ timeout, err := time.ParseDuration(loop.Timeout)
+ if err != nil {
+ return nil, fmt.Errorf("loop %s: invalid timeout %q: %w", loop.ID, loop.Timeout, err)
+ }
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, timeout)
+ defer cancel()
+ }
+
+ ec.PushLoop(loopState)
+ defer ec.PopLoop()
+
+ var lastResult *StepResult
+
+ for round := 1; round <= loopState.MaxIterations; round++ {
+ loopState.Round = round
+
+ // 3. 执行 body
+ var err error
+ lastResult, err = e.executeBody(ctx, loop, ec, lastResult)
+ if err != nil {
+ return lastResult, err
+ }
+ if lastResult != nil && (lastResult.Completed || lastResult.Blocked) {
+ return lastResult, nil
+ }
+
+ // 4. 维护并检查终止条件
+ if result, exit := e.postRound(ctx, ec, loop, loopState, lastResult, round); exit {
+ return result, nil
+ }
+ }
+
+ // 跑完 maxIterations 仍无退出
+ return &StepResult{
+ Success: true,
+ ManualRequired: true,
+ StageName: "loop",
+ Output: fmt.Sprintf("loop %s: max iterations (%d) reached", loop.ID, loopState.MaxIterations),
+ }, nil
+}
+
+// postRound 执行每轮 body 后的维护工作:ConsecutiveUnknown、judge、LastFeedback。
+// 返回 (result, true) 表示需要退出循环。
+func (e *LoopExecutor) postRound(ctx context.Context, ec *ExecutionContext, loop *schema.Loop, loopState *LoopState, lastResult *StepResult, round int) (*StepResult, bool) {
+ // ConsecutiveUnknown
+ if lastResult != nil && lastResult.Status == "UNKNOWN" {
+ loopState.ConsecutiveUnknown++
+ } else {
+ loopState.ConsecutiveUnknown = 0
+ }
+ if loopState.ConsecutiveUnknown >= loopState.MaxConsecutiveUnknown {
+ return &StepResult{
+ Success: true,
+ ManualRequired: true,
+ StageName: "loop",
+ Output: fmt.Sprintf("loop %s: max consecutive unknown (%d) reached", loop.ID, loopState.MaxConsecutiveUnknown),
+ }, true
+ }
+
+ // judge
+ judgeResult, err := e.handleJudge(ctx, ec, loop, loopState, lastResult, round)
+ if err != nil {
+ return judgeResult, true
+ }
+ if judgeResult != nil {
+ return judgeResult, true
+ }
+
+ // LastFeedback
+ if lastResult != nil {
+ fb := buildFeedbackFromResult(lastResult)
+ if fb != "" {
+ loopState.LastFeedback = fb
+ }
+ }
+ return nil, false
+}
+
+// executeBody 执行 loop body 中的所有 step,处理 blocked 和 exit_when。
+func (e *LoopExecutor) executeBody(ctx context.Context, loop *schema.Loop, ec *ExecutionContext, lastResult *StepResult) (*StepResult, error) {
+ for _, bodyStep := range loop.Body {
+ r, err := e.dispatch(ctx, bodyStep, ec)
+ if err != nil {
+ if r == nil {
+ r = &StepResult{Success: false, StageName: "loop"}
+ }
+ return r, err
+ }
+ if r != nil {
+ lastResult = r
+ if r.Blocked && bodyStep.Stage != nil {
+ onBlocked := bodyStep.Stage.OnBlocked
+ if onBlocked == "" {
+ onBlocked = "stop"
+ }
+ if onBlocked == "stop" {
+ return r, nil
+ }
+ }
+ }
+
+ if len(loop.ExitWhen) > 0 {
+ matched, err := AnyExitConditionMatched(ec, loop.ExitWhen)
+ if err != nil {
+ return nil, fmt.Errorf("loop %s exit_when: %w", loop.ID, err)
+ }
+ if matched {
+ return e.makeExitResult(lastResult, "exit_when matched"), nil
+ }
+ }
+ }
+ return lastResult, nil
+}
+
+// handleJudge 执行价值评估并处理决策。
+// 返回 nil result 表示 CONTINUE;返回非 nil 表示 EXIT。
+func (e *LoopExecutor) handleJudge(ctx context.Context, ec *ExecutionContext, loop *schema.Loop, loopState *LoopState, _ *StepResult, round int) (*StepResult, error) {
+ if loop.Judge == nil || round < judgeStartRound(loop.Judge) {
+ return nil, nil
+ }
+
+ judgeResult, err := e.runJudge(ctx, ec, loop.Judge, loopState)
+ if err != nil {
+ return judgeResult, err
+ }
+ if judgeResult == nil {
+ return nil, nil
+ }
+
+ decision := getDecision(judgeResult)
+ action := mapJudgeDecision(decision, loop.Judge.OnDecision)
+
+ if action == "exit" {
+ return &StepResult{
+ Success: true,
+ ManualRequired: decision == "STOP_MANUAL",
+ Blocked: decision == "STOP_BLOCKED",
+ StageName: "loop",
+ Output: judgeResult.Output,
+ Data: judgeResult.Data,
+ }, nil
+ }
+
+ if decision == "SHRINK_TASK" {
+ fb := buildShrinkFeedback(judgeResult)
+ if fb != "" {
+ loopState.LastFeedback = fb + "\n\n" + loopState.LastFeedback
+ }
+ }
+ return nil, nil
+}
+
+// runJudge 执行价值评估员。
+//
+// 通过 dispatch 执行,便于测试 mock。dispatch 会路由到 StageExecutor,
+// StageExecutor 负责解析 tool、计算路径、调用 tool.Execute。
+func (e *LoopExecutor) runJudge(ctx context.Context, ec *ExecutionContext, judge *schema.LoopJudge, _ *LoopState) (*StepResult, error) {
+ judgeStep := schema.PipelineStep{
+ Stage: &schema.Stage{
+ Name: "loop-judge",
+ Tool: judge.Tool,
+ },
+ }
+ return e.dispatch(ctx, judgeStep, ec)
+}
+
+// makeExitResult 构造 exit_when 退出时的结果。
+func (e *LoopExecutor) makeExitResult(lastResult *StepResult, reason string) *StepResult {
+ if lastResult == nil {
+ return &StepResult{
+ Success: true,
+ Completed: true,
+ StageName: "loop",
+ Output: reason,
+ }
+ }
+ // 基于最后一个 stage 的结果构造
+ return &StepResult{
+ Success: true,
+ Completed: lastResult.Status == "PASS" || lastResult.Status == "POSTED",
+ Blocked: lastResult.Blocked,
+ StageName: "loop",
+ Output: reason,
+ Data: lastResult.Data,
+ Status: lastResult.Status,
+ }
+}
+
+// judgeStartRound 返回 judge 触发起始轮。
+// StartRound <= 0 表示始终触发(返回 1)。
+func judgeStartRound(judge *schema.LoopJudge) int {
+ if judge.StartRound <= 0 {
+ return 1
+ }
+ return judge.StartRound
+}
+
+// mapJudgeDecision 把 judge 的 decision 映射到 action(continue/exit)。
+//
+// on_decision 是用户自定义映射,未配置时用默认值:
+// - CONTINUE/SHRINK_TASK → continue
+// - STOP_MANUAL/STOP_BLOCKED → exit
+func mapJudgeDecision(decision string, onDecision map[string]string) string {
+ if action, ok := onDecision[decision]; ok {
+ return action
+ }
+
+ switch decision {
+ case "CONTINUE", "SHRINK_TASK":
+ return "continue"
+ case "STOP_MANUAL", "STOP_BLOCKED":
+ return "exit"
+ }
+ return "continue"
+}
+
+// getDecision 从 judge 结果中提取 decision 字段。
+func getDecision(result *StepResult) string {
+ if result == nil || result.Data == nil {
+ return ""
+ }
+ if v, ok := result.Data["decision"]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+// buildFeedbackFromResult 从结果中提取 next_action 作为下一轮反馈。
+func buildFeedbackFromResult(result *StepResult) string {
+ if result == nil || result.Data == nil {
+ return ""
+ }
+ if v, ok := result.Data["next_action"]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+// buildShrinkFeedback 从 judge 结果中提取 reason 作为 SHRINK_TASK 反馈。
+func buildShrinkFeedback(result *StepResult) string {
+ if result == nil || result.Data == nil {
+ return ""
+ }
+ if v, ok := result.Data["reason"]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
diff --git a/internal/runtime/loop_executor_test.go b/internal/runtime/loop_executor_test.go
new file mode 100644
index 0000000..6927a3c
--- /dev/null
+++ b/internal/runtime/loop_executor_test.go
@@ -0,0 +1,417 @@
+package runtime
+
+import (
+ "context"
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+func TestLoopExecutor_ExitWhen_Matched(t *testing.T) {
+ // 第一轮 review.status=PASS → exit_when 命中
+ callCount := 0
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ callCount++
+ stage := step.Stage
+ r := &StepResult{StageName: stage.Name, Success: true, Status: "PASS", Data: map[string]any{"status": "PASS"}}
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ exec := NewLoopExecutor(nil, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 3,
+ ExitWhen: []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "PASS"},
+ },
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success")
+ }
+ if !result.Completed {
+ t.Error("expected Completed=true for PASS exit")
+ }
+ if callCount != 1 {
+ t.Errorf("expected 1 dispatch call, got %d", callCount)
+ }
+}
+
+func TestLoopExecutor_ExitWhen_Blocked(t *testing.T) {
+ // review.status=BLOCKED → exit_when 命中
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ r := &StepResult{StageName: stage.Name, Success: true, Status: "BLOCKED",
+ Data: map[string]any{"status": "BLOCKED"}, Blocked: true}
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ exec := NewLoopExecutor(nil, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 3,
+ ExitWhen: []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "BLOCKED"},
+ },
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true")
+ }
+}
+
+func TestLoopExecutor_MaxIterations(t *testing.T) {
+ // 跑完 max_iterations 仍无 exit_when 命中 → ManualRequired
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ r := &StepResult{StageName: stage.Name, Success: true, Status: "FAIL",
+ Data: map[string]any{"status": "FAIL"}}
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ exec := NewLoopExecutor(nil, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 2,
+ ExitWhen: []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "PASS"},
+ },
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.ManualRequired {
+ t.Error("expected ManualRequired=true after max iterations")
+ }
+}
+
+func TestLoopExecutor_MaxConsecutiveUnknown(t *testing.T) {
+ // 连续 UNKNOWN 达到上限 → ManualRequired
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ r := &StepResult{StageName: stage.Name, Success: true, Status: "UNKNOWN",
+ Data: map[string]any{"status": "UNKNOWN"}}
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ exec := NewLoopExecutor(nil, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 5,
+ MaxConsecutiveUnknown: 2,
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.ManualRequired {
+ t.Error("expected ManualRequired for consecutive unknowns")
+ }
+}
+
+func TestLoopExecutor_JudgeStopManual(t *testing.T) {
+ // judge 返回 STOP_MANUAL → ManualRequired 退出
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ var r *StepResult
+ if stage.Name == "review" {
+ r = &StepResult{StageName: stage.Name, Success: true, Status: "FAIL",
+ Data: map[string]any{"status": "FAIL"}}
+ } else if stage.Name == "loop-judge" {
+ r = &StepResult{StageName: stage.Name, Success: true,
+ Data: map[string]any{"decision": "STOP_MANUAL", "reason": "no progress"}}
+ }
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ // 需要 registry 来解析 judge tool
+ wf := &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "judge-tool", Type: "command", Run: "echo judge"},
+ },
+ }
+ registry, _ := NewToolRegistry(wf, nil, nil)
+
+ exec := NewLoopExecutor(registry, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 5,
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ Judge: &schema.LoopJudge{
+ Tool: "judge-tool",
+ StartRound: 1,
+ },
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.ManualRequired {
+ t.Error("expected ManualRequired for STOP_MANUAL")
+ }
+}
+
+func TestLoopExecutor_JudgeStopBlocked(t *testing.T) {
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ var r *StepResult
+ if stage.Name == "review" {
+ r = &StepResult{StageName: stage.Name, Success: true, Status: "FAIL",
+ Data: map[string]any{"status": "FAIL"}}
+ } else if stage.Name == "loop-judge" {
+ r = &StepResult{StageName: stage.Name, Success: true,
+ Data: map[string]any{"decision": "STOP_BLOCKED", "reason": "external dep"}}
+ }
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ wf := &schema.Workflow{Tools: []schema.Tool{{Name: "judge-tool", Type: "command", Run: "echo"}}}
+ registry, _ := NewToolRegistry(wf, nil, nil)
+
+ exec := NewLoopExecutor(registry, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 5,
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ Judge: &schema.LoopJudge{Tool: "judge-tool", StartRound: 1},
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked for STOP_BLOCKED")
+ }
+}
+
+func TestLoopExecutor_JudgeContinue(t *testing.T) {
+ // judge 返回 CONTINUE → 继续循环
+ rounds := 0
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ var r *StepResult
+ if stage.Name == "review" {
+ rounds++
+ r = &StepResult{StageName: stage.Name, Success: true, Status: "FAIL",
+ Data: map[string]any{"status": "FAIL", "next_action": "keep trying"}}
+ } else if stage.Name == "loop-judge" {
+ // 第一轮 CONTINUE,第二轮 STOP_MANUAL
+ if rounds == 1 {
+ r = &StepResult{StageName: stage.Name, Success: true,
+ Data: map[string]any{"decision": "CONTINUE", "reason": "keep going"}}
+ } else {
+ r = &StepResult{StageName: stage.Name, Success: true,
+ Data: map[string]any{"decision": "STOP_MANUAL", "reason": "stop"}}
+ }
+ }
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ wf := &schema.Workflow{Tools: []schema.Tool{{Name: "judge-tool", Type: "command", Run: "echo"}}}
+ registry, _ := NewToolRegistry(wf, nil, nil)
+
+ exec := NewLoopExecutor(registry, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 5,
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ Judge: &schema.LoopJudge{Tool: "judge-tool", StartRound: 1},
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ // 第一轮 CONTINUE,第二轮 STOP_MANUAL
+ if !result.ManualRequired {
+ t.Error("expected ManualRequired after second round judge")
+ }
+ if rounds != 2 {
+ t.Errorf("expected 2 review rounds, got %d", rounds)
+ }
+}
+
+func TestLoopExecutor_JudgeShrinkTask(t *testing.T) {
+ // judge 返回 SHRINK_TASK → 注入反馈继续
+ dispatch := func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ var r *StepResult
+ if stage.Name == "review" {
+ r = &StepResult{StageName: stage.Name, Success: true, Status: "PASS",
+ Data: map[string]any{"status": "PASS"}}
+ } else if stage.Name == "loop-judge" {
+ r = &StepResult{StageName: stage.Name, Success: true,
+ Data: map[string]any{"decision": "SHRINK_TASK", "reason": "focus on core"}}
+ }
+ ec.SetResult(stage.Name, r)
+ return r, nil
+ }
+
+ wf := &schema.Workflow{Tools: []schema.Tool{{Name: "judge-tool", Type: "command", Run: "echo"}}}
+ registry, _ := NewToolRegistry(wf, nil, nil)
+
+ exec := NewLoopExecutor(registry, dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{
+ Loop: &schema.Loop{
+ ID: "test",
+ MaxIterations: 3,
+ ExitWhen: []schema.ExitCondition{
+ {Stage: "review", Field: "status", Operator: "equals", Value: "PASS"},
+ },
+ Body: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "review", Tool: "reviewer"}},
+ },
+ Judge: &schema.LoopJudge{Tool: "judge-tool", StartRound: 1},
+ },
+ }
+
+ // 第一轮 review PASS → exit_when 命中退出(在 judge 之前)
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Completed {
+ t.Error("expected Completed=true for PASS")
+ }
+}
+
+func TestLoopExecutor_NilLoop(t *testing.T) {
+ exec := NewLoopExecutor(nil, nil)
+ ec := NewExecutionContext(nil, nil, PlatformContext{}, nil)
+
+ _, err := exec.Execute(context.Background(), schema.PipelineStep{}, ec)
+ if err == nil {
+ t.Fatal("expected error for nil loop")
+ }
+}
+
+func TestJudgeStartRound(t *testing.T) {
+ cases := []struct {
+ start int
+ want int
+ }{
+ {0, 1},
+ {-1, 1},
+ {1, 1},
+ {3, 3},
+ }
+ for _, c := range cases {
+ got := judgeStartRound(&schema.LoopJudge{StartRound: c.start})
+ if got != c.want {
+ t.Errorf("judgeStartRound(%d) = %d, want %d", c.start, got, c.want)
+ }
+ }
+}
+
+func TestMapJudgeDecision(t *testing.T) {
+ // 默认映射
+ cases := []struct {
+ decision string
+ want string
+ }{
+ {"CONTINUE", "continue"},
+ {"SHRINK_TASK", "continue"},
+ {"STOP_MANUAL", "exit"},
+ {"STOP_BLOCKED", "exit"},
+ {"UNKNOWN", "continue"}, // 未知默认 continue
+ }
+ for _, c := range cases {
+ got := mapJudgeDecision(c.decision, nil)
+ if got != c.want {
+ t.Errorf("mapJudgeDecision(%q, nil) = %q, want %q", c.decision, got, c.want)
+ }
+ }
+
+ // 自定义映射
+ custom := map[string]string{
+ "CONTINUE": "exit", // 覆盖默认
+ "STOP_MANUAL": "continue", // 覆盖默认
+ }
+ if got := mapJudgeDecision("CONTINUE", custom); got != "exit" {
+ t.Errorf("custom map CONTINUE = %q, want exit", got)
+ }
+ if got := mapJudgeDecision("STOP_MANUAL", custom); got != "continue" {
+ t.Errorf("custom map STOP_MANUAL = %q, want continue", got)
+ }
+}
+
+func TestGetDecision(t *testing.T) {
+ if got := getDecision(nil); got != "" {
+ t.Error("expected empty for nil")
+ }
+ if got := getDecision(&StepResult{}); got != "" {
+ t.Error("expected empty for nil Data")
+ }
+ if got := getDecision(&StepResult{Data: map[string]any{"decision": "STOP_MANUAL"}}); got != "STOP_MANUAL" {
+ t.Errorf("got %q, want STOP_MANUAL", got)
+ }
+}
diff --git a/internal/runtime/parallel_executor.go b/internal/runtime/parallel_executor.go
new file mode 100644
index 0000000..657b5ee
--- /dev/null
+++ b/internal/runtime/parallel_executor.go
@@ -0,0 +1,178 @@
+// 本文件实现 ParallelExecutor,并行执行多个分支。
+//
+// 执行流程:
+// 1. 拍摄基线快照,每个分支用 CloneForBranch 创建独立写空间
+// 2. 用 WaitGroup + context.WithCancel 并发执行所有分支
+// 3. 按 join 策略汇合:
+// - all: 全成功才成功
+// - any: 任一成功即成功
+// - first_success: 首个成功后 cancel 其他
+// 4. 成功分支的结果 Merge 回主 EC
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+ "sync"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// dispatchFunc 是 step 分发函数,用于处理嵌套编排原语。
+type dispatchFunc func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error)
+
+// ParallelExecutor 并行执行多个分支。
+type ParallelExecutor struct {
+ dispatch dispatchFunc
+}
+
+// NewParallelExecutor 创建 ParallelExecutor。
+func NewParallelExecutor(dispatch dispatchFunc) *ParallelExecutor {
+ return &ParallelExecutor{dispatch: dispatch}
+}
+
+// Execute 并行执行所有分支。
+func (e *ParallelExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ branches := step.Parallel
+ if len(branches) == 0 {
+ return nil, fmt.Errorf("ParallelExecutor: no branches")
+ }
+
+ join := step.Join
+ if join == "" {
+ join = "all"
+ }
+
+ // 基线快照
+ base := ec.Snapshot()
+
+ // 并发执行
+ results := make([]*StepResult, len(branches))
+ errs := make([]error, len(branches))
+
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ var wg sync.WaitGroup
+ var firstSuccessMu sync.Mutex
+ firstSuccessIdx := -1
+
+ for i, branch := range branches {
+ i, branch := i, branch
+
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ branchEC := ec.CloneForBranch(base)
+ r, err := e.dispatch(ctx, branch, branchEC)
+ results[i] = r
+ errs[i] = err
+
+ // first_success: 首个成功后 cancel 其他
+ if join == "first_success" && err == nil && r != nil && r.Success && !r.Skipped {
+ firstSuccessMu.Lock()
+ if firstSuccessIdx < 0 {
+ firstSuccessIdx = i
+ cancel() // 取消其他分支
+ }
+ firstSuccessMu.Unlock()
+ }
+ }()
+ }
+ wg.Wait()
+
+ // 按 join 策略汇合
+ return e.joinResults(results, errs, join, ec, base, firstSuccessIdx)
+}
+
+// joinResults 按 join 策略汇合分支结果。
+func (e *ParallelExecutor) joinResults(
+ results []*StepResult,
+ errs []error,
+ join string,
+ ec *ExecutionContext,
+ _ map[string]*StepResult,
+ firstSuccessIdx int,
+) (*StepResult, error) {
+ // 收集成功分支
+ var successBranches []int
+ var failedErrs []error
+ for i, r := range results {
+ if errs[i] != nil {
+ failedErrs = append(failedErrs, errs[i])
+ continue
+ }
+ if r != nil && r.Success {
+ successBranches = append(successBranches, i)
+ }
+ }
+
+ // 判断整体成功/失败
+ overallSuccess := false
+ switch join {
+ case "all":
+ overallSuccess = len(successBranches) == len(results) && len(failedErrs) == 0
+ case "any":
+ overallSuccess = len(successBranches) > 0
+ case "first_success":
+ overallSuccess = firstSuccessIdx >= 0
+ default:
+ overallSuccess = len(successBranches) == len(results) && len(failedErrs) == 0
+ }
+
+ // 即使失败也尝试 Merge 成功分支的结果(便于后续诊断)
+ for _, i := range successBranches {
+ if results[i] != nil && results[i].StageName != "" {
+ ec.SetResult(results[i].StageName, results[i])
+ }
+ }
+
+ // 构造聚合结果
+ agg := &StepResult{
+ StageName: "parallel",
+ Success: overallSuccess,
+ }
+
+ if !overallSuccess {
+ // 收集错误信息
+ if len(failedErrs) > 0 {
+ return agg, fmt.Errorf("parallel join=%s: %d branches failed, first error: %w",
+ join, len(failedErrs), failedErrs[0])
+ }
+ // 没有错误但没成功(如 all 模式下某些分支 !Success)
+ return agg, nil
+ }
+
+ // 成功:聚合 status
+ agg.Status = aggregateStatus(results, join, firstSuccessIdx, successBranches)
+
+ return agg, nil
+}
+
+// aggregateStatus 按 join 策略聚合多个分支的 status。
+func aggregateStatus(results []*StepResult, join string, firstSuccessIdx int, successBranches []int) string {
+ if join == "all" {
+ for _, r := range results {
+ if r == nil || r.Status != "PASS" {
+ if r != nil && r.Status != "" && r.Status != "PASS" {
+ return ""
+ }
+ }
+ }
+ if len(results) > 0 {
+ return "PASS"
+ }
+ return ""
+ }
+ if firstSuccessIdx >= 0 && results[firstSuccessIdx] != nil {
+ return results[firstSuccessIdx].Status
+ }
+ if len(successBranches) > 0 && results[successBranches[0]] != nil {
+ return results[successBranches[0]].Status
+ }
+ return ""
+}
diff --git a/internal/runtime/parallel_executor_test.go b/internal/runtime/parallel_executor_test.go
new file mode 100644
index 0000000..e1216cc
--- /dev/null
+++ b/internal/runtime/parallel_executor_test.go
@@ -0,0 +1,227 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// makeDispatch 创建 mock dispatch 函数,按 stage name 返回预设结果。
+func makeDispatch(results map[string]*StepResult) dispatchFunc {
+ return func(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ if step.Stage == nil {
+ return nil, errors.New("stage is nil")
+ }
+ name := step.Stage.Name
+ r, ok := results[name]
+ if !ok {
+ return nil, errors.New("no result for " + name)
+ }
+ ec.SetResult(name, r)
+ return r, nil
+ }
+}
+
+func TestParallelExecutor_AllJoin_Success(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: true, Status: "PASS"},
+ "b": {StageName: "b", Success: true, Status: "PASS"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "all",
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success for all join")
+ }
+ if result.Status != "PASS" {
+ t.Errorf("got status %q, want PASS", result.Status)
+ }
+}
+
+func TestParallelExecutor_AllJoin_OneFails(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: true, Status: "PASS"},
+ "b": {StageName: "b", Success: false, Status: "FAIL"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "all",
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ // all 模式下 b 失败不应返回 error(除非有 err),但应不 success
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Success {
+ t.Error("expected failure when one branch fails in all mode")
+ }
+}
+
+func TestParallelExecutor_AnyJoin_OneSucceeds(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: false, Status: "FAIL"},
+ "b": {StageName: "b", Success: true, Status: "PASS"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "any",
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success for any join when one succeeds")
+ }
+}
+
+func TestParallelExecutor_AnyJoin_AllFail(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: false, Status: "FAIL"},
+ "b": {StageName: "b", Success: false, Status: "FAIL"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "any",
+ }
+
+ result, _ := exec.Execute(context.Background(), step, ec)
+ if result.Success {
+ t.Error("expected failure when all branches fail in any mode")
+ }
+}
+
+func TestParallelExecutor_FirstSuccess(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: true, Status: "PASS"},
+ "b": {StageName: "b", Success: true, Status: "PASS"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "first_success",
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success for first_success")
+ }
+}
+
+func TestParallelExecutor_WriteIsolation(t *testing.T) {
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: true, Status: "PASS", Data: map[string]any{"from": "a"}},
+ "b": {StageName: "b", Success: true, Status: "PASS", Data: map[string]any{"from": "b"}},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ Join: "all",
+ }
+
+ _, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // 两个分支的结果都应 Merge 回主 EC
+ rA, ok := ec.GetResult("a")
+ if !ok {
+ t.Fatal("expected result a in EC after merge")
+ }
+ if rA.Data["from"] != "a" {
+ t.Errorf("a.Data[from] = %v, want a", rA.Data["from"])
+ }
+
+ rB, ok := ec.GetResult("b")
+ if !ok {
+ t.Fatal("expected result b in EC after merge")
+ }
+ if rB.Data["from"] != "b" {
+ t.Errorf("b.Data[from] = %v, want b", rB.Data["from"])
+ }
+}
+
+func TestParallelExecutor_DefaultJoin(t *testing.T) {
+ // 不设 join,默认应为 all
+ dispatch := makeDispatch(map[string]*StepResult{
+ "a": {StageName: "a", Success: true, Status: "PASS"},
+ "b": {StageName: "b", Success: true, Status: "PASS"},
+ })
+ exec := NewParallelExecutor(dispatch)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{
+ Parallel: []schema.PipelineStep{
+ {Stage: &schema.Stage{Name: "a", Tool: "t"}},
+ {Stage: &schema.Stage{Name: "b", Tool: "t"}},
+ },
+ }
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !result.Success {
+ t.Error("expected success for default (all) join")
+ }
+}
+
+func TestParallelExecutor_NoBranches(t *testing.T) {
+ exec := NewParallelExecutor(makeDispatch(nil))
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, nil)
+
+ step := schema.PipelineStep{Parallel: []schema.PipelineStep{}}
+
+ _, err := exec.Execute(context.Background(), step, ec)
+ if err == nil {
+ t.Fatal("expected error for empty parallel")
+ }
+}
diff --git a/internal/runtime/prompt_builder.go b/internal/runtime/prompt_builder.go
new file mode 100644
index 0000000..d77d8d9
--- /dev/null
+++ b/internal/runtime/prompt_builder.go
@@ -0,0 +1,195 @@
+// 本文件实现 PromptBuilder,从 embed FS 加载 prompt 模板并渲染。
+//
+// 设计说明:
+// - prompt 模板用 Go text/template 语法,命名参数替代位置敏感的 %s/%d
+// - 模板文件位于 internal/runtime/prompts/*.md,通过 go:embed 打包
+// - PromptData 结构体覆盖所有模板的字段需求,不同模板用不同子集
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "text/template"
+)
+
+//go:embed prompts/*.md
+var promptFS embed.FS
+
+// 内置 prompt 模板名称。
+const (
+ promptIssueHandling = "issue-handling"
+ promptCoding = "coding"
+ promptReview = "review"
+ promptIssuePost = "issue-post"
+ promptLoopJudge = "loop-judge"
+)
+
+// PromptData 包含所有 prompt 模板可能用到的参数。
+//
+// 不同模板使用不同字段子集:
+// - issue-handling: WorkerID, DefaultAgent, Repo, IssueNumber, IssueURL, PlatformName, PlatformGuide, ResultFilePath, PostFeedback
+// - coding: Agent, ResultFilePath, WorkerID, Repo, IssueNumber, IssueURL, PlatformName, Round, MaxRounds, PlatformGuide, IssueSummary, Acceptance, ReviewerFeedback
+// - review: ReviewerAgent, ResultFilePath, WorkerID, Repo, IssueNumber, IssueURL, PlatformName, Round, MaxRounds, ConsecutiveUnknowns, PlatformGuide, IssueSummary, Acceptance, CodingSummary, CodingEvidence, CodingAcceptanceCheck
+// - issue-post: IssuePostAgent, SourceFilePath, ResultFilePath, WorkerID, Repo, IssueNumber, IssueURL, PlatformName, Purpose, PlatformGuide, PreviousFeedback
+// - loop-judge: HistoryFilePath, ResultFilePath, WorkerID, Repo, IssueNumber, IssueURL, PlatformName, Round, MaxRounds, PlatformGuide
+type PromptData struct {
+ // 公共字段
+ WorkerID string
+ IssueURL string
+ PlatformName string
+ PlatformGuide string
+ Repo string
+ IssueNumber int
+
+ // 结果文件路径(多数模板都需要)
+ ResultFilePath string
+
+ // Issue handling 模板
+ DefaultAgent string
+ PostFeedback string
+
+ // Coding 模板
+ Agent string
+ Round int
+ MaxRounds int
+ ReviewerFeedback string
+ IssueSummary string
+ Acceptance string
+
+ // Review 模板
+ ReviewerAgent string
+ ConsecutiveUnknowns int
+ CodingSummary string
+ CodingEvidence string
+ CodingAcceptanceCheck string
+
+ // Issue post 模板
+ IssuePostAgent string
+ SourceFilePath string
+ Purpose string
+ PreviousFeedback string
+
+ // Loop judge 模板
+ HistoryFilePath string
+}
+
+// PromptBuilder 从 embed FS 加载 prompt 模板并渲染。
+type PromptBuilder struct {
+ templates map[string]*template.Template
+}
+
+// NewPromptBuilder 加载内置 prompt 模板,然后叠加外部目录模板(overlay 语义)。
+//
+// 加载策略:
+// - dir 为空: 只加载内置 5 个模板
+// - dir 非空: 先加载内置模板,再读取 dir 下所有 .md 文件,同名模板覆盖内置
+//
+// overlay 语义让用户只需提供想自定义的模板,其余回退到内置默认。
+func NewPromptBuilder(dir string) (*PromptBuilder, error) {
+ templates := make(map[string]*template.Template)
+
+ // 1. 加载内置模板
+ names := []string{
+ promptIssueHandling,
+ promptCoding,
+ promptReview,
+ promptIssuePost,
+ promptLoopJudge,
+ }
+
+ for _, name := range names {
+ path := "prompts/" + name + ".md"
+ data, err := promptFS.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("runtime.NewPromptBuilder: load %s: %w", name, err)
+ }
+
+ tmpl, err := template.New(name).Parse(string(data))
+ if err != nil {
+ return nil, fmt.Errorf("runtime.NewPromptBuilder: parse %s: %w", name, err)
+ }
+ templates[name] = tmpl
+ }
+
+ // 2. 叠加外部目录模板(overlay)
+ if dir != "" {
+ if err := loadPromptsFromDir(dir, templates); err != nil {
+ return nil, err
+ }
+ }
+
+ return &PromptBuilder{templates: templates}, nil
+}
+
+// loadPromptsFromDir 读取 dir 下所有 .md 文件,解析为模板并覆盖 templates 中的同名条目。
+func loadPromptsFromDir(dir string, templates map[string]*template.Template) error {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return fmt.Errorf("runtime.NewPromptBuilder: read prompts dir %q: %w", dir, err)
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
+ continue
+ }
+
+ name := strings.TrimSuffix(entry.Name(), ".md")
+ data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ return fmt.Errorf("runtime.NewPromptBuilder: read %s: %w", entry.Name(), err)
+ }
+
+ tmpl, err := template.New(name).Parse(string(data))
+ if err != nil {
+ return fmt.Errorf("runtime.NewPromptBuilder: parse %s: %w", name, err)
+ }
+
+ templates[name] = tmpl // 覆盖同名内置模板
+ }
+
+ return nil
+}
+
+// Build 渲染指定模板,返回最终 prompt 字符串。
+func (b *PromptBuilder) Build(templateName string, data PromptData) (string, error) {
+ tmpl, ok := b.templates[templateName]
+ if !ok {
+ return "", fmt.Errorf("runtime.PromptBuilder.Build: unknown template %q", templateName)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return "", fmt.Errorf("runtime.PromptBuilder.Build: execute %q: %w", templateName, err)
+ }
+
+ return buf.String(), nil
+}
+
+// mapTemplateToKind 把 prompt 模板名映射到 agent.TaskKind。
+//
+// agent.Runner.Run 的 kind 参数仅用于错误归因,不影响执行逻辑,
+// 但保持映射一致有助于日志和错误信息可读。
+func mapTemplateToKind(templateName string) string {
+ switch templateName {
+ case promptIssueHandling:
+ return "issue-handling"
+ case promptCoding:
+ return "coding"
+ case promptReview:
+ return "review"
+ case promptIssuePost:
+ return "issue-post"
+ case promptLoopJudge:
+ return "loop-judge"
+ default:
+ return "unknown"
+ }
+}
diff --git a/internal/runtime/prompt_builder_test.go b/internal/runtime/prompt_builder_test.go
new file mode 100644
index 0000000..2cffe8d
--- /dev/null
+++ b/internal/runtime/prompt_builder_test.go
@@ -0,0 +1,323 @@
+package runtime
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestNewPromptBuilder_LoadsAllTemplates(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ for _, name := range []string{promptIssueHandling, promptCoding, promptReview, promptIssuePost, promptLoopJudge} {
+ if _, ok := pb.templates[name]; !ok {
+ t.Errorf("template %q not loaded", name)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_IssueHandling(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ data := PromptData{
+ WorkerID: "worker-123",
+ DefaultAgent: "开发者",
+ Repo: "owner/repo",
+ IssueNumber: 42,
+ IssueURL: "https://github.com/owner/repo/issues/42",
+ PlatformName: "github",
+ PlatformGuide: "guide content",
+ ResultFilePath: "/tmp/issue_intake_result.json",
+ PostFeedback: "previous feedback",
+ }
+
+ out, err := pb.Build(promptIssueHandling, data)
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ // 验证关键字段都被渲染
+ checks := []string{
+ "worker-123",
+ "开发者",
+ "owner/repo",
+ "#42",
+ "https://github.com/owner/repo/issues/42",
+ "/tmp/issue_intake_result.json",
+ "previous feedback",
+ "guide content",
+ }
+ for _, s := range checks {
+ if !strings.Contains(out, s) {
+ t.Errorf("output missing %q", s)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_Coding(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ data := PromptData{
+ Agent: "开发者",
+ ResultFilePath: "/tmp/coding_result.json",
+ WorkerID: "worker-1",
+ Repo: "owner/repo",
+ IssueNumber: 7,
+ IssueURL: "https://github.com/owner/repo/issues/7",
+ PlatformName: "github",
+ Round: 2,
+ MaxRounds: 3,
+ PlatformGuide: "guide",
+ IssueSummary: "fix bug",
+ Acceptance: "tests pass",
+ ReviewerFeedback: "please add tests",
+ }
+
+ out, err := pb.Build(promptCoding, data)
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ checks := []string{
+ "@开发者",
+ "/tmp/coding_result.json",
+ "Round: 2/3",
+ "fix bug",
+ "tests pass",
+ "please add tests",
+ }
+ for _, s := range checks {
+ if !strings.Contains(out, s) {
+ t.Errorf("output missing %q", s)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_Review(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ data := PromptData{
+ ReviewerAgent: "QA负责人",
+ ResultFilePath: "/tmp/review_result.json",
+ WorkerID: "worker-1",
+ Repo: "owner/repo",
+ IssueNumber: 7,
+ IssueURL: "https://github.com/owner/repo/issues/7",
+ PlatformName: "github",
+ Round: 1,
+ MaxRounds: 3,
+ ConsecutiveUnknowns: 2,
+ PlatformGuide: "guide",
+ IssueSummary: "summary",
+ Acceptance: "acceptance",
+ CodingSummary: "coding summary",
+ CodingEvidence: "evidence",
+ CodingAcceptanceCheck: "self check",
+ }
+
+ out, err := pb.Build(promptReview, data)
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ checks := []string{
+ "@QA负责人",
+ "/tmp/review_result.json",
+ "Round: 1/3",
+ "Consecutive UNKNOWN Before This Round: 2",
+ "coding summary",
+ "evidence",
+ "self check",
+ }
+ for _, s := range checks {
+ if !strings.Contains(out, s) {
+ t.Errorf("output missing %q", s)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_IssuePost(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ data := PromptData{
+ IssuePostAgent: "产品经理",
+ SourceFilePath: "/tmp/coding_result.json",
+ ResultFilePath: "/tmp/issue_post_result.json",
+ WorkerID: "worker-1",
+ Repo: "owner/repo",
+ IssueNumber: 7,
+ IssueURL: "https://github.com/owner/repo/issues/7",
+ PlatformName: "github",
+ Purpose: "completion",
+ PlatformGuide: "guide",
+ PreviousFeedback: "prev feedback",
+ }
+
+ out, err := pb.Build(promptIssuePost, data)
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ checks := []string{
+ "@产品经理",
+ "/tmp/coding_result.json",
+ "/tmp/issue_post_result.json",
+ "Purpose: completion",
+ "prev feedback",
+ }
+ for _, s := range checks {
+ if !strings.Contains(out, s) {
+ t.Errorf("output missing %q", s)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_LoopJudge(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ data := PromptData{
+ HistoryFilePath: "/tmp/loop_history.json",
+ ResultFilePath: "/tmp/loop_judge_result.json",
+ WorkerID: "worker-1",
+ Repo: "owner/repo",
+ IssueNumber: 7,
+ IssueURL: "https://github.com/owner/repo/issues/7",
+ PlatformName: "github",
+ Round: 2,
+ MaxRounds: 3,
+ PlatformGuide: "guide",
+ }
+
+ out, err := pb.Build(promptLoopJudge, data)
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ checks := []string{
+ "/tmp/loop_history.json",
+ "/tmp/loop_judge_result.json",
+ "Round: 2/3",
+ }
+ for _, s := range checks {
+ if !strings.Contains(out, s) {
+ t.Errorf("output missing %q", s)
+ }
+ }
+}
+
+func TestPromptBuilder_Build_UnknownTemplate(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder() error: %v", err)
+ }
+
+ _, err = pb.Build("nonexistent", PromptData{})
+ if err == nil {
+ t.Fatal("expected error for unknown template, got nil")
+ }
+ if !strings.Contains(err.Error(), "unknown template") {
+ t.Errorf("error should mention unknown template, got: %v", err)
+ }
+}
+
+func TestMapTemplateToKind(t *testing.T) {
+ cases := []struct {
+ template string
+ want string
+ }{
+ {promptIssueHandling, "issue-handling"},
+ {promptCoding, "coding"},
+ {promptReview, "review"},
+ {promptIssuePost, "issue-post"},
+ {promptLoopJudge, "loop-judge"},
+ {"unknown", "unknown"},
+ }
+
+ for _, c := range cases {
+ got := mapTemplateToKind(c.template)
+ if got != c.want {
+ t.Errorf("mapTemplateToKind(%q) = %q, want %q", c.template, got, c.want)
+ }
+ }
+}
+
+// TestNewPromptBuilder_OverlayExternalDir 验证外部目录的 .md 模板会覆盖同名内置模板,
+// 而未覆盖的模板仍用内置默认。
+func TestNewPromptBuilder_OverlayExternalDir(t *testing.T) {
+ // 创建临时目录,写入自定义 coding.md
+ tempDir := t.TempDir()
+ customContent := "自定义 coding 模板:{{.Repo}} #{{.IssueNumber}}"
+ if err := os.WriteFile(filepath.Join(tempDir, "coding.md"), []byte(customContent), 0o600); err != nil {
+ t.Fatalf("os.WriteFile() error: %v", err)
+ }
+
+ pb, err := NewPromptBuilder(tempDir)
+ if err != nil {
+ t.Fatalf("NewPromptBuilder(%q) error: %v", tempDir, err)
+ }
+
+ // coding 模板应被覆盖
+ output, err := pb.Build(promptCoding, PromptData{Repo: "test/repo", IssueNumber: 7})
+ if err != nil {
+ t.Fatalf("Build(coding) error: %v", err)
+ }
+ if !strings.Contains(output, "自定义 coding 模板") {
+ t.Errorf("coding template should be overridden, got: %s", output)
+ }
+ if !strings.Contains(output, "test/repo #7") {
+ t.Errorf("coding template should render data, got: %s", output)
+ }
+
+ // review 模板应仍用内置默认(未被覆盖)
+ reviewOutput, err := pb.Build(promptReview, PromptData{ReviewerAgent: "审查员"})
+ if err != nil {
+ t.Fatalf("Build(review) error: %v", err)
+ }
+ if !strings.Contains(reviewOutput, "审查") {
+ t.Errorf("review template should use builtin, got: %s", reviewOutput)
+ }
+}
+
+// TestNewPromptBuilder_ExternalDirNotFound 验证不存在的目录会返回错误。
+func TestNewPromptBuilder_ExternalDirNotFound(t *testing.T) {
+ _, err := NewPromptBuilder("/nonexistent/path/that/should/not/exist")
+ if err == nil {
+ t.Fatal("NewPromptBuilder() with nonexistent dir should return error")
+ }
+ if !strings.Contains(err.Error(), "read prompts dir") {
+ t.Errorf("error should mention prompts dir, got: %v", err)
+ }
+}
+
+// TestNewPromptBuilder_EmptyDirUsesBuiltinOnly 验证空字符串目录只加载内置模板。
+func TestNewPromptBuilder_EmptyDirUsesBuiltinOnly(t *testing.T) {
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder(\"\") error: %v", err)
+ }
+
+ for _, name := range []string{promptIssueHandling, promptCoding, promptReview, promptIssuePost, promptLoopJudge} {
+ if _, ok := pb.templates[name]; !ok {
+ t.Errorf("builtin template %q not loaded", name)
+ }
+ }
+}
diff --git a/internal/runtime/prompts/coding.md b/internal/runtime/prompts/coding.md
new file mode 100644
index 0000000..fe1ceb3
--- /dev/null
+++ b/internal/runtime/prompts/coding.md
@@ -0,0 +1,56 @@
+你是蜂巢编码智能体 @{{.Agent}}。
+
+你的职责:
+1. 自己查看 Issue 并完成实现、自验、必要的 PR 操作
+2. 把本轮结果写入指定 JSON 文件
+3. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
+
+最小切入原则(修正/开发/推进均适用,违反即停手):
+- 修正:只改导致问题的根因行。不顺手重构周边代码,不修"附近"的其它问题,不做纯格式化,不改未在反馈中提到的文件
+- 开发:只实现 Issue 验收标准要求的功能。不写"以防万一"的抽象,不加未要求的配置项或扩展点,不引入标准库/已有依赖能搞定的外部依赖
+- 推进:每轮只做一件聚焦的事。不把多个不相关改动打包进同一轮;下一轮基于本轮证据再决定是否扩大范围
+
+判断标准:你能为每一行改动找到和"当前 Issue 验收标准"或"本轮 reviewer 反馈"的直接关联吗?找不到 → 撤回该行。
+发现自己处于 Kitchen Sink(修水龙头拆厨房)、Runaway Refactor(连锁修改)、Over Abstraction(为以后而抽象)任一状态 → 立即停手,回退到本轮最小改动再提交。
+
+你必须把结果写入以下文件:
+- 结果文件: {{.ResultFilePath}}
+
+结果文件必须是合法 JSON,字段固定如下:
+{
+ "status": "DONE / IN_PROGRESS / BLOCKED",
+ "summary": "本轮工作总结",
+ "evidence": "可验证证据",
+ "acceptance_check": "按验收标准逐条自检结果",
+ "next_action": "下一步动作"
+}
+
+上下文:
+- Worker: {{.WorkerID}}
+- Repo: {{.Repo}}
+- Issue: #{{.IssueNumber}}
+- URL: {{.IssueURL}}
+- Platform: {{.PlatformName}}
+- Round: {{.Round}}/{{.MaxRounds}}
+
+平台技能包说明:
+{{.PlatformGuide}}
+
+Issue 摘要:
+{{.IssueSummary}}
+
+验收标准:
+{{.Acceptance}}
+
+上一轮 reviewer 反馈(首轮为空):
+{{.ReviewerFeedback}}
+
+阶段要求:
+1. 你必须自己查看 Issue 原文并开展编码工作
+2. 你必须根据 reviewer 反馈修复问题或补充证据
+3. 如果当前已准备好接受审查,写入 "status": "DONE"
+4. 如果还需要继续开发或补证据,写入 "status": "IN_PROGRESS"
+5. 如果因权限、环境、外部依赖等无法继续,写入 "status": "BLOCKED"
+6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释
+7. 不要直接提交 Issue 评论
+8. 每一行改动必须能说出与 Issue 验收标准或本轮 reviewer 反馈的直接关联;做不到的撤回该行,不要硬提交
diff --git a/internal/runtime/prompts/issue-handling.md b/internal/runtime/prompts/issue-handling.md
new file mode 100644
index 0000000..48dd2cf
--- /dev/null
+++ b/internal/runtime/prompts/issue-handling.md
@@ -0,0 +1,44 @@
+你是蜂巢 Issue 处理智能体。
+
+你的职责:
+1. 自己查看目标 Issue
+2. 判断当前 Issue 是接单还是阻塞
+3. 把结构化结果写入指定 JSON 文件
+4. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
+
+你必须把结果写入以下文件:
+- 结果文件: {{.ResultFilePath}}
+
+结果文件必须是合法 JSON,字段固定如下:
+{
+ "status": "READY 或 BLOCKED",
+ "agent": "开发者 / 技术负责人 / 架构师 / 产品经理 / QA负责人 / UI负责人 之一",
+ "summary": "你对任务的理解摘要",
+ "acceptance": "验收标准或完成判断依据",
+ "next_action": "下一步动作",
+ "comment_body": "准备提交到 Issue 的接单或阻塞评论正文"
+}
+
+上下文:
+- Worker: {{.WorkerID}}
+- Default Agent: @{{.DefaultAgent}}
+- Repo: {{.Repo}}
+- Issue: #{{.IssueNumber}}
+- URL: {{.IssueURL}}
+- Platform: {{.PlatformName}}
+
+平台技能包说明:
+{{.PlatformGuide}}
+
+Issue 提交智能体上一轮反馈(首轮为空):
+{{.PostFeedback}}
+
+阶段要求:
+1. 你必须自己使用平台工具查看 Issue 原文和评论
+2. 你必须自己判断当前 Issue 是否可开工
+3. 如果不能开工,写入 "status": "BLOCKED"
+4. 如果可以开工,写入 "status": "READY"
+5. 你必须选择最合适的编码角色;无法判断时回退为默认角色 {{.DefaultAgent}}
+6. 你必须同时写出可直接发布的 Issue 评论正文,放到 "comment_body"
+7. 结果文件必须可被机器稳定解析,不能写 JSON 之外的解释
+8. 不要直接提交 Issue 评论
diff --git a/internal/runtime/prompts/issue-post.md b/internal/runtime/prompts/issue-post.md
new file mode 100644
index 0000000..7e6b11d
--- /dev/null
+++ b/internal/runtime/prompts/issue-post.md
@@ -0,0 +1,43 @@
+你是蜂巢 Issue 提交智能体 @{{.IssuePostAgent}}。
+
+你的职责:
+1. 读取指定结果文件
+2. 检查该结果文件里的 "comment_body" 是否足以直接发布为合格的 Issue 评论
+3. 如果合格,则由你自己完成 Issue 评论提交
+4. 如果不合格,则不要提交评论,而是把修正反馈写入指定结果文件
+
+输入文件:
+- 源结果文件: {{.SourceFilePath}}
+
+输出文件:
+- 提交结果文件: {{.ResultFilePath}}
+
+提交结果文件必须是合法 JSON,字段固定如下:
+{
+ "status": "POSTED / REJECTED / BLOCKED",
+ "summary": "本次提交动作总结",
+ "feedback": "若被拒绝,需要给上游阶段的修正反馈;若已提交,也要写明提交了什么",
+ "next_action": "下一步动作"
+}
+
+上下文:
+- Worker: {{.WorkerID}}
+- Repo: {{.Repo}}
+- Issue: #{{.IssueNumber}}
+- URL: {{.IssueURL}}
+- Platform: {{.PlatformName}}
+- Purpose: {{.Purpose}}
+
+平台技能包说明:
+{{.PlatformGuide}}
+
+上一轮 Issue 提交反馈(首轮为空):
+{{.PreviousFeedback}}
+
+阶段要求:
+1. 你必须自己读取源结果文件并校验其完整性,重点检查 "comment_body"
+2. 只有当源结果中的 "comment_body" 非空且评论格式合理时,才允许提交 Issue 评论,并写入 "status": "POSTED"
+3. 如果源结果缺少 "comment_body" 或评论内容不足以安全提交,写入 "status": "REJECTED",并明确说明缺什么
+4. 如果因权限、环境或外部依赖无法提交,写入 "status": "BLOCKED"
+5. 你可以自己使用平台工具完成最终评论提交
+6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释
diff --git a/internal/runtime/prompts/loop-judge.md b/internal/runtime/prompts/loop-judge.md
new file mode 100644
index 0000000..e59559b
--- /dev/null
+++ b/internal/runtime/prompts/loop-judge.md
@@ -0,0 +1,46 @@
+你是蜂巢价值评估员。
+
+你的职责:
+1. 自己读取 coder-reviewer 逐轮历史文件
+2. 判断当前自动循环是否还有继续价值
+3. 把评估结论写入指定 JSON 文件
+
+输入文件:
+- 逐轮历史: {{.HistoryFilePath}}
+
+输出文件:
+- 评估结果文件: {{.ResultFilePath}}
+
+评估结果文件必须是合法 JSON,字段固定如下:
+{
+ "decision": "CONTINUE / SHRINK_TASK / STOP_MANUAL / STOP_BLOCKED",
+ "reason": "决策详细说明",
+ "evidence": "支撑决策的引用证据,必须引用具体轮次或事实",
+ "confidence": "HIGH / MEDIUM / LOW",
+ "next_action": "本次评估后的下一步动作"
+}
+
+决策说明:
+- CONTINUE: 当前问题在收敛,继续自动循环仍然有价值
+- SHRINK_TASK: 继续自动循环,但下一轮应缩小任务范围,优先补证据或聚焦核心问题
+- STOP_MANUAL: 自动循环已无价值,需要人工接管
+- STOP_BLOCKED: 当前问题依赖外部条件,继续自动循环没有意义
+
+上下文:
+- Worker: {{.WorkerID}}
+- Repo: {{.Repo}}
+- Issue: #{{.IssueNumber}}
+- URL: {{.IssueURL}}
+- Platform: {{.PlatformName}}
+- Round: {{.Round}}/{{.MaxRounds}}
+
+平台技能包说明:
+{{.PlatformGuide}}
+
+阶段要求:
+1. 你必须自己读取逐轮历史文件,理解当前循环进展
+2. 你必须基于历史中的具体证据来判断,不能凭空猜测
+3. 如果连续两轮指出相同问题且没有收敛迹象,应优先考虑 STOP_MANUAL
+4. 如果问题核心是外部依赖缺失,应优先考虑 STOP_BLOCKED
+5. 如果问题和方向正确但证据不足,应优先考虑 SHRINK_TASK
+6. 结果文件必须可被机器稳定解析,不能写 Markdown,不能混入解释
diff --git a/internal/runtime/prompts/review.md b/internal/runtime/prompts/review.md
new file mode 100644
index 0000000..6c9917c
--- /dev/null
+++ b/internal/runtime/prompts/review.md
@@ -0,0 +1,58 @@
+你是蜂巢审查智能体 @{{.ReviewerAgent}}。
+
+你的职责:
+1. 自己查看 Issue、代码现状、评论、PR 与证据
+2. 判断当前结果是否满足 Issue 要求
+3. 把审查结论写入指定 JSON 文件
+4. 不要直接提交 Issue 评论,Issue 评论由专门的 Issue 提交智能体完成
+
+你必须把结果写入以下文件:
+- 结果文件: {{.ResultFilePath}}
+
+结果文件必须是合法 JSON,字段固定如下:
+{
+ "status": "PASS / FAIL / UNKNOWN / BLOCKED",
+ "summary": "审查总结",
+ "check_result": "按验收标准逐条判断结果",
+ "missing": "仍缺失的实现或证据",
+ "next_action": "下一轮 coder 应执行的动作",
+ "comment_body": "若 status=PASS,则这里写准备提交到 Issue 的完成评论正文;否则可留空字符串"
+}
+
+上下文:
+- Worker: {{.WorkerID}}
+- Reviewer: @{{.ReviewerAgent}}
+- Repo: {{.Repo}}
+- Issue: #{{.IssueNumber}}
+- URL: {{.IssueURL}}
+- Platform: {{.PlatformName}}
+- Round: {{.Round}}/{{.MaxRounds}}
+- Consecutive UNKNOWN Before This Round: {{.ConsecutiveUnknowns}}
+
+平台技能包说明:
+{{.PlatformGuide}}
+
+Issue 摘要:
+{{.IssueSummary}}
+
+验收标准:
+{{.Acceptance}}
+
+Coder 本轮总结:
+{{.CodingSummary}}
+
+Coder 本轮证据:
+{{.CodingEvidence}}
+
+Coder 自检结果:
+{{.CodingAcceptanceCheck}}
+
+阶段要求:
+1. 只有在你能够明确确认满足要求时,才能写入 "status": "PASS"
+2. 如果你已确认不满足要求,写入 "status": "FAIL"
+3. 如果你暂时无法确认是否满足要求,写入 "status": "UNKNOWN"
+4. 如果因权限、环境或外部依赖无法继续审查,写入 "status": "BLOCKED"
+5. UNKNOWN 绝不能被当成 PASS
+6. 当证据不足时,应优先要求 coder 补证据,而不是要求它盲目继续改代码
+7. 当 status=PASS 时,你必须写出可直接发布的完成评论正文,放到 "comment_body"
+8. 不要直接提交 Issue 评论
diff --git a/internal/runtime/stage_executor.go b/internal/runtime/stage_executor.go
new file mode 100644
index 0000000..fda0896
--- /dev/null
+++ b/internal/runtime/stage_executor.go
@@ -0,0 +1,136 @@
+// 本文件实现 StageExecutor,执行单个串行 stage。
+//
+// 执行流程:
+// 1. 求值 stage.When,不满足则跳过
+// 2. 通过 ToolRegistry 解析 tool
+// 3. 计算结果文件路径并重置
+// 4. 从 input_from stage 获取上游数据
+// 5. 构造 Invocation 并执行 Tool
+// 6. 处理 on_blocked 策略(stop/skip/continue)
+// 7. 存储结果到 ExecutionContext
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// StageExecutor 执行单个 stage。
+type StageExecutor struct {
+ registry *ToolRegistry
+}
+
+// NewStageExecutor 创建 StageExecutor。
+func NewStageExecutor(registry *ToolRegistry) *StageExecutor {
+ return &StageExecutor{registry: registry}
+}
+
+// Execute 执行 stage 步骤。
+func (e *StageExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec *ExecutionContext) (*StepResult, error) {
+ stage := step.Stage
+ if stage == nil {
+ return nil, fmt.Errorf("StageExecutor: step.Stage is nil")
+ }
+
+ // 1. 求值 when 条件
+ matched, err := EvalCondition(ec, stage.When)
+ if err != nil {
+ return nil, fmt.Errorf("stage %s: when: %w", stage.Name, err)
+ }
+ if !matched {
+ result := &StepResult{
+ StageName: stage.Name,
+ Success: true,
+ Skipped: true,
+ }
+ ec.SetResult(stage.Name, result)
+ return result, nil
+ }
+
+ // 2. 解析 tool
+ tool, ok := e.registry.Resolve(stage.Tool)
+ if !ok {
+ return nil, fmt.Errorf("stage %s: tool %q not found", stage.Name, stage.Tool)
+ }
+
+ // 3. 计算结果文件路径
+ if ec.Artifacts == nil {
+ return nil, fmt.Errorf("stage %s: artifacts resolver is nil", stage.Name)
+ }
+ loopRound := 0
+ if loop := ec.CurrentLoop(); loop != nil {
+ loopRound = loop.Round
+ }
+ resultFilePath := ec.Artifacts.ResolveResultFile(stage.Name, stage.Args, loopRound)
+
+ // 4. 重置结果文件
+ if err := ec.Artifacts.ResetResultFile(resultFilePath); err != nil {
+ return nil, fmt.Errorf("stage %s: reset result file: %w", stage.Name, err)
+ }
+
+ // 5. 获取 input_from 数据
+ var inputFrom map[string]any
+ if stage.InputFrom != "" {
+ if val, ok := ec.Lookup(stage.InputFrom, ""); ok {
+ if m, ok := val.(map[string]any); ok {
+ inputFrom = m
+ }
+ }
+ }
+
+ // 6. 构造 Invocation
+ inv := Invocation{
+ StageName: stage.Name,
+ Tool: tool.Definition(),
+ InputFrom: inputFrom,
+ Args: stage.Args,
+ ResultFilePath: resultFilePath,
+ Loop: ec.CurrentLoop(),
+ Platform: ec.Platform,
+ Config: ec.Config,
+ EC: ec,
+ }
+
+ // 7. 执行 Tool
+ result, err := tool.Execute(ctx, inv)
+ if err != nil {
+ if result == nil {
+ result = &StepResult{StageName: stage.Name, Success: false}
+ }
+ ec.SetResult(stage.Name, result)
+ return result, err
+ }
+
+ // 8. 确保 StageName 正确
+ if result.StageName == "" {
+ result.StageName = stage.Name
+ }
+
+ // 9. 处理 on_blocked
+ handleOnBlocked(result, stage.OnBlocked)
+
+ // 10. 存储结果
+ ec.SetResult(stage.Name, result)
+
+ return result, nil
+}
+
+// handleOnBlocked 根据 stage 的 on_blocked 策略调整阻塞结果。
+func handleOnBlocked(result *StepResult, onBlocked string) {
+ if !result.Blocked {
+ return
+ }
+ if onBlocked == "" {
+ onBlocked = "stop"
+ }
+ if onBlocked == "skip" {
+ result.Skipped = true
+ }
+ // "continue" 和 "stop" 不改 result 字段,上层 Engine 决定是否终止
+}
diff --git a/internal/runtime/stage_executor_test.go b/internal/runtime/stage_executor_test.go
new file mode 100644
index 0000000..6db8826
--- /dev/null
+++ b/internal/runtime/stage_executor_test.go
@@ -0,0 +1,261 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+func newStageExecutorForTest(t *testing.T, template string) (*StageExecutor, *scriptedRunner, *ExecutionContext) {
+ t.Helper()
+ pb, _ := NewPromptBuilder("")
+ runner := &scriptedRunner{output: "ok"}
+ wf := &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "agent-tool", Type: "agent", PromptTemplate: template, Skill: "s/SKILL.md"},
+ },
+ }
+ registry, err := NewToolRegistry(wf, runner, pb)
+ if err != nil {
+ t.Fatalf("NewToolRegistry: %v", err)
+ }
+ fa := &fakeArtifacts{results: map[string]map[string]any{}}
+ ec := NewExecutionContext(wf, fa, PlatformContext{}, &config.Config{Repo: "o/r", IssueNumber: 1})
+ return NewStageExecutor(registry), runner, ec
+}
+
+func TestStageExecutor_Execute_Success(t *testing.T) {
+ exec, _, ec := newStageExecutorForTest(t, promptCoding)
+
+ // 预置 issue-handling 结果
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{"agent": "dev", "summary": "s", "acceptance": "a"},
+ })
+ ec.Artifacts.(*fakeArtifacts).results["coding"] = map[string]any{"status": "DONE"}
+
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success")
+ }
+ if result.Status != "DONE" {
+ t.Errorf("got status %q, want DONE", result.Status)
+ }
+ // 结果应存储到 EC
+ got, ok := ec.GetResult("coding")
+ if !ok {
+ t.Fatal("expected coding result in EC")
+ }
+ if got.Status != "DONE" {
+ t.Errorf("EC result status %s, want DONE", got.Status)
+ }
+}
+
+func TestStageExecutor_Execute_WhenSkipped(t *testing.T) {
+ exec, runner, ec := newStageExecutorForTest(t, promptCoding)
+
+ // when 条件不满足(round < 2,但当前 round=1)
+ ec.PushLoop(&LoopState{ID: "test", Round: 1, MaxIterations: 3})
+
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "judge",
+ Tool: "agent-tool",
+ When: &schema.Condition{Expr: "round >= 2"},
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Skipped {
+ t.Error("expected Skipped=true")
+ }
+ if len(runner.calls) != 0 {
+ t.Errorf("runner should not be called, got %d calls", len(runner.calls))
+ }
+}
+
+func TestStageExecutor_Execute_WhenMatched(t *testing.T) {
+ exec, runner, ec := newStageExecutorForTest(t, promptCoding)
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{"agent": "dev", "summary": "s", "acceptance": "a"},
+ })
+ ec.Artifacts.(*fakeArtifacts).results["coding"] = map[string]any{"status": "DONE"}
+ ec.PushLoop(&LoopState{ID: "test", Round: 2, MaxIterations: 3})
+
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ When: &schema.Condition{Expr: "round >= 2"},
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if result.Skipped {
+ t.Error("expected Skipped=false")
+ }
+ if len(runner.calls) != 1 {
+ t.Errorf("runner should be called once, got %d", len(runner.calls))
+ }
+}
+
+func TestStageExecutor_Execute_ToolNotFound(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ wf := &schema.Workflow{Tools: []schema.Tool{}}
+ registry, _ := NewToolRegistry(wf, &scriptedRunner{}, pb)
+ ec := NewExecutionContext(wf, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ exec := NewStageExecutor(registry)
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "test",
+ Tool: "nonexistent",
+ }}
+
+ _, err := exec.Execute(context.Background(), step, ec)
+ if err == nil {
+ t.Fatal("expected error for missing tool")
+ }
+}
+
+func TestStageExecutor_Execute_OnBlockedStop(t *testing.T) {
+ exec, _, ec := newStageExecutorForTest(t, promptCoding)
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{"agent": "dev", "summary": "s", "acceptance": "a"},
+ })
+ ec.Artifacts.(*fakeArtifacts).results["coding"] = map[string]any{"status": "BLOCKED"}
+
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ OnBlocked: "stop",
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true")
+ }
+ if result.Skipped {
+ t.Error("expected Skipped=false for on_blocked=stop")
+ }
+}
+
+func TestStageExecutor_Execute_OnBlockedSkip(t *testing.T) {
+ exec, _, ec := newStageExecutorForTest(t, promptCoding)
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{"agent": "dev", "summary": "s", "acceptance": "a"},
+ })
+ ec.Artifacts.(*fakeArtifacts).results["coding"] = map[string]any{"status": "BLOCKED"}
+
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ OnBlocked: "skip",
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true")
+ }
+ if !result.Skipped {
+ t.Error("expected Skipped=true for on_blocked=skip")
+ }
+}
+
+func TestStageExecutor_Execute_OnBlockedDefault(t *testing.T) {
+ exec, _, ec := newStageExecutorForTest(t, promptCoding)
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{"agent": "dev", "summary": "s", "acceptance": "a"},
+ })
+ ec.Artifacts.(*fakeArtifacts).results["coding"] = map[string]any{"status": "BLOCKED"}
+
+ // 不设 on_blocked,默认应为 stop
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ }}
+
+ result, err := exec.Execute(context.Background(), step, ec)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true")
+ }
+ if result.Skipped {
+ t.Error("expected Skipped=false for default on_blocked")
+ }
+}
+
+func TestStageExecutor_Execute_RunnerError(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ runner := &scriptedRunner{err: errors.New("runner failed")}
+ wf := &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "agent-tool", Type: "agent", PromptTemplate: promptCoding, Skill: "s"},
+ },
+ }
+ registry, _ := NewToolRegistry(wf, runner, pb)
+ ec := NewExecutionContext(wf, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ exec := NewStageExecutor(registry)
+ step := schema.PipelineStep{Stage: &schema.Stage{
+ Name: "coding",
+ Tool: "agent-tool",
+ }}
+
+ _, err := exec.Execute(context.Background(), step, ec)
+ if err == nil {
+ t.Fatal("expected error from runner")
+ }
+}
+
+func TestStageExecutor_Execute_NilStage(t *testing.T) {
+ exec, _, _ := newStageExecutorForTest(t, promptCoding)
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+
+ step := schema.PipelineStep{}
+ _, err := exec.Execute(context.Background(), step, ec)
+ if err == nil {
+ t.Fatal("expected error for nil stage")
+ }
+}
+
+func TestPickExecutor(t *testing.T) {
+ stageExec := &StageExecutor{}
+ parallelExec := &ParallelExecutor{}
+ loopExec := &LoopExecutor{}
+
+ cases := []struct {
+ step schema.PipelineStep
+ want Executor
+ }{
+ {schema.PipelineStep{Stage: &schema.Stage{}}, stageExec},
+ {schema.PipelineStep{Parallel: []schema.PipelineStep{{}}}, parallelExec},
+ {schema.PipelineStep{Loop: &schema.Loop{}}, loopExec},
+ {schema.PipelineStep{}, nil},
+ }
+ for _, c := range cases {
+ got := pickExecutor(c.step, stageExec, parallelExec, loopExec)
+ if got != c.want {
+ t.Errorf("pickExecutor() = %T, want %T", got, c.want)
+ }
+ }
+}
diff --git a/internal/runtime/tool.go b/internal/runtime/tool.go
new file mode 100644
index 0000000..467b207
--- /dev/null
+++ b/internal/runtime/tool.go
@@ -0,0 +1,154 @@
+// 本文件定义 Runtime 引擎的核心抽象:Tool 接口、StepResult、Invocation。
+//
+// 设计原则:
+// 1. runtime 包不依赖 pipeline 包(避免循环依赖)
+// 2. Tool 统一 agent/command/function 三种工具类型
+// 3. StepResult.Status 是唯一状态字段,收敛现有散落的 BlockedStatus/Passed/Unknown
+// 4. 文件契约通过 ArtifactResolver 接口抽象,由 pipeline 层注入实现
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "context"
+
+ "github.com/JiGuangWorker/code-bee/internal/agent"
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// Runner 是 agent.Runner 的抽象接口。
+//
+// runtime 包通过此接口调用智能体,避免直接依赖 agent 包的具体实现。
+// agent.Runner struct 自动满足此接口(duck typing)。
+type Runner interface {
+ Run(ctx context.Context, kind agent.TaskKind, task string) (*agent.RunResult, error)
+}
+
+// Tool 统一 agent/command/function 三种工具类型的执行接口。
+//
+// 实现类:
+// - AgentTool: 包装 Runner,调用 reasonix 执行 AI 智能体
+// - CommandTool: 通过 exec.CommandContext 执行 shell 命令
+// - FunctionTool: 调用内置注册函数
+type Tool interface {
+ // Definition 返回工具的 schema 定义(name/aliases/skill 等)。
+ Definition() *schema.Tool
+
+ // Execute 执行工具并返回结构化结果。
+ Execute(ctx context.Context, inv Invocation) (*StepResult, error)
+}
+
+// Invocation 封装工具执行时所需的全部运行时上下文。
+type Invocation struct {
+ // StageName 是当前 stage 的名称,用于结果存储和日志。
+ StageName string
+
+ // Tool 是工具的 schema 定义,供 PromptBuilder 渲染时引用。
+ Tool *schema.Tool
+
+ // InputFrom 是 input_from stage 的结构化数据,可为 nil。
+ InputFrom map[string]any
+
+ // Args 是 stage 配置中的参数,如 issue-post 的 purpose。
+ Args map[string]any
+
+ // ResultFilePath 是 agent 端契约文件路径,agent 把 JSON 结果写入此文件。
+ ResultFilePath string
+
+ // Loop 是当前 loop 状态,非 loop 内执行时为 nil。
+ Loop *LoopState
+
+ // Platform 是平台上下文(worker_id/issue_url 等)。
+ Platform PlatformContext
+
+ // Config 是 code-bee 配置,供 prompt 渲染引用。
+ Config *config.Config
+
+ // EC 是执行上下文引用,供 Tool 访问 Artifacts/Lookup 等能力。
+ EC *ExecutionContext
+}
+
+// PlatformContext 封装平台相关的上下文信息。
+type PlatformContext struct {
+ WorkerID string
+ IssueURL string
+ PlatformName string
+ PlatformGuide string
+}
+
+// StepResult 是单个 stage 执行后的结构化结果。
+//
+// 关键设计:
+// - Status 是唯一状态字段,从 result file 的 "status" 字段提取
+// - Data 是完整的结果 map,供 exit_when/when 条件求值时按 dot path 取值
+// - Blocked/ManualRequired/Completed 由 Executor 层根据 Status 和 loop 语义设置
+type StepResult struct {
+ // StageName 是对应的 stage 名称。
+ StageName string
+
+ // Success 表示工具调用本身是否成功(无系统级错误)。
+ Success bool
+
+ // Blocked 表示业务流程被阻塞(status=BLOCKED)。
+ Blocked bool
+
+ // ManualRequired 表示需要人工接管(loop 超限或 judge STOP_MANUAL)。
+ ManualRequired bool
+
+ // Completed 表示流程已完成(如 review PASS 或 issue-post POSTED)。
+ Completed bool
+
+ // Output 是工具的原始 stdout 输出。
+ Output string
+
+ // Data 是从结果文件加载的结构化数据。
+ Data map[string]any
+
+ // Status 是 Data["status"] 的快捷引用,统一状态判断。
+ Status string
+
+ // Skipped 表示因 when 条件不满足而跳过。
+ Skipped bool
+}
+
+// EngineResult 是 Runtime 引擎的最终执行结果。
+//
+// 与 pipeline.Result 结构对齐,由 pipeline 层做类型转换。
+type EngineResult struct {
+ // Success 表示整个流程顺利完成,没有系统级错误。
+ Success bool
+
+ // Completed 表示 Issue 已被判定通过且最终回复已提交。
+ Completed bool
+
+ // Blocked 表示流程因外部条件不足而暂停。
+ Blocked bool
+
+ // ManualRequired 表示自动循环触达兜底阈值,需要人工接管。
+ ManualRequired bool
+
+ // Output 是最终输出文本。
+ Output string
+}
+
+// ArtifactResolver 抽象文件契约的路径计算和结果加载。
+//
+// 由 pipeline 层实现并注入,runtime 包不直接依赖 pipeline.ArtifactSet。
+type ArtifactResolver interface {
+ // ResolveResultFile 返回指定 stage 的结果文件路径。
+ // loopRound 为 0 表示非 loop 内的 stage。
+ ResolveResultFile(stageName string, args map[string]any, loopRound int) string
+
+ // ResetResultFile 删除旧的结果文件,防止读到上一轮残留。
+ ResetResultFile(path string) error
+
+ // LoadResult 从结果文件加载结构化数据。
+ // stageName 用于路由到对应的 Result 结构(如 coding → CodingResult)。
+ LoadResult(stageName string, path string) (map[string]any, error)
+
+ // LoopHistoryPath 返回 loop 历史文件路径。
+ LoopHistoryPath() string
+}
diff --git a/internal/runtime/tool_registry.go b/internal/runtime/tool_registry.go
new file mode 100644
index 0000000..40b79fe
--- /dev/null
+++ b/internal/runtime/tool_registry.go
@@ -0,0 +1,115 @@
+// 本文件实现 ToolRegistry,管理 workflow 中定义的全部工具实例。
+//
+// 职责:
+// 1. 根据 *schema.Workflow.Tools 构建 Tool 实例(agent/command/function)
+// 2. 维护 name → Tool 和 alias → name 的映射
+// 3. 对外提供 Resolve 方法,按 name 或 alias 查找 Tool
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+
+package runtime
+
+import (
+ "fmt"
+
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// ToolRegistry 管理全部 Tool 实例,支持 name 和 alias 查找。
+type ToolRegistry struct {
+ tools map[string]Tool
+ aliases map[string]string // alias → tool name
+}
+
+// NewToolRegistry 根据 workflow 配置构建所有 Tool 实例。
+//
+// 构建逻辑:
+// - agent: 用 runner + promptBuilder 创建 AgentTool
+// - command: 创建 CommandTool
+// - function: 创建 FunctionTool(暂不支持,返回错误)
+func NewToolRegistry(wf *schema.Workflow, runner Runner, pb *PromptBuilder) (*ToolRegistry, error) {
+ if wf == nil {
+ return nil, fmt.Errorf("ToolRegistry: workflow is nil")
+ }
+
+ registry := &ToolRegistry{
+ tools: make(map[string]Tool),
+ aliases: make(map[string]string),
+ }
+
+ for i := range wf.Tools {
+ def := &wf.Tools[i]
+
+ if _, exists := registry.tools[def.Name]; exists {
+ return nil, fmt.Errorf("ToolRegistry: duplicate tool name %q", def.Name)
+ }
+
+ var tool Tool
+ var err error
+ switch def.Type {
+ case "agent":
+ tool, err = NewAgentTool(def, runner, pb)
+ case "command":
+ tool, err = NewCommandTool(def)
+ case "function":
+ tool, err = NewFunctionTool(def)
+ default:
+ err = fmt.Errorf("unknown tool type %q for tool %s", def.Type, def.Name)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("ToolRegistry: build tool %s: %w", def.Name, err)
+ }
+
+ registry.tools[def.Name] = tool
+
+ // 注册别名
+ for _, alias := range def.Aliases {
+ if alias == def.Name {
+ continue // 别名与 name 重复时跳过
+ }
+ if existing, ok := registry.aliases[alias]; ok {
+ return nil, fmt.Errorf("ToolRegistry: alias %q conflict between %s and %s", alias, existing, def.Name)
+ }
+ registry.aliases[alias] = def.Name
+ }
+ }
+
+ return registry, nil
+}
+
+// Resolve 按 name 或 alias 查找 Tool。
+func (r *ToolRegistry) Resolve(ref string) (Tool, bool) {
+ // 先按 name 查
+ if tool, ok := r.tools[ref]; ok {
+ return tool, true
+ }
+
+ // 再按 alias 查
+ if name, ok := r.aliases[ref]; ok {
+ tool, ok := r.tools[name]
+ return tool, ok
+ }
+
+ return nil, false
+}
+
+// MustResolve 按 name 或 alias 查找 Tool,找不到时 panic。
+//
+// 仅用于内部调用,外部调用应使用 Resolve。
+func (r *ToolRegistry) MustResolve(ref string) Tool {
+ tool, ok := r.Resolve(ref)
+ if !ok {
+ panic(fmt.Sprintf("ToolRegistry: tool %q not found", ref))
+ }
+ return tool
+}
+
+// Names 返回所有已注册的工具 name 列表。
+func (r *ToolRegistry) Names() []string {
+ names := make([]string, 0, len(r.tools))
+ for name := range r.tools {
+ names = append(names, name)
+ }
+ return names
+}
diff --git a/internal/runtime/tool_test.go b/internal/runtime/tool_test.go
new file mode 100644
index 0000000..5edf924
--- /dev/null
+++ b/internal/runtime/tool_test.go
@@ -0,0 +1,474 @@
+package runtime
+
+import (
+ "context"
+ "errors"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/JiGuangWorker/code-bee/internal/agent"
+ "github.com/JiGuangWorker/code-bee/internal/config"
+ "github.com/JiGuangWorker/code-bee/internal/schema"
+)
+
+// scriptedRunner 是测试用的 Runner mock。
+type scriptedRunner struct {
+ output string
+ err error
+ calls []string // 记录每次调用的 task
+}
+
+func (r *scriptedRunner) Run(ctx context.Context, kind agent.TaskKind, task string) (*agent.RunResult, error) {
+ r.calls = append(r.calls, task)
+ if r.err != nil {
+ return &agent.RunResult{Success: false, Output: r.output}, r.err
+ }
+ return &agent.RunResult{Success: true, Output: r.output}, nil
+}
+
+// fakeArtifacts 是测试用的 ArtifactResolver mock。
+type fakeArtifacts struct {
+ results map[string]map[string]any // stageName → data
+ loaded []string // 记录被加载的 stageName
+}
+
+func (f *fakeArtifacts) ResolveResultFile(stageName string, args map[string]any, loopRound int) string {
+ return "/tmp/fake_" + stageName + ".json"
+}
+
+func (f *fakeArtifacts) ResetResultFile(path string) error { return nil }
+
+func (f *fakeArtifacts) LoadResult(stageName string, path string) (map[string]any, error) {
+ f.loaded = append(f.loaded, stageName)
+ if data, ok := f.results[stageName]; ok {
+ return data, nil
+ }
+ return nil, errors.New("no result for " + stageName)
+}
+
+func (f *fakeArtifacts) LoopHistoryPath() string { return "/tmp/loop_history.json" }
+
+func newTestAgentTool(t *testing.T, template string) (*AgentTool, *scriptedRunner, *PromptBuilder) {
+ t.Helper()
+ pb, err := NewPromptBuilder("")
+ if err != nil {
+ t.Fatalf("NewPromptBuilder: %v", err)
+ }
+ runner := &scriptedRunner{output: "agent output"}
+ def := &schema.Tool{
+ Name: "test-agent",
+ Type: "agent",
+ PromptTemplate: template,
+ }
+ tool, err := NewAgentTool(def, runner, pb)
+ if err != nil {
+ t.Fatalf("NewAgentTool: %v", err)
+ }
+ return tool, runner, pb
+}
+
+// testWorkflow 返回包含 3 个 agent 工具的最小 workflow,供 prompt 派生测试用。
+// display_name 与默认 workflow 一致,确保角色名派生行为向后兼容。
+func testWorkflow() *schema.Workflow {
+ return &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "coder", Type: "agent", PromptTemplate: promptCoding, DisplayName: "开发者"},
+ {Name: "reviewer", Type: "agent", PromptTemplate: promptReview, DisplayName: "QA负责人"},
+ {Name: "issue-post", Type: "agent", PromptTemplate: promptIssuePost, DisplayName: "产品经理"},
+ },
+ }
+}
+
+func TestAgentTool_Execute_IssueHandling(t *testing.T) {
+ tool, runner, _ := newTestAgentTool(t, promptIssueHandling)
+
+ ec := NewExecutionContext(testWorkflow(), &fakeArtifacts{}, PlatformContext{
+ WorkerID: "w1",
+ IssueURL: "https://github.com/o/r/issues/1",
+ PlatformName: "github",
+ PlatformGuide: "guide",
+ }, &config.Config{Repo: "o/r", IssueNumber: 1})
+
+ inv := Invocation{
+ StageName: "issue-handling",
+ Tool: tool.Definition(),
+ ResultFilePath: "/tmp/issue_intake_result.json",
+ Platform: ec.Platform,
+ Config: ec.Config,
+ EC: ec,
+ }
+
+ // fakeArtifacts 返回 READY 状态
+ ec.Artifacts.(*fakeArtifacts).results = map[string]map[string]any{
+ "issue-handling": {"status": "READY", "agent": "开发者"},
+ }
+
+ result, err := tool.Execute(context.Background(), inv)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success")
+ }
+ if result.Status != "READY" {
+ t.Errorf("got status %q, want READY", result.Status)
+ }
+ if len(runner.calls) != 1 {
+ t.Errorf("expected 1 runner call, got %d", len(runner.calls))
+ }
+ // 验证 prompt 包含关键字段
+ if !strings.Contains(runner.calls[0], "开发者") {
+ t.Error("prompt should contain default agent")
+ }
+}
+
+func TestAgentTool_Execute_Coding(t *testing.T) {
+ tool, runner, _ := newTestAgentTool(t, promptCoding)
+
+ fa := &fakeArtifacts{results: map[string]map[string]any{
+ "coding": {"status": "DONE", "summary": "implemented"},
+ }}
+ ec := NewExecutionContext(nil, fa, PlatformContext{WorkerID: "w1"}, &config.Config{
+ Repo: "o/r", IssueNumber: 1,
+ })
+ ec.SetResult("issue-handling", &StepResult{
+ Data: map[string]any{
+ "agent": "开发者",
+ "summary": "fix bug",
+ "acceptance": "tests pass",
+ },
+ })
+
+ inv := Invocation{
+ StageName: "coding",
+ Tool: tool.Definition(),
+ ResultFilePath: "/tmp/coding_result.json",
+ Platform: ec.Platform,
+ Config: ec.Config,
+ EC: ec,
+ Loop: &LoopState{Round: 1, MaxIterations: 3, LastFeedback: "add tests"},
+ }
+
+ result, err := tool.Execute(context.Background(), inv)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if result.Status != "DONE" {
+ t.Errorf("got status %q, want DONE", result.Status)
+ }
+ // 验证 prompt 包含 issue 摘要和反馈
+ prompt := runner.calls[0]
+ if !strings.Contains(prompt, "fix bug") {
+ t.Error("prompt should contain issue summary")
+ }
+ if !strings.Contains(prompt, "add tests") {
+ t.Error("prompt should contain reviewer feedback")
+ }
+ if !strings.Contains(prompt, "Round: 1/3") {
+ t.Error("prompt should contain round info")
+ }
+}
+
+func TestAgentTool_Execute_Blocked(t *testing.T) {
+ tool, _, _ := newTestAgentTool(t, promptCoding)
+
+ fa := &fakeArtifacts{results: map[string]map[string]any{
+ "coding": {"status": "BLOCKED"},
+ }}
+ ec := NewExecutionContext(nil, fa, PlatformContext{}, &config.Config{})
+
+ inv := Invocation{
+ StageName: "coding",
+ Tool: tool.Definition(),
+ ResultFilePath: "/tmp/coding_result.json",
+ EC: ec,
+ }
+
+ result, err := tool.Execute(context.Background(), inv)
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Blocked {
+ t.Error("expected Blocked=true for BLOCKED status")
+ }
+}
+
+func TestAgentTool_Execute_RunnerError(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ runner := &scriptedRunner{err: errors.New("runner failed")}
+ def := &schema.Tool{Name: "bad", Type: "agent", PromptTemplate: promptCoding}
+ tool, _ := NewAgentTool(def, runner, pb)
+
+ ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{}, &config.Config{})
+ inv := Invocation{
+ StageName: "coding",
+ Tool: def,
+ EC: ec,
+ }
+
+ _, err := tool.Execute(context.Background(), inv)
+ if err == nil {
+ t.Fatal("expected error from runner")
+ }
+ if !strings.Contains(err.Error(), "runner failed") {
+ t.Errorf("error should contain 'runner failed', got: %v", err)
+ }
+}
+
+func TestNewAgentTool_Validation(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ runner := &scriptedRunner{}
+
+ cases := []struct {
+ name string
+ def *schema.Tool
+ }{
+ {"wrong type", &schema.Tool{Name: "t", Type: "command", PromptTemplate: "coding"}},
+ {"missing template", &schema.Tool{Name: "t", Type: "agent"}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, err := NewAgentTool(c.def, runner, pb)
+ if err == nil {
+ t.Error("expected error")
+ }
+ })
+ }
+}
+
+func TestCommandTool_Execute_Success(t *testing.T) {
+ // 用 fake exec
+ oldExec := execCommandContext
+ defer func() { execCommandContext = oldExec }()
+
+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
+ // 模拟输出 JSON
+ cmd := exec.Command("echo", `{"status":"PASS","summary":"lint clean"}`)
+ return cmd
+ }
+
+ def := &schema.Tool{Name: "lint", Type: "command", Run: "golangci-lint run"}
+ tool, err := NewCommandTool(def)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := tool.Execute(context.Background(), Invocation{StageName: "lint"})
+ if err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ if !result.Success {
+ t.Error("expected success")
+ }
+ if result.Status != "PASS" {
+ t.Errorf("got status %q, want PASS", result.Status)
+ }
+}
+
+func TestCommandTool_Execute_Failure(t *testing.T) {
+ oldExec := execCommandContext
+ defer func() { execCommandContext = oldExec }()
+
+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
+ // 模拟命令失败
+ return exec.Command("false")
+ }
+
+ def := &schema.Tool{Name: "fail", Type: "command", Run: "exit 1"}
+ tool, _ := NewCommandTool(def)
+
+ _, err := tool.Execute(context.Background(), Invocation{StageName: "fail"})
+ if err == nil {
+ t.Fatal("expected error for failed command")
+ }
+}
+
+func TestNewCommandTool_Validation(t *testing.T) {
+ _, err := NewCommandTool(&schema.Tool{Name: "t", Type: "agent"})
+ if err == nil {
+ t.Error("expected error for wrong type")
+ }
+
+ _, err = NewCommandTool(&schema.Tool{Name: "t", Type: "command"})
+ if err == nil {
+ t.Error("expected error for missing run")
+ }
+}
+
+func TestToolRegistry_BuildAndResolve(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ runner := &scriptedRunner{}
+
+ wf := &schema.Workflow{
+ Version: "1",
+ Name: "test",
+ Tools: []schema.Tool{
+ {Name: "coder", Type: "agent", PromptTemplate: "coding", Skill: "dev/SKILL.md"},
+ {Name: "reviewer", Type: "agent", PromptTemplate: "review", Skill: "qa/SKILL.md", Aliases: []string{"QA负责人", "审查员"}},
+ {Name: "lint", Type: "command", Run: "golangci-lint run"},
+ },
+ }
+
+ registry, err := NewToolRegistry(wf, runner, pb)
+ if err != nil {
+ t.Fatalf("NewToolRegistry: %v", err)
+ }
+
+ // 按 name 查
+ tool, ok := registry.Resolve("coder")
+ if !ok {
+ t.Fatal("expected to resolve coder")
+ }
+ if tool.Definition().Name != "coder" {
+ t.Errorf("got name %s, want coder", tool.Definition().Name)
+ }
+
+ // 按 alias 查
+ tool, ok = registry.Resolve("QA负责人")
+ if !ok {
+ t.Fatal("expected to resolve QA负责人 alias")
+ }
+ if tool.Definition().Name != "reviewer" {
+ t.Errorf("got name %s, want reviewer", tool.Definition().Name)
+ }
+
+ // 查 command
+ _, ok = registry.Resolve("lint")
+ if !ok {
+ t.Fatal("expected to resolve lint")
+ }
+
+ // 未知工具
+ _, ok = registry.Resolve("unknown")
+ if ok {
+ t.Error("expected not found for unknown")
+ }
+}
+
+func TestToolRegistry_DuplicateName(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ wf := &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "dup", Type: "command", Run: "echo 1"},
+ {Name: "dup", Type: "command", Run: "echo 2"},
+ },
+ }
+ _, err := NewToolRegistry(wf, nil, pb)
+ if err == nil {
+ t.Fatal("expected error for duplicate name")
+ }
+}
+
+func TestToolRegistry_AliasConflict(t *testing.T) {
+ pb, _ := NewPromptBuilder("")
+ wf := &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "a", Type: "command", Run: "echo 1", Aliases: []string{"shared"}},
+ {Name: "b", Type: "command", Run: "echo 2", Aliases: []string{"shared"}},
+ },
+ }
+ _, err := NewToolRegistry(wf, nil, pb)
+ if err == nil {
+ t.Fatal("expected error for alias conflict")
+ }
+}
+
+func TestFunctionTool_NotImplemented(t *testing.T) {
+ def := &schema.Tool{Name: "fn", Type: "function", Function: "do-something"}
+ tool, err := NewFunctionTool(def)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = tool.Execute(context.Background(), Invocation{})
+ if err == nil {
+ t.Fatal("expected error for unimplemented function")
+ }
+}
+
+func TestExtractStatus(t *testing.T) {
+ cases := []struct {
+ data map[string]any
+ want string
+ }{
+ {map[string]any{"status": "PASS"}, "PASS"},
+ {map[string]any{"status": 123}, ""},
+ {map[string]any{}, ""},
+ {nil, ""},
+ }
+ for _, c := range cases {
+ got := extractStatus(c.data)
+ if got != c.want {
+ t.Errorf("extractStatus(%v) = %q, want %q", c.data, got, c.want)
+ }
+ }
+}
+
+// TestLookupDisplayNameByPromptTemplate 验证从 workflow.Tools 按 prompt_template 派生角色展示名的逻辑。
+func TestLookupDisplayNameByPromptTemplate(t *testing.T) {
+ cases := []struct {
+ name string
+ wf *schema.Workflow
+ tmpl string
+ want string
+ }{
+ {
+ name: "found_with_display_name",
+ wf: &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "coder", Type: "agent", PromptTemplate: "coding", DisplayName: "开发者"},
+ },
+ },
+ tmpl: "coding",
+ want: "开发者",
+ },
+ {
+ name: "found_fallback_to_alias",
+ wf: &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "coder", Type: "agent", PromptTemplate: "coding", Aliases: []string{"前端开发"}},
+ },
+ },
+ tmpl: "coding",
+ want: "前端开发",
+ },
+ {
+ name: "found_fallback_to_name",
+ wf: &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "my-coder", Type: "agent", PromptTemplate: "coding"},
+ },
+ },
+ tmpl: "coding",
+ want: "my-coder",
+ },
+ {
+ name: "not_found",
+ wf: &schema.Workflow{
+ Tools: []schema.Tool{
+ {Name: "coder", Type: "agent", PromptTemplate: "coding"},
+ },
+ },
+ tmpl: "review",
+ want: "",
+ },
+ {
+ name: "nil_workflow",
+ wf: nil,
+ tmpl: "coding",
+ want: "",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := lookupDisplayNameByPromptTemplate(tc.wf, tc.tmpl)
+ if got != tc.want {
+ t.Fatalf("lookupDisplayNameByPromptTemplate() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+// 确保 fakeArtifacts 满足 ArtifactResolver 接口
+var _ ArtifactResolver = (*fakeArtifacts)(nil)
diff --git a/internal/schema/errors.go b/internal/schema/errors.go
new file mode 100644
index 0000000..710e8f5
--- /dev/null
+++ b/internal/schema/errors.go
@@ -0,0 +1,25 @@
+// Package schema 的错误定义。
+package schema
+
+import "errors"
+
+// errConditionKind 表示 Condition 的 YAML 形态既不是字符串也不是对象。
+var errConditionKind = errors.New("condition must be a string or a mapping")
+
+// 语义校验错误。用错误类型而非固定字符串,便于调用方判断。
+var (
+ // errToolNameConflict 表示 tools 中 name 或 aliases 出现冲突。
+ errToolNameConflict = errors.New("tool name or alias conflict")
+
+ // errUnknownToolRef 表示 stage 或 judge 引用了未声明的 tool。
+ errUnknownToolRef = errors.New("unknown tool reference")
+
+ // errStepMutualExclusive 表示 PipelineStep 的 stage/parallel/loop 不满足互斥。
+ errStepMutualExclusive = errors.New("pipeline step must have exactly one of stage/parallel/loop")
+
+ // errJoinWithoutParallel 表示 join 字段在没有 parallel 时出现。
+ errJoinWithoutParallel = errors.New("join only valid with parallel")
+
+ // errUnknownStageRef 表示 input_from 或 exit_when 引用了不存在的 stage。
+ errUnknownStageRef = errors.New("unknown stage reference")
+)
diff --git a/internal/schema/loader.go b/internal/schema/loader.go
new file mode 100644
index 0000000..8a24be8
--- /dev/null
+++ b/internal/schema/loader.go
@@ -0,0 +1,291 @@
+// Package schema 的配置加载器。
+//
+// 核心职责:
+// 1. 从 YAML 字节流或文件加载 Workflow 配置
+// 2. 先做 JSON Schema 校验(字段级),再做语义校验(跨字段引用)
+// 3. 提供工具查找接口(按 name 或 alias),供 runtime 调度器使用
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+// 更新时间: 2026-07-05
+package schema
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Loader 负责加载并校验 workflow 配置。
+type Loader struct {
+ validator *Validator
+}
+
+// NewLoader 创建一个加载器实例。
+func NewLoader() (*Loader, error) {
+ v, err := NewValidator()
+ if err != nil {
+ return nil, fmt.Errorf("schema.NewLoader: %w", err)
+ }
+ return &Loader{validator: v}, nil
+}
+
+// LoadFromBytes 从 YAML 字节流加载并校验一份 workflow 配置。
+//
+// 校验顺序:
+// 1. JSON Schema 校验(字段类型、枚举、必填、条件必填)
+// 2. YAML 反序列化到强类型结构体
+// 3. 语义校验(工具引用完整性、别名唯一性、stage 引用合法性)
+func (l *Loader) LoadFromBytes(data []byte) (*Workflow, error) {
+ if err := l.validator.ValidateWorkflow(data); err != nil {
+ return nil, fmt.Errorf("schema.LoadFromBytes: validate: %w", err)
+ }
+
+ var wf Workflow
+ if err := yaml.Unmarshal(data, &wf); err != nil {
+ return nil, fmt.Errorf("schema.LoadFromBytes: unmarshal: %w", err)
+ }
+
+ if err := wf.Validate(); err != nil {
+ return nil, fmt.Errorf("schema.LoadFromBytes: semantic: %w", err)
+ }
+
+ return &wf, nil
+}
+
+// LoadFromFile 从 YAML 文件加载并校验一份 workflow 配置。
+func (l *Loader) LoadFromFile(path string) (*Workflow, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("schema.LoadFromFile: read %s: %w", path, err)
+ }
+ return l.LoadFromBytes(data)
+}
+
+// Validate 做跨字段语义校验。
+//
+// 校验项:
+// 1. tools 中 name 唯一
+// 2. aliases 不与任何 name 或其他 alias 冲突
+// 3. pipeline 中所有 stage.tool / judge.tool 引用都能在 tools 中找到
+// 4. PipelineStep 的 stage/parallel/loop 互斥
+// 5. join 仅在 parallel 模式下有效
+// 6. input_from / exit_when 引用的 stage 在可见范围内存在
+func (w *Workflow) Validate() error {
+ registry, err := buildToolRegistry(w.Tools)
+ if err != nil {
+ return fmt.Errorf("build tool registry: %w", err)
+ }
+
+ if err := validatePipeline(w.Pipeline, registry, make(map[string]bool)); err != nil {
+ return fmt.Errorf("validate pipeline: %w", err)
+ }
+
+ return nil
+}
+
+// FindTool 按 name 或 alias 查找工具,找不到返回 nil。
+//
+// runtime 调度器通过此方法解析 stage.tool 和 judge.tool 引用。
+func (w *Workflow) FindTool(ref string) *Tool {
+ for i := range w.Tools {
+ t := &w.Tools[i]
+ if t.Name == ref {
+ return t
+ }
+ for _, a := range t.Aliases {
+ if a == ref {
+ return t
+ }
+ }
+ }
+ return nil
+}
+
+// toolRegistry 是 name→Tool 的索引,O(1) 查找。
+type toolRegistry struct {
+ byName map[string]*Tool
+ byAlias map[string]*Tool
+ allNames map[string]bool
+}
+
+// buildToolRegistry 构建 tool 索引并检测 name/alias 冲突。
+func buildToolRegistry(tools []Tool) (*toolRegistry, error) {
+ r := &toolRegistry{
+ byName: make(map[string]*Tool),
+ byAlias: make(map[string]*Tool),
+ allNames: make(map[string]bool),
+ }
+
+ for i := range tools {
+ t := &tools[i]
+
+ if _, exists := r.byName[t.Name]; exists {
+ return nil, fmt.Errorf("%w: duplicate tool name %q", errToolNameConflict, t.Name)
+ }
+ r.byName[t.Name] = t
+ r.allNames[t.Name] = true
+
+ for _, alias := range t.Aliases {
+ // alias 与已有 tool name 冲突
+ if _, exists := r.byName[alias]; exists {
+ return nil, fmt.Errorf("%w: alias %q conflicts with a tool name", errToolNameConflict, alias)
+ }
+ // alias 与已有 alias 冲突
+ if _, exists := r.byAlias[alias]; exists {
+ return nil, fmt.Errorf("%w: duplicate alias %q", errToolNameConflict, alias)
+ }
+ r.byAlias[alias] = t
+ r.allNames[alias] = true
+ }
+ }
+
+ return r, nil
+}
+
+// resolve 解析一个 tool 引用,name 或 alias 均可。
+func (r *toolRegistry) resolve(ref string) (*Tool, error) {
+ if t, ok := r.byName[ref]; ok {
+ return t, nil
+ }
+ if t, ok := r.byAlias[ref]; ok {
+ return t, nil
+ }
+ return nil, fmt.Errorf("%w: %q", errUnknownToolRef, ref)
+}
+
+// validatePipeline 递归校验 pipeline 步骤序列。
+//
+// 输入参数:
+// - steps: 待校验的步骤列表
+// - registry: tool 索引
+// - scopeStages: 当前可见的 stage name 集合,会被本函数修改(加入本层 stage)
+func validatePipeline(steps []PipelineStep, registry *toolRegistry, scopeStages map[string]bool) error {
+ for i := range steps {
+ if err := validateStep(&steps[i], registry, scopeStages); err != nil {
+ return fmt.Errorf("step[%d]: %w", i, err)
+ }
+ }
+ return nil
+}
+
+// validateStep 校验单个 PipelineStep。
+func validateStep(step *PipelineStep, registry *toolRegistry, scopeStages map[string]bool) error {
+ if err := checkStepMutualExclusive(step); err != nil {
+ return err
+ }
+
+ switch {
+ case step.Stage != nil:
+ return validateStage(step.Stage, registry, scopeStages)
+ case step.Parallel != nil:
+ return validateParallel(step, registry, scopeStages)
+ case step.Loop != nil:
+ return validateLoop(step.Loop, registry, scopeStages)
+ }
+ return nil
+}
+
+// checkStepMutualExclusive 校验 stage/parallel/loop 互斥,以及 join 仅在 parallel 时有效。
+func checkStepMutualExclusive(step *PipelineStep) error {
+ n := 0
+ if step.Stage != nil {
+ n++
+ }
+ if step.Parallel != nil {
+ n++
+ }
+ if step.Loop != nil {
+ n++
+ }
+ if n != 1 {
+ return fmt.Errorf("%w: got %d", errStepMutualExclusive, n)
+ }
+ if step.Join != "" && step.Parallel == nil {
+ return errJoinWithoutParallel
+ }
+ return nil
+}
+
+// validateStage 校验单个 stage。
+func validateStage(st *Stage, registry *toolRegistry, scopeStages map[string]bool) error {
+ if _, err := registry.resolve(st.Tool); err != nil {
+ return fmt.Errorf("stage %q: %w", st.Name, err)
+ }
+
+ if st.InputFrom != "" && !scopeStages[st.InputFrom] {
+ return fmt.Errorf("%w: stage %q input_from %q not in scope", errUnknownStageRef, st.Name, st.InputFrom)
+ }
+
+ // 把本 stage 加入可见集合,供后续步骤引用
+ scopeStages[st.Name] = true
+ return nil
+}
+
+// validateParallel 校验并行块。
+//
+// 语义: parallel 内的 stage 互相不可见(并发执行),但都对外层后续可见。
+func validateParallel(step *PipelineStep, registry *toolRegistry, scopeStages map[string]bool) error {
+ // parallel 子步骤共享一个 innerScope,互相不可见
+ innerScope := make(map[string]bool)
+ for k, v := range scopeStages {
+ innerScope[k] = v
+ }
+
+ for i := range step.Parallel {
+ // 每个并行分支独立 copy,避免互相可见
+ branchScope := make(map[string]bool)
+ for k, v := range innerScope {
+ branchScope[k] = v
+ }
+ if err := validateStep(&step.Parallel[i], registry, branchScope); err != nil {
+ return fmt.Errorf("parallel[%d]: %w", i, err)
+ }
+ // 分支内的 stage 对外层可见
+ for k, v := range branchScope {
+ if v {
+ scopeStages[k] = true
+ }
+ }
+ }
+ return nil
+}
+
+// validateLoop 校验循环块。
+//
+// 语义: loop body 内的 stage 互相可见,且对 loop 后续步骤可见。
+func validateLoop(lp *Loop, registry *toolRegistry, scopeStages map[string]bool) error {
+ // loop body 共享一个 innerScope,body 内 stage 互相可见
+ innerScope := make(map[string]bool)
+ for k, v := range scopeStages {
+ innerScope[k] = v
+ }
+
+ if err := validatePipeline(lp.Body, registry, innerScope); err != nil {
+ return fmt.Errorf("loop %q body: %w", lp.ID, err)
+ }
+
+ // 校验 exit_when 引用的 stage 在 body 内存在
+ for i, ec := range lp.ExitWhen {
+ if !innerScope[ec.Stage] {
+ return fmt.Errorf("%w: loop %q exit_when[%d] stage %q not in body",
+ errUnknownStageRef, lp.ID, i, ec.Stage)
+ }
+ }
+
+ // 校验 judge.tool 引用
+ if lp.Judge != nil {
+ if _, err := registry.resolve(lp.Judge.Tool); err != nil {
+ return fmt.Errorf("loop %q judge: %w", lp.ID, err)
+ }
+ }
+
+ // loop body 内的 stage 对外层后续可见
+ for k, v := range innerScope {
+ if v {
+ scopeStages[k] = true
+ }
+ }
+ return nil
+}
diff --git a/internal/schema/loader_test.go b/internal/schema/loader_test.go
new file mode 100644
index 0000000..5121816
--- /dev/null
+++ b/internal/schema/loader_test.go
@@ -0,0 +1,451 @@
+package schema
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+// TestLoader_LoadFromBytes_Valid 全部合法配置应成功加载并返回结构体。
+func TestLoader_LoadFromBytes_Valid(t *testing.T) {
+ l := newLoader(t)
+
+ cases := loadCases(t, "testdata/valid")
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Arrange
+ data, err := os.ReadFile(tc.path)
+ if err != nil {
+ t.Fatalf("read testdata: %v", err)
+ }
+
+ // Act
+ wf, err := l.LoadFromBytes(data)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("expected valid, got error: %v", err)
+ }
+ if wf == nil {
+ t.Fatal("workflow is nil")
+ }
+ if wf.Version != "1" {
+ t.Errorf("version = %q, want \"1\"", wf.Version)
+ }
+ if len(wf.Tools) == 0 {
+ t.Error("tools is empty")
+ }
+ if len(wf.Pipeline) == 0 {
+ t.Error("pipeline is empty")
+ }
+ })
+ }
+}
+
+// TestLoader_LoadFromBytes_ToolAlias 验证工具别名查找:
+// FindTool 应能通过 name 和任一 alias 找到同一个 Tool。
+func TestLoader_LoadFromBytes_ToolAlias(t *testing.T) {
+ l := newLoader(t)
+
+ // Arrange
+ data := []byte(`
+version: "1"
+name: alias-lookup
+tools:
+ - name: reviewer
+ display_name: 代码审核员
+ aliases:
+ - QA负责人
+ - 审查员
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+ - stage:
+ name: review
+ tool: 审查员
+`)
+
+ // Act
+ wf, err := l.LoadFromBytes(data)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("load failed: %v", err)
+ }
+
+ // 通过 name 查找
+ r := wf.FindTool("reviewer")
+ if r == nil {
+ t.Fatal("FindTool by name failed")
+ }
+ if r.DisplayName != "代码审核员" {
+ t.Errorf("display_name = %q, want 代码审核员", r.DisplayName)
+ }
+
+ // 通过 alias 查找(中文)
+ r2 := wf.FindTool("QA负责人")
+ if r2 == nil || r2.Name != "reviewer" {
+ t.Errorf("FindTool by alias QA负责人 failed")
+ }
+
+ // 通过另一个 alias 查找
+ r3 := wf.FindTool("审查员")
+ if r3 == nil || r3.Name != "reviewer" {
+ t.Errorf("FindTool by alias 审查员 failed")
+ }
+
+ // 不存在的引用
+ r4 := wf.FindTool("不存在")
+ if r4 != nil {
+ t.Error("FindTool should return nil for unknown ref")
+ }
+
+ // pipeline 中 stage.tool 用 alias 也应通过校验
+ if len(wf.Pipeline) != 2 {
+ t.Fatalf("pipeline length = %d, want 2", len(wf.Pipeline))
+ }
+ if wf.Pipeline[1].Stage.Tool != "审查员" {
+ t.Errorf("pipeline[1].stage.tool = %q, want 审查员", wf.Pipeline[1].Stage.Tool)
+ }
+}
+
+// TestLoader_SemanticErrors 验证语义校验能识别 schema 校验不到的问题。
+func TestLoader_SemanticErrors(t *testing.T) {
+ l := newLoader(t)
+
+ cases := []struct {
+ name string
+ data string
+ wantErrIs error
+ wantSubstr string
+ }{
+ {
+ name: "duplicate_tool_name",
+ data: `
+version: "1"
+name: dup-name
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+ - name: coder
+ type: agent
+ skill: "s2.md"
+ prompt_template: c
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+`,
+ wantErrIs: errToolNameConflict,
+ wantSubstr: "duplicate tool name",
+ },
+ {
+ name: "alias_conflicts_with_name",
+ data: `
+version: "1"
+name: alias-conflict
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+ - name: reviewer
+ type: agent
+ skill: "r.md"
+ prompt_template: r
+ aliases:
+ - coder
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+`,
+ wantErrIs: errToolNameConflict,
+ wantSubstr: "conflicts with a tool name",
+ },
+ {
+ name: "duplicate_alias",
+ data: `
+version: "1"
+name: dup-alias
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+ aliases:
+ - 开发者
+ - name: reviewer
+ type: agent
+ skill: "r.md"
+ prompt_template: r
+ aliases:
+ - 开发者
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+`,
+ wantErrIs: errToolNameConflict,
+ wantSubstr: "duplicate alias",
+ },
+ {
+ name: "unknown_tool_ref",
+ data: `
+version: "1"
+name: unknown-tool
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+pipeline:
+ - stage:
+ name: coding
+ tool: ghost
+`,
+ wantErrIs: errUnknownToolRef,
+ wantSubstr: "ghost",
+ },
+ {
+ name: "input_from_unknown_stage",
+ data: `
+version: "1"
+name: bad-input-from
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+ - name: reviewer
+ type: agent
+ skill: "r.md"
+ prompt_template: r
+pipeline:
+ - stage:
+ name: review
+ tool: reviewer
+ input_from: ghost
+`,
+ wantErrIs: errUnknownStageRef,
+ wantSubstr: "input_from",
+ },
+ {
+ name: "loop_exit_when_unknown_stage",
+ data: `
+version: "1"
+name: bad-exit-when
+tools:
+ - name: coder
+ type: agent
+ skill: "s.md"
+ prompt_template: c
+pipeline:
+ - loop:
+ id: dev-loop
+ max_iterations: 3
+ exit_when:
+ - stage: ghost
+ field: status
+ operator: equals
+ value: PASS
+ body:
+ - stage:
+ name: coding
+ tool: coder
+`,
+ wantErrIs: errUnknownStageRef,
+ wantSubstr: "ghost",
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Act
+ _, err := l.LoadFromBytes([]byte(tc.data))
+
+ // Assert
+ if err == nil {
+ t.Fatalf("expected semantic error, got nil")
+ }
+ if !errors.Is(err, tc.wantErrIs) {
+ t.Errorf("error type = %v, want %v", err, tc.wantErrIs)
+ }
+ if tc.wantSubstr != "" && !strings.Contains(err.Error(), tc.wantSubstr) {
+ t.Errorf("error message %q does not contain %q", err.Error(), tc.wantSubstr)
+ }
+ })
+ }
+}
+
+// TestLoader_LoadFromFile 验证从文件加载。
+func TestLoader_LoadFromFile(t *testing.T) {
+ l := newLoader(t)
+
+ // Arrange
+ path := filepath.Join("testdata", "valid", "minimal.yaml")
+
+ // Act
+ wf, err := l.LoadFromFile(path)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("LoadFromFile failed: %v", err)
+ }
+ if wf.Name != "minimal" {
+ t.Errorf("name = %q, want minimal", wf.Name)
+ }
+}
+
+// TestLoader_LoadFromFile_NotExist 验证文件不存在的错误。
+func TestLoader_LoadFromFile_NotExist(t *testing.T) {
+ l := newLoader(t)
+
+ // Act
+ _, err := l.LoadFromFile("testdata/nonexistent.yaml")
+
+ // Assert
+ if err == nil {
+ t.Fatal("expected error for nonexistent file, got nil")
+ }
+ if !strings.Contains(err.Error(), "nonexistent.yaml") {
+ t.Errorf("error should mention file path: %v", err)
+ }
+}
+
+// TestWorkflow_FindTool_ByAlias 覆盖 FindTool 的三种命中路径。
+func TestWorkflow_FindTool_ByAlias(t *testing.T) {
+ // Arrange
+ wf := &Workflow{
+ Tools: []Tool{
+ {Name: "coder", Aliases: []string{"开发者"}},
+ {Name: "reviewer", DisplayName: "代码审核员"},
+ },
+ }
+
+ cases := []struct {
+ name string
+ ref string
+ wantNil bool
+ wantKey string
+ }{
+ {name: "by_name", ref: "coder", wantKey: "coder"},
+ {name: "by_alias", ref: "开发者", wantKey: "coder"},
+ {name: "by_name_no_alias", ref: "reviewer", wantKey: "reviewer"},
+ {name: "unknown", ref: "ghost", wantNil: true},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Act
+ got := wf.FindTool(tc.ref)
+
+ // Assert
+ if tc.wantNil {
+ if got != nil {
+ t.Errorf("expected nil, got %+v", got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatalf("expected non-nil for ref %q", tc.ref)
+ }
+ if got.Name != tc.wantKey {
+ t.Errorf("name = %q, want %q", got.Name, tc.wantKey)
+ }
+ })
+ }
+}
+
+// newLoader 是测试用的便捷构造函数。
+func newLoader(t *testing.T) *Loader {
+ t.Helper()
+ l, err := NewLoader()
+ if err != nil {
+ t.Fatalf("NewLoader: %v", err)
+ }
+ return l
+}
+
+// TestCondition_UnmarshalYAML 验证 Condition 的三种 YAML 形态:字符串、对象、非法类型。
+func TestCondition_UnmarshalYAML(t *testing.T) {
+ cases := []struct {
+ name string
+ yaml string
+ wantExpr string
+ wantStage string
+ wantErr bool
+ }{
+ {
+ name: "string_form",
+ yaml: `"round >= 2"`,
+ wantExpr: "round >= 2",
+ },
+ {
+ name: "object_form",
+ yaml: `stage: review
+field: status
+operator: equals
+value: PASS`,
+ wantStage: "review",
+ },
+ {
+ name: "sequence_invalid",
+ yaml: `- foo
+- bar`,
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Arrange
+ var c Condition
+ data := []byte(tc.yaml)
+
+ // Act
+ err := yaml.Unmarshal(data, &c)
+
+ // Assert
+ if tc.wantErr {
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if tc.wantExpr != "" && c.Expr != tc.wantExpr {
+ t.Errorf("expr = %q, want %q", c.Expr, tc.wantExpr)
+ }
+ if tc.wantStage != "" {
+ if c.Structured == nil {
+ t.Fatal("structured is nil")
+ }
+ if c.Structured.Stage != tc.wantStage {
+ t.Errorf("stage = %q, want %q", c.Structured.Stage, tc.wantStage)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/schema/schemas/v1/README.md b/internal/schema/schemas/v1/README.md
new file mode 100644
index 0000000..f5431c0
--- /dev/null
+++ b/internal/schema/schemas/v1/README.md
@@ -0,0 +1,73 @@
+# code-bee Workflow Schema v1
+
+三层 JSON Schema(draft-07),描述可配置的蜂巢调度策略。
+
+> **文件格式说明**:schema 定义文件统一用 YAML 格式(`.yaml`),加载时转 JSON 喂给校验库。`$id` 和 `$ref` 中的 URL 保持 `.json` 后缀作为 URI 标识符(JSON Schema 标准约定)。
+
+## 层次结构
+
+```
+Layer 3: workflow.yaml (顶层)
+ │
+Layer 2: pipeline_step.yaml (编排原语:stage / parallel / loop 判别联合)
+ loop.yaml
+ loop_judge.yaml
+ │
+Layer 1: tool.yaml (基础类型)
+ stage.yaml
+ condition.yaml
+ exit_condition.yaml
+ result_contract.yaml
+```
+
+## 关键设计
+
+### 工具别名(@mention)
+
+每个 tool 同时支持:
+
+- `name`:内部标识,英文小写,pipeline 中引用此名
+- `display_name`:类人展示名(如「代码审核员」),用于评论和日志
+- `aliases`:@mention 别名数组,Issue 中 @ 任一别名都可触发本工具
+
+```yaml
+tools:
+ - name: "reviewer"
+ display_name: "代码审核员"
+ aliases: ["QA负责人", "审查员"]
+ type: "agent"
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: "review"
+```
+
+### 编排原语
+
+- **stage**:串行单步
+- **parallel**:并行执行(fork-join)
+- **loop**:循环(含 max_iterations / exit_when / judge)
+
+```yaml
+pipeline:
+ - stage: {name: "intake", tool: "issue-handler"}
+ - loop:
+ id: "dev-loop"
+ max_iterations: 3
+ exit_when:
+ - stage: "review"
+ field: "status"
+ operator: "equals"
+ value: "PASS"
+ body:
+ - stage: {name: "coding", tool: "coder"}
+ - stage: {name: "review", tool: "reviewer"}
+ - stage: {name: "post", tool: "issue-post", when: "review.status == \"PASS\""}
+```
+
+## 校验
+
+```go
+import "github.com/JiGuangWorker/code-bee/internal/schema"
+
+validator, err := schema.NewValidator()
+err = validator.ValidateWorkflow(yamlBytes)
+```
diff --git a/internal/schema/schemas/v1/condition.yaml b/internal/schema/schemas/v1/condition.yaml
new file mode 100644
index 0000000..a4152e8
--- /dev/null
+++ b/internal/schema/schemas/v1/condition.yaml
@@ -0,0 +1,9 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/condition.json
+title: Condition
+description: 条件表达式,用于 stage.when 和 loop.exit_when。支持两种形式:字符串表达式或结构化条件对象。
+oneOf:
+- type: string
+ minLength: 1
+ description: 表达式字符串,如 'round >= 2'、'review.status == "PASS"'。运行时求值。
+- $ref: https://codebee.dev/schemas/v1/exit_condition.json
diff --git a/internal/schema/schemas/v1/exit_condition.yaml b/internal/schema/schemas/v1/exit_condition.yaml
new file mode 100644
index 0000000..cacad55
--- /dev/null
+++ b/internal/schema/schemas/v1/exit_condition.yaml
@@ -0,0 +1,34 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/exit_condition.json
+title: ExitCondition
+description: 结构化退出条件,引用某 stage 的输出字段做判断。
+type: object
+required:
+- stage
+- field
+- operator
+- value
+additionalProperties: false
+properties:
+ stage:
+ type: string
+ minLength: 1
+ description: 引用 loop body 中的 stage name
+ field:
+ type: string
+ minLength: 1
+ description: JSON 路径,如 'status' 或 'check_result.passed'
+ operator:
+ type: string
+ enum:
+ - equals
+ - not_equals
+ - in
+ - not_in
+ - contains
+ - exists
+ - not_exists
+ - regex
+ description: 比较运算符
+ value:
+ description: 比较值,类型取决于 operator。equals/not_equals/in 支持任意类型;regex 为字符串;exists 忽略此字段。
diff --git a/internal/schema/schemas/v1/loop.yaml b/internal/schema/schemas/v1/loop.yaml
new file mode 100644
index 0000000..63ce5de
--- /dev/null
+++ b/internal/schema/schemas/v1/loop.yaml
@@ -0,0 +1,45 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/loop.json
+title: Loop
+description: 循环编排原语。body 内的步骤序列每轮顺序执行,直到命中 exit_when 或触达 max_iterations。
+type: object
+required:
+- id
+- body
+additionalProperties: false
+properties:
+ id:
+ type: string
+ pattern: ^[a-z][a-z0-9-]{1,31}$
+ description: loop 唯一标识,用于嵌套 loop 与日志归因
+ max_iterations:
+ type: integer
+ minimum: 1
+ maximum: 20
+ default: 3
+ description: 硬上限,防止死循环
+ max_consecutive_unknown:
+ type: integer
+ minimum: 1
+ maximum: 10
+ default: 2
+ description: 连续 UNKNOWN 状态的软上限,触达后强制要求补证据或停止
+ timeout:
+ type: string
+ pattern: ^[0-9]+(s|m|h)$
+ description: 整体超时时间
+ exit_when:
+ type: array
+ items:
+ $ref: https://codebee.dev/schemas/v1/exit_condition.json
+ minItems: 1
+ description: 退出条件列表,任一满足即退出 loop
+ body:
+ type: array
+ items:
+ $ref: https://codebee.dev/schemas/v1/pipeline_step.json
+ minItems: 1
+ description: 每轮执行的步骤序列,可包含 stage/parallel/嵌套 loop
+ judge:
+ $ref: https://codebee.dev/schemas/v1/loop_judge.json
+ description: 可选的价值评估员
diff --git a/internal/schema/schemas/v1/loop_judge.yaml b/internal/schema/schemas/v1/loop_judge.yaml
new file mode 100644
index 0000000..036ddbc
--- /dev/null
+++ b/internal/schema/schemas/v1/loop_judge.yaml
@@ -0,0 +1,39 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/loop_judge.json
+title: LoopJudge
+description: 价值评估员配置。judge 本身也是一个工具调用,但其 decision 字段决定 loop 是否继续。
+type: object
+required:
+- tool
+additionalProperties: false
+properties:
+ tool:
+ type: string
+ minLength: 1
+ description: 引用 tools[].name 或 aliases[]
+ start_round:
+ type: integer
+ minimum: 1
+ default: 1
+ description: 从第几轮开始触发 judge。0 或 1 表示始终触发。
+ on_decision:
+ type: object
+ additionalProperties: false
+ properties:
+ CONTINUE:
+ type: string
+ enum:
+ - continue
+ SHRINK_TASK:
+ type: string
+ enum:
+ - continue
+ STOP_MANUAL:
+ type: string
+ enum:
+ - exit
+ STOP_BLOCKED:
+ type: string
+ enum:
+ - exit
+ description: 决策到动作的映射。默认 CONTINUE/SHRINK_TASK=continue, STOP_*=exit。
diff --git a/internal/schema/schemas/v1/pipeline_step.yaml b/internal/schema/schemas/v1/pipeline_step.yaml
new file mode 100644
index 0000000..4220510
--- /dev/null
+++ b/internal/schema/schemas/v1/pipeline_step.yaml
@@ -0,0 +1,45 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/pipeline_step.json
+title: PipelineStep
+description: 流水线步骤。三种原语判别联合:stage(串行单步)、parallel(并行)、loop(循环)。
+oneOf:
+- $ref: https://codebee.dev/schemas/v1/pipeline_step.json#/$defs/stageStep
+- $ref: https://codebee.dev/schemas/v1/pipeline_step.json#/$defs/parallelStep
+- $ref: https://codebee.dev/schemas/v1/pipeline_step.json#/$defs/loopStep
+$defs:
+ stageStep:
+ type: object
+ required:
+ - stage
+ additionalProperties: false
+ properties:
+ stage:
+ $ref: https://codebee.dev/schemas/v1/stage.json
+ parallelStep:
+ type: object
+ required:
+ - parallel
+ additionalProperties: false
+ properties:
+ parallel:
+ type: array
+ items:
+ $ref: https://codebee.dev/schemas/v1/pipeline_step.json
+ minItems: 2
+ description: 并行执行的步骤列表
+ join:
+ type: string
+ enum:
+ - all
+ - any
+ - first_success
+ default: all
+ description: 汇合策略:all=全部成功, any=任一成功, first_success=首个成功即继续
+ loopStep:
+ type: object
+ required:
+ - loop
+ additionalProperties: false
+ properties:
+ loop:
+ $ref: https://codebee.dev/schemas/v1/loop.json
diff --git a/internal/schema/schemas/v1/result_contract.yaml b/internal/schema/schemas/v1/result_contract.yaml
new file mode 100644
index 0000000..297bd17
--- /dev/null
+++ b/internal/schema/schemas/v1/result_contract.yaml
@@ -0,0 +1,23 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/result_contract.json
+title: ResultContract
+description: 结果契约,声明 stage 产出的 JSON 结构与状态字段,供调度器做退出判断。
+type: object
+required:
+- schema
+additionalProperties: false
+properties:
+ schema:
+ type: object
+ description: JSON Schema 描述本 stage 的输出结构
+ status_field:
+ type: string
+ default: status
+ description: 哪个字段表示状态,用于 exit_when 判断
+ status_enum:
+ type: array
+ items:
+ type: string
+ minItems: 1
+ uniqueItems: true
+ description: 状态枚举值列表
diff --git a/internal/schema/schemas/v1/stage.yaml b/internal/schema/schemas/v1/stage.yaml
new file mode 100644
index 0000000..d188b30
--- /dev/null
+++ b/internal/schema/schemas/v1/stage.yaml
@@ -0,0 +1,41 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/stage.json
+title: StageDefinition
+description: 流水线中的一个执行阶段。引用一个工具,并定义其输入输出与执行条件。
+type: object
+required:
+- name
+- tool
+additionalProperties: false
+properties:
+ name:
+ type: string
+ pattern: ^[a-z][a-z0-9-]{1,31}$
+ description: stage 唯一标识,loop 内通过此名引用结果
+ tool:
+ type: string
+ minLength: 1
+ description: 引用 tools[].name 或 tools[].aliases[] 中的任一值
+ input_from:
+ type: string
+ minLength: 1
+ description: 消费哪个 stage 的输出,值为目标 stage 的 name
+ output:
+ type: string
+ pattern: ^[a-z][a-z0-9_-]*\.json$
+ description: 结果文件名,必须以 .json 结尾
+ when:
+ $ref: https://codebee.dev/schemas/v1/condition.json
+ description: 条件执行表达式,如 round >= 2
+ on_blocked:
+ type: string
+ enum:
+ - stop
+ - skip
+ - continue
+ default: stop
+ description: 本 stage 阻塞时的处理策略:stop=终止流程, skip=跳过本 stage, continue=继续后续
+ args:
+ type: object
+ description: 传递给工具的额外参数
+ additionalProperties: true
diff --git a/internal/schema/schemas/v1/tool.yaml b/internal/schema/schemas/v1/tool.yaml
new file mode 100644
index 0000000..01058b0
--- /dev/null
+++ b/internal/schema/schemas/v1/tool.yaml
@@ -0,0 +1,92 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/tool.json
+title: ToolDefinition
+description: 定义一个可调度的工具。工具是 Agent 与 Command 的统一抽象——Agent 本质上也是工具。
+type: object
+required:
+- name
+- type
+additionalProperties: false
+properties:
+ name:
+ type: string
+ pattern: ^[a-z][a-z0-9-]{1,31}$
+ description: 工具唯一标识,pipeline 中通过此名引用。必须英文小写开头,仅含 a-z 0-9 -
+ display_name:
+ type: string
+ minLength: 1
+ maxLength: 32
+ description: 类人展示名称,如「代码审核员」「开发者」。用于 Issue 评论、日志等人类可读场景。
+ aliases:
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 32
+ uniqueItems: true
+ description: '@mention 别名列表。用户在 Issue 中 @任一别名都可触发本工具,支持中文类人名称如「开发者」「代码审核员」。'
+ type:
+ type: string
+ enum:
+ - agent
+ - command
+ - function
+ description: agent=AI 智能体, command=shell 命令, function=内置函数
+ skill:
+ type: string
+ minLength: 1
+ description: type=agent 时必填,对应 .skills/ 下的相对路径
+ prompt_template:
+ type: string
+ minLength: 1
+ description: type=agent 时必填,引用的 prompt 模板 ID
+ run:
+ type: string
+ minLength: 1
+ description: type=command 时必填,shell 命令
+ function:
+ type: string
+ minLength: 1
+ description: type=function 时必填,内置函数名
+ timeout:
+ type: string
+ pattern: ^[0-9]+(s|m|h)$
+ default: 60s
+ description: 超时时间,格式如 60s / 5m / 1h
+ env:
+ type: object
+ additionalProperties:
+ type: string
+ description: 环境变量
+ description:
+ type: string
+ description: 工具说明,用于文档生成
+allOf:
+- if:
+ properties:
+ type:
+ const: agent
+ required:
+ - type
+ then:
+ required:
+ - skill
+ - prompt_template
+- if:
+ properties:
+ type:
+ const: command
+ required:
+ - type
+ then:
+ required:
+ - run
+- if:
+ properties:
+ type:
+ const: function
+ required:
+ - type
+ then:
+ required:
+ - function
diff --git a/internal/schema/schemas/v1/workflow.yaml b/internal/schema/schemas/v1/workflow.yaml
new file mode 100644
index 0000000..34d98ff
--- /dev/null
+++ b/internal/schema/schemas/v1/workflow.yaml
@@ -0,0 +1,51 @@
+$schema: http://json-schema.org/draft-07/schema#
+$id: https://codebee.dev/schemas/v1/workflow.json
+title: Workflow
+description: 顶层 Workflow 配置。声明工具集与流水线编排,覆盖现有硬编码的 BuiltinAgents + 四阶段管线。
+type: object
+required:
+- version
+- name
+- tools
+- pipeline
+additionalProperties: false
+properties:
+ version:
+ type: string
+ enum:
+ - '1'
+ description: Schema 版本,用于未来兼容性判断
+ name:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: workflow 名称
+ description:
+ type: string
+ description: workflow 说明
+ tools:
+ type: array
+ items:
+ $ref: https://codebee.dev/schemas/v1/tool.json
+ minItems: 1
+ description: 工具集,pipeline 中所有 tool 引用都必须在此声明
+ pipeline:
+ type: array
+ items:
+ $ref: https://codebee.dev/schemas/v1/pipeline_step.json
+ minItems: 1
+ description: 流水线步骤序列
+ defaults:
+ type: object
+ additionalProperties: false
+ properties:
+ timeout:
+ type: string
+ pattern: ^[0-9]+(s|m|h)$
+ on_blocked:
+ type: string
+ enum:
+ - stop
+ - skip
+ - continue
+ description: 全局默认值,stage 未显式声明时继承
diff --git a/internal/schema/testdata/invalid/agent_missing_skill.yaml b/internal/schema/testdata/invalid/agent_missing_skill.yaml
new file mode 100644
index 0000000..53163ff
--- /dev/null
+++ b/internal/schema/testdata/invalid/agent_missing_skill.yaml
@@ -0,0 +1,10 @@
+version: "1"
+name: agent-missing-skill
+description: agent 类型工具缺少必填的 skill 和 prompt_template
+tools:
+ - name: coder
+ type: agent
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/invalid/bad_tool_name.yaml b/internal/schema/testdata/invalid/bad_tool_name.yaml
new file mode 100644
index 0000000..3f4a1fa
--- /dev/null
+++ b/internal/schema/testdata/invalid/bad_tool_name.yaml
@@ -0,0 +1,12 @@
+version: "1"
+name: bad-tool-name
+description: 工具名不符合 a-z 开头规则(含大写)
+tools:
+ - name: Coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: Coder
diff --git a/internal/schema/testdata/invalid/command_missing_run.yaml b/internal/schema/testdata/invalid/command_missing_run.yaml
new file mode 100644
index 0000000..6d04de1
--- /dev/null
+++ b/internal/schema/testdata/invalid/command_missing_run.yaml
@@ -0,0 +1,14 @@
+version: "1"
+name: command-missing-run
+description: command 类型工具缺少必填的 run 字段
+tools:
+ - name: lint
+ type: command
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: lint
+ tool: lint
diff --git a/internal/schema/testdata/invalid/empty_aliases.yaml b/internal/schema/testdata/invalid/empty_aliases.yaml
new file mode 100644
index 0000000..b6f3a78
--- /dev/null
+++ b/internal/schema/testdata/invalid/empty_aliases.yaml
@@ -0,0 +1,15 @@
+version: "1"
+name: alias-duplicate
+description: aliases 数组中存在重复值,违反 uniqueItems
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+ aliases:
+ - 开发者
+ - 开发者
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/invalid/empty_config.yaml b/internal/schema/testdata/invalid/empty_config.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/internal/schema/testdata/invalid/exit_condition_bad_operator.yaml b/internal/schema/testdata/invalid/exit_condition_bad_operator.yaml
new file mode 100644
index 0000000..f692ff5
--- /dev/null
+++ b/internal/schema/testdata/invalid/exit_condition_bad_operator.yaml
@@ -0,0 +1,28 @@
+version: "1"
+name: bad-exit-condition
+description: exit_when 中 operator 不是合法枚举值
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+ - name: reviewer
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+pipeline:
+ - loop:
+ id: bad-cond
+ max_iterations: 3
+ exit_when:
+ - stage: review
+ field: status
+ operator: is_pass
+ value: PASS
+ body:
+ - stage:
+ name: coding
+ tool: coder
+ - stage:
+ name: review
+ tool: reviewer
diff --git a/internal/schema/testdata/invalid/loop_max_iterations.yaml b/internal/schema/testdata/invalid/loop_max_iterations.yaml
new file mode 100644
index 0000000..4853565
--- /dev/null
+++ b/internal/schema/testdata/invalid/loop_max_iterations.yaml
@@ -0,0 +1,16 @@
+version: "1"
+name: loop-max-iterations
+description: max_iterations 超过上限 20
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - loop:
+ id: bad-loop
+ max_iterations: 100
+ body:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/invalid/loop_missing_body.yaml b/internal/schema/testdata/invalid/loop_missing_body.yaml
new file mode 100644
index 0000000..17ea2b6
--- /dev/null
+++ b/internal/schema/testdata/invalid/loop_missing_body.yaml
@@ -0,0 +1,12 @@
+version: "1"
+name: loop-missing-body
+description: loop 缺少必填的 body 字段
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - loop:
+ id: empty-loop
+ max_iterations: 3
diff --git a/internal/schema/testdata/invalid/missing_required_fields.yaml b/internal/schema/testdata/invalid/missing_required_fields.yaml
new file mode 100644
index 0000000..9629161
--- /dev/null
+++ b/internal/schema/testdata/invalid/missing_required_fields.yaml
@@ -0,0 +1,3 @@
+version: "1"
+name: missing-required
+description: 缺少 tools 和 pipeline 必填字段
diff --git a/internal/schema/testdata/invalid/parallel_single_step.yaml b/internal/schema/testdata/invalid/parallel_single_step.yaml
new file mode 100644
index 0000000..b9154b0
--- /dev/null
+++ b/internal/schema/testdata/invalid/parallel_single_step.yaml
@@ -0,0 +1,13 @@
+version: "1"
+name: parallel-single
+description: "parallel 块内只有 1 个步骤,违反 minItems: 2"
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - parallel:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/invalid/pipeline_step_unknown_key.yaml b/internal/schema/testdata/invalid/pipeline_step_unknown_key.yaml
new file mode 100644
index 0000000..58008e0
--- /dev/null
+++ b/internal/schema/testdata/invalid/pipeline_step_unknown_key.yaml
@@ -0,0 +1,12 @@
+version: "1"
+name: unknown-pipeline-step
+description: pipeline 步骤既不是 stage 也不是 parallel 也不是 loop
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - branch:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/invalid/wrong_version.yaml b/internal/schema/testdata/invalid/wrong_version.yaml
new file mode 100644
index 0000000..eaf79e5
--- /dev/null
+++ b/internal/schema/testdata/invalid/wrong_version.yaml
@@ -0,0 +1,12 @@
+version: "2"
+name: wrong-version
+description: version 字段不是合法枚举值 "1"
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/valid/command_tool.yaml b/internal/schema/testdata/valid/command_tool.yaml
new file mode 100644
index 0000000..296e929
--- /dev/null
+++ b/internal/schema/testdata/valid/command_tool.yaml
@@ -0,0 +1,19 @@
+version: "1"
+name: command-tool
+description: 验证 command 类型工具必填 run 字段
+tools:
+ - name: lint
+ type: command
+ run: "golangci-lint run ./..."
+ timeout: 120s
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: lint
+ tool: lint
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/valid/function_tool.yaml b/internal/schema/testdata/valid/function_tool.yaml
new file mode 100644
index 0000000..c864bb6
--- /dev/null
+++ b/internal/schema/testdata/valid/function_tool.yaml
@@ -0,0 +1,18 @@
+version: "1"
+name: function-tool
+description: 验证 function 类型工具必填 function 字段
+tools:
+ - name: notify
+ type: function
+ function: send_webhook
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+ - stage:
+ name: notify
+ tool: notify
diff --git a/internal/schema/testdata/valid/loop_with_judge.yaml b/internal/schema/testdata/valid/loop_with_judge.yaml
new file mode 100644
index 0000000..8ec7852
--- /dev/null
+++ b/internal/schema/testdata/valid/loop_with_judge.yaml
@@ -0,0 +1,56 @@
+version: "1"
+name: loop-with-judge
+description: 验证完整 loop + judge 配置,对齐现有 coder-reviewer 流程
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+ - name: reviewer
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+ - name: judge
+ type: agent
+ skill: "技术负责人Skill/SKILL.md"
+ prompt_template: loop-judge
+ - name: poster
+ type: agent
+ skill: "产品经理Skill/SKILL.md"
+ prompt_template: issue-post
+pipeline:
+ - loop:
+ id: dev-loop
+ max_iterations: 3
+ max_consecutive_unknown: 2
+ timeout: 30m
+ exit_when:
+ - stage: review
+ field: status
+ operator: equals
+ value: PASS
+ - stage: review
+ field: status
+ operator: equals
+ value: BLOCKED
+ body:
+ - stage:
+ name: coding
+ tool: coder
+ output: coding_result.json
+ - stage:
+ name: review
+ tool: reviewer
+ input_from: coding
+ output: review_result.json
+ - stage:
+ name: judge
+ tool: judge
+ when: "round >= 2"
+ judge:
+ tool: judge
+ start_round: 2
+ - stage:
+ name: post
+ tool: poster
+ when: "review.status == \"PASS\""
diff --git a/internal/schema/testdata/valid/minimal.yaml b/internal/schema/testdata/valid/minimal.yaml
new file mode 100644
index 0000000..3c6488e
--- /dev/null
+++ b/internal/schema/testdata/valid/minimal.yaml
@@ -0,0 +1,11 @@
+version: "1"
+name: minimal
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/valid/nested_loop.yaml b/internal/schema/testdata/valid/nested_loop.yaml
new file mode 100644
index 0000000..ab2f320
--- /dev/null
+++ b/internal/schema/testdata/valid/nested_loop.yaml
@@ -0,0 +1,35 @@
+version: "1"
+name: nested-loop
+description: 验证 loop 内嵌套 parallel
+tools:
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+ - name: reviewer
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+ - name: lint
+ type: command
+ run: "golangci-lint run ./..."
+pipeline:
+ - loop:
+ id: outer
+ max_iterations: 3
+ exit_when:
+ - stage: review
+ field: status
+ operator: equals
+ value: PASS
+ body:
+ - stage:
+ name: coding
+ tool: coder
+ - parallel:
+ - stage:
+ name: review
+ tool: reviewer
+ - stage:
+ name: lint
+ tool: lint
diff --git a/internal/schema/testdata/valid/parallel_pipeline.yaml b/internal/schema/testdata/valid/parallel_pipeline.yaml
new file mode 100644
index 0000000..904cd70
--- /dev/null
+++ b/internal/schema/testdata/valid/parallel_pipeline.yaml
@@ -0,0 +1,25 @@
+version: "1"
+name: parallel-pipeline
+description: 验证 parallel 原语至少 2 个子步骤
+tools:
+ - name: lint
+ type: command
+ run: "golangci-lint run ./..."
+ - name: test
+ type: command
+ run: "go test ./..."
+ - name: coder
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - parallel:
+ - stage:
+ name: lint
+ tool: lint
+ - stage:
+ name: test
+ tool: test
+ - stage:
+ name: coding
+ tool: coder
diff --git a/internal/schema/testdata/valid/tool_with_alias.yaml b/internal/schema/testdata/valid/tool_with_alias.yaml
new file mode 100644
index 0000000..3854666
--- /dev/null
+++ b/internal/schema/testdata/valid/tool_with_alias.yaml
@@ -0,0 +1,28 @@
+version: "1"
+name: tool-with-alias
+description: 验证工具别名(含中文类人名称)能被接受
+tools:
+ - name: reviewer
+ display_name: 代码审核员
+ aliases:
+ - QA负责人
+ - 审查员
+ - reviewer-bot
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+ - name: coder
+ display_name: 开发者
+ aliases:
+ - 开发
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+ - stage:
+ name: review
+ tool: reviewer
+ input_from: coding
diff --git a/internal/schema/types.go b/internal/schema/types.go
new file mode 100644
index 0000000..d7284ca
--- /dev/null
+++ b/internal/schema/types.go
@@ -0,0 +1,137 @@
+// Package schema 的类型定义文件。
+//
+// 本文件定义与 schemas/v1/*.yaml 一一对应的 Go 数据结构。
+// 设计原则:
+// 1. 结构体字段顺序与 schema properties 顺序一致,便于对照
+// 2. yaml tag 与 schema 字段名严格一致
+// 3. 判别联合(PipelineStep/Condition)用自定义 UnmarshalYAML 处理
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+// 更新时间: 2026-07-05
+package schema
+
+import "gopkg.in/yaml.v3"
+
+// Workflow 是顶层配置结构,对应 workflow.json。
+type Workflow struct {
+ Version string `yaml:"version"`
+ Name string `yaml:"name"`
+ Description string `yaml:"description,omitempty"`
+ Tools []Tool `yaml:"tools"`
+ Pipeline []PipelineStep `yaml:"pipeline"`
+ Defaults *Defaults `yaml:"defaults,omitempty"`
+}
+
+// Defaults 是全局默认值。
+type Defaults struct {
+ Timeout string `yaml:"timeout,omitempty"`
+ OnBlocked string `yaml:"on_blocked,omitempty"`
+}
+
+// Tool 定义一个可调度的工具,对应 tool.json。
+//
+// 关键设计:
+// - Name 是内部标识,pipeline 中引用此名
+// - DisplayName 是类人展示名,用于评论和日志
+// - Aliases 是 @mention 别名列表,Issue 中 @ 任一别名都可触发本工具
+type Tool struct {
+ Name string `yaml:"name"`
+ DisplayName string `yaml:"display_name,omitempty"`
+ Aliases []string `yaml:"aliases,omitempty"`
+ Type string `yaml:"type"` // agent | command | function
+ Skill string `yaml:"skill,omitempty"`
+ PromptTemplate string `yaml:"prompt_template,omitempty"`
+ Run string `yaml:"run,omitempty"`
+ Function string `yaml:"function,omitempty"`
+ Timeout string `yaml:"timeout,omitempty"`
+ Env map[string]string `yaml:"env,omitempty"`
+ Description string `yaml:"description,omitempty"`
+}
+
+// Stage 定义流水线中的一个执行阶段,对应 stage.json。
+type Stage struct {
+ Name string `yaml:"name"`
+ Tool string `yaml:"tool"`
+ InputFrom string `yaml:"input_from,omitempty"`
+ Output string `yaml:"output,omitempty"`
+ When *Condition `yaml:"when,omitempty"`
+ OnBlocked string `yaml:"on_blocked,omitempty"`
+ Args map[string]any `yaml:"args,omitempty"`
+}
+
+// Condition 是条件表达式,对应 condition.json 的 oneOf。
+//
+// 两种形态:
+// - Expr: 字符串表达式,如 "round >= 2"
+// - Structured: 结构化条件,引用某 stage 的输出字段
+type Condition struct {
+ Expr string
+ Structured *ExitCondition
+}
+
+// UnmarshalYAML 让 Condition 支持字符串或对象两种 YAML 形态。
+func (c *Condition) UnmarshalYAML(value *yaml.Node) error {
+ switch value.Kind {
+ case yaml.ScalarNode:
+ c.Expr = value.Value
+ return nil
+ case yaml.MappingNode:
+ var ec ExitCondition
+ if err := value.Decode(&ec); err != nil {
+ return err
+ }
+ c.Structured = &ec
+ return nil
+ default:
+ return errConditionKind
+ }
+}
+
+// ExitCondition 是结构化退出条件,对应 exit_condition.json。
+type ExitCondition struct {
+ Stage string `yaml:"stage"`
+ Field string `yaml:"field"`
+ Operator string `yaml:"operator"`
+ Value any `yaml:"value"`
+}
+
+// ResultContract 是结果契约,对应 result_contract.json。
+type ResultContract struct {
+ Schema map[string]any `yaml:"schema"`
+ StatusField string `yaml:"status_field,omitempty"`
+ StatusEnum []string `yaml:"status_enum,omitempty"`
+}
+
+// PipelineStep 是流水线步骤的判别联合,对应 pipeline_step.json。
+//
+// 三个字段互斥,由 Validate 保证恰好一个非空:
+// - Stage: 串行单步
+// - Parallel: 并行执行(至少 2 个子步骤)
+// - Loop: 循环
+//
+// Join 仅在 Parallel 非 nil 时有效,表示汇合策略。
+type PipelineStep struct {
+ Stage *Stage `yaml:"stage,omitempty"`
+ Parallel []PipelineStep `yaml:"parallel,omitempty"`
+ Loop *Loop `yaml:"loop,omitempty"`
+ Join string `yaml:"join,omitempty"`
+}
+
+// Loop 是循环编排原语,对应 loop.json。
+type Loop struct {
+ ID string `yaml:"id"`
+ MaxIterations int `yaml:"max_iterations,omitempty"`
+ MaxConsecutiveUnknown int `yaml:"max_consecutive_unknown,omitempty"`
+ Timeout string `yaml:"timeout,omitempty"`
+ ExitWhen []ExitCondition `yaml:"exit_when,omitempty"`
+ Body []PipelineStep `yaml:"body"`
+ Judge *LoopJudge `yaml:"judge,omitempty"`
+}
+
+// LoopJudge 是价值评估员配置,对应 loop_judge.json。
+type LoopJudge struct {
+ Tool string `yaml:"tool"`
+ StartRound int `yaml:"start_round,omitempty"`
+ OnDecision map[string]string `yaml:"on_decision,omitempty"`
+}
diff --git a/internal/schema/validator.go b/internal/schema/validator.go
new file mode 100644
index 0000000..8fe25fe
--- /dev/null
+++ b/internal/schema/validator.go
@@ -0,0 +1,249 @@
+// Package schema 提供 code-bee workflow 配置的 JSON Schema 加载与校验。
+//
+// 设计目标:
+// 1. 把 schemas/v1/*.yaml 通过 go:embed 打包进二进制,避免外部文件依赖
+// 2. 用 santhosh-tekuri/jsonschema/v5 做完整 draft-07 校验
+// 3. 对外暴露最小接口:NewValidator + ValidateWorkflow
+//
+// Schema 文件格式说明:
+// - schema 定义文件用 YAML 格式(与用户配置统一),加载时转 JSON 喂给校验库
+// - $id 和 $ref 中的 URL 保持 .json 后缀,作为 URI 标识符(JSON Schema 标准约定)
+//
+// 开发维护: AI Assistant
+// 创建时间: 2026-07-05
+// 更新时间: 2026-07-05
+package schema
+
+import (
+ "bytes"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/fs"
+ "strings"
+
+ jsonschema "github.com/santhosh-tekuri/jsonschema/v5"
+ "gopkg.in/yaml.v3"
+)
+
+//go:embed schemas/v1/*.yaml
+var schemaFS embed.FS
+
+const (
+ // schemaIDBase 是所有 v1 schema 的 $id 前缀。
+ // 注意:$id URL 保持 .json 后缀作为 URI 标识符(JSON Schema 标准约定),
+ // 实际文件用 .yaml 格式,加载时转 JSON 注册到 compiler。
+ schemaIDBase = "https://codebee.dev/schemas/v1/"
+)
+
+// Validator 负责加载所有 v1 schema 并对外提供校验入口。
+type Validator struct {
+ // compiler 是已注册全部 v1 schema 资源的编译器实例。
+ compiler *jsonschema.Compiler
+
+ // workflow 是编译后的顶层 workflow schema。
+ workflow *jsonschema.Schema
+}
+
+// NewValidator 创建并初始化一个 schema 校验器。
+//
+// 核心逻辑:
+// - 遍历 embed FS 中的 schemas/v1/*.yaml,逐个转 JSON 后注册到 compiler
+// - 编译 workflow schema 作为顶层入口
+func NewValidator() (*Validator, error) {
+ c := jsonschema.NewCompiler()
+
+ if err := loadEmbeddedSchemas(c); err != nil {
+ return nil, fmt.Errorf("schema.NewValidator: load embedded schemas: %w", err)
+ }
+
+ workflow, err := c.Compile(schemaIDBase + "workflow.json")
+ if err != nil {
+ return nil, fmt.Errorf("schema.NewValidator: compile workflow: %w", err)
+ }
+
+ return &Validator{compiler: c, workflow: workflow}, nil
+}
+
+// loadEmbeddedSchemas 把 embed FS 中所有 v1 schema 文件注册到 compiler。
+//
+// 关键点:
+// - schema 文件用 YAML 格式存储,加载时转 JSON 喂给 santhosh-tekuri/jsonschema
+// - $id 和 $ref URL 保持 .json 后缀,作为 URI 标识符(与 JSON Schema 标准约定一致)
+// - 必须先注册全部资源再 Compile,否则跨文件 $ref 会失败
+func loadEmbeddedSchemas(c *jsonschema.Compiler) error {
+ entries, err := fs.ReadDir(schemaFS, "schemas/v1")
+ if err != nil {
+ return fmt.Errorf("read schema dir: %w", err)
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
+ continue
+ }
+
+ data, readErr := fs.ReadFile(schemaFS, "schemas/v1/"+entry.Name())
+ if readErr != nil {
+ return fmt.Errorf("read schema %s: %w", entry.Name(), readErr)
+ }
+
+ // YAML → JSON 转换:santhosh-tekuri/jsonschema 只接受 JSON 输入
+ var doc any
+ if err := yaml.Unmarshal(data, &doc); err != nil {
+ return fmt.Errorf("yaml unmarshal schema %s: %w", entry.Name(), err)
+ }
+
+ jsonBytes, err := json.Marshal(doc)
+ if err != nil {
+ return fmt.Errorf("json marshal schema %s: %w", entry.Name(), err)
+ }
+
+ // URL 用 .json 后缀(与 schema 文件内 $id 和 $ref 中的引用一致)
+ baseName := strings.TrimSuffix(entry.Name(), ".yaml")
+ url := schemaIDBase + baseName + ".json"
+ if err := c.AddResource(url, bytes.NewReader(jsonBytes)); err != nil {
+ return fmt.Errorf("add resource %s: %w", baseName, err)
+ }
+ }
+
+ return nil
+}
+
+// ValidateWorkflow 校验一份 YAML 格式的 workflow 配置。
+//
+// 设计说明:
+// - 用户配置文件统一用 YAML(可读性好、支持注释、与 workflow 生态一致)
+// - schema 定义文件也用 YAML,加载时统一转 JSON 喂给校验库
+//
+// 输入参数:
+// - data: YAML 字节流
+//
+// 返回值:
+// - error: 校验失败时返回 *ValidationError,包含全部字段级错误
+func (v *Validator) ValidateWorkflow(data []byte) error {
+ doc, err := decodeConfig(data)
+ if err != nil {
+ return fmt.Errorf("schema.ValidateWorkflow: decode: %w", err)
+ }
+
+ if err := v.workflow.Validate(doc); err != nil {
+ return newValidationError(err)
+ }
+
+ return nil
+}
+
+// ValidateTool 单独校验一份 tool 配置,便于工具注册场景增量校验。
+func (v *Validator) ValidateTool(data []byte) error {
+ doc, err := decodeConfig(data)
+ if err != nil {
+ return fmt.Errorf("schema.ValidateTool: decode: %w", err)
+ }
+
+ sch := v.compiler.MustCompile(schemaIDBase + "tool.json")
+ if err := sch.Validate(doc); err != nil {
+ return newValidationError(err)
+ }
+
+ return nil
+}
+
+// decodeConfig 把 YAML 字节流解码为 interface{}。
+//
+// 设计说明:
+// - 用户配置文件统一用 YAML(可读性好、支持注释和多行字符串、与 K8s/GitHub Actions 生态一致)
+// - YAML 解出的数值类型与 jsonschema 期望不一致,需通过 JSON 往返统一类型
+func decodeConfig(data []byte) (any, error) {
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) == 0 {
+ return nil, errors.New("empty config data")
+ }
+
+ var doc any
+ if err := yaml.Unmarshal(trimmed, &doc); err != nil {
+ return nil, fmt.Errorf("yaml unmarshal: %w", err)
+ }
+
+ // YAML 解出的 map 类型是 map[string]interface{},但数值类型可能为 int64,
+ // jsonschema 期望 float64 或 json.Number。通过 JSON 往返统一类型。
+ jsonBytes, err := json.Marshal(doc)
+ if err != nil {
+ return nil, fmt.Errorf("yaml normalize marshal: %w", err)
+ }
+
+ var unified any
+ if err := json.Unmarshal(jsonBytes, &unified); err != nil {
+ return nil, fmt.Errorf("yaml normalize unmarshal: %w", err)
+ }
+
+ return unified, nil
+}
+
+// ValidationError 描述一份配置的全部校验错误。
+type ValidationError struct {
+ // Errors 是字段级错误列表。
+ Errors []FieldError
+}
+
+// FieldError 描述单个字段的校验错误。
+type FieldError struct {
+ // Location 是错误字段的 JSON Pointer 路径,如 /tools/0/name。
+ Location string
+
+ // Message 是错误描述。
+ Message string
+}
+
+// Error 实现 error 接口。
+func (e *ValidationError) Error() string {
+ if len(e.Errors) == 0 {
+ return "validation failed"
+ }
+
+ var b strings.Builder
+ b.WriteString("validation failed:")
+ for _, fe := range e.Errors {
+ b.WriteString("\n at ")
+ if fe.Location == "" {
+ b.WriteString("(root)")
+ } else {
+ b.WriteString(fe.Location)
+ }
+ b.WriteString(": ")
+ b.WriteString(fe.Message)
+ }
+ return b.String()
+}
+
+// newValidationError 把 jsonschema 库返回的错误转换成 ValidationError。
+func newValidationError(err error) *ValidationError {
+ ve := &ValidationError{}
+
+ var valErr *jsonschema.ValidationError
+ if errors.As(err, &valErr) {
+ collectValidationError(valErr, &ve.Errors)
+ return ve
+ }
+
+ ve.Errors = append(ve.Errors, FieldError{
+ Location: "",
+ Message: err.Error(),
+ })
+ return ve
+}
+
+// collectValidationError 递归收集所有叶子错误。
+func collectValidationError(err *jsonschema.ValidationError, out *[]FieldError) {
+ if len(err.Causes) == 0 {
+ *out = append(*out, FieldError{
+ Location: err.InstanceLocation,
+ Message: err.Message,
+ })
+ return
+ }
+
+ for _, cause := range err.Causes {
+ collectValidationError(cause, out)
+ }
+}
diff --git a/internal/schema/validator_test.go b/internal/schema/validator_test.go
new file mode 100644
index 0000000..70a55ce
--- /dev/null
+++ b/internal/schema/validator_test.go
@@ -0,0 +1,357 @@
+package schema
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestValidator_ValidateWorkflow_Valid 全部合法 workflow 配置应通过校验。
+//
+// 覆盖场景:
+// - 最小化配置(仅一个 tool + 一个 stage)
+// - 工具别名(中文类人名称)
+// - 三种 tool type(agent / command / function)
+// - parallel 编排
+// - loop + judge 编排
+// - 嵌套 loop
+// - JSON 与 YAML 两种格式
+func TestValidator_ValidateWorkflow_Valid(t *testing.T) {
+ cases := loadCases(t, "testdata/valid")
+
+ v := newValidator(t)
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Arrange
+ data, err := os.ReadFile(tc.path)
+ if err != nil {
+ t.Fatalf("read testdata %s: %v", tc.path, err)
+ }
+
+ // Act
+ err = v.ValidateWorkflow(data)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("expected valid, got error: %v", err)
+ }
+ })
+ }
+}
+
+// TestValidator_ValidateWorkflow_Invalid 全部非法 workflow 配置应被拒绝。
+//
+// 覆盖场景:
+// - 缺少必填字段
+// - agent 缺 skill
+// - command 缺 run
+// - 工具名非法
+// - parallel 子步骤不足
+// - loop 超限 / 缺 body
+// - exit_when operator 非法
+// - version 非法
+// - 未知 pipeline step
+// - aliases 重复
+// - 空配置
+func TestValidator_ValidateWorkflow_Invalid(t *testing.T) {
+ cases := loadCases(t, "testdata/invalid")
+
+ v := newValidator(t)
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Arrange
+ data, err := os.ReadFile(tc.path)
+ if err != nil {
+ t.Fatalf("read testdata %s: %v", tc.path, err)
+ }
+
+ // Act
+ err = v.ValidateWorkflow(data)
+
+ // Assert
+ // 配置非法包含两类:
+ // 1. decode 阶段失败(空配置 / YAML 语法错)→ 返回普通 wrapError
+ // 2. schema 校验失败(字段缺失 / 枚举非法)→ 返回 *ValidationError
+ // 两者都算"配置非法",本测试只要求 err != nil
+ if err == nil {
+ t.Fatalf("expected validation error, got nil")
+ }
+ })
+ }
+}
+
+// TestValidator_ValidateWorkflow_ToolAlias 验证工具别名功能:
+// 含中文类人名称的 aliases 字段应被接受,且能正确解析。
+func TestValidator_ValidateWorkflow_ToolAlias(t *testing.T) {
+ v := newValidator(t)
+
+ // Arrange: 含中文别名的工具定义
+ data := []byte(`
+version: "1"
+name: alias-feature
+tools:
+ - name: reviewer
+ display_name: 代码审核员
+ aliases:
+ - QA负责人
+ - 审查员
+ type: agent
+ skill: "QA负责人Skill/SKILL.md"
+ prompt_template: review
+pipeline:
+ - stage:
+ name: review
+ tool: reviewer
+`)
+
+ // Act
+ err := v.ValidateWorkflow(data)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("tool with chinese aliases should be valid: %v", err)
+ }
+}
+
+// TestValidator_ValidateTool 单独校验 tool 配置,验证 agent / command / function 三种类型的条件必填。
+func TestValidator_ValidateTool(t *testing.T) {
+ v := newValidator(t)
+
+ cases := []struct {
+ name string
+ data string
+ wantErr bool
+ }{
+ {
+ name: "agent_with_skill_and_prompt",
+ data: `
+name: coder
+type: agent
+skill: "开发者Skill/SKILL.md"
+prompt_template: coding
+`,
+ wantErr: false,
+ },
+ {
+ name: "agent_without_skill",
+ data: `
+name: coder
+type: agent
+prompt_template: coding
+`,
+ wantErr: true,
+ },
+ {
+ name: "command_with_run",
+ data: `
+name: lint
+type: command
+run: "golangci-lint run ./..."
+`,
+ wantErr: false,
+ },
+ {
+ name: "command_without_run",
+ data: `
+name: lint
+type: command
+`,
+ wantErr: true,
+ },
+ {
+ name: "function_with_function_name",
+ data: `
+name: notify
+type: function
+function: send_webhook
+`,
+ wantErr: false,
+ },
+ {
+ name: "function_without_function_name",
+ data: `
+name: notify
+type: function
+`,
+ wantErr: true,
+ },
+ {
+ name: "unknown_type",
+ data: `
+name: foo
+type: unknown
+`,
+ wantErr: true,
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Act
+ err := v.ValidateTool([]byte(tc.data))
+
+ // Assert
+ if tc.wantErr && err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ if !tc.wantErr && err != nil {
+ t.Fatalf("expected no error, got: %v", err)
+ }
+ })
+ }
+}
+
+// TestValidationError_Error 验证 ValidationError 的字符串格式包含字段路径。
+func TestValidationError_Error(t *testing.T) {
+ // Arrange
+ ve := &ValidationError{
+ Errors: []FieldError{
+ {Location: "/tools/0/name", Message: "invalid pattern"},
+ {Location: "", Message: "missing pipeline"},
+ },
+ }
+
+ // Act
+ got := ve.Error()
+
+ // Assert
+ if !strings.Contains(got, "/tools/0/name") {
+ t.Errorf("error output missing location: %s", got)
+ }
+ if !strings.Contains(got, "(root)") {
+ t.Errorf("error output should mark empty location as (root): %s", got)
+ }
+}
+
+// TestNewValidator_LoadsAllSchemas 验证 NewValidator 能加载全部 9 个 schema 文件且无错误。
+func TestNewValidator_LoadsAllSchemas(t *testing.T) {
+ // Act
+ v, err := NewValidator()
+
+ // Assert
+ if err != nil {
+ t.Fatalf("NewValidator failed: %v", err)
+ }
+ if v == nil {
+ t.Fatal("NewValidator returned nil")
+ }
+ if v.workflow == nil {
+ t.Fatal("workflow schema not compiled")
+ }
+}
+
+// TestValidateWorkflow_YAMLFormat 验证 YAML 格式(含注释)能正确解码。
+func TestValidateWorkflow_YAMLFormat(t *testing.T) {
+ v := newValidator(t)
+
+ // Arrange: 含注释和多行结构的 YAML
+ data := []byte(`# code-bee workflow 配置
+version: "1"
+name: yaml-with-comments
+tools:
+ - name: coder # 编码智能体
+ type: agent
+ skill: "开发者Skill/SKILL.md"
+ prompt_template: coding
+pipeline:
+ - stage:
+ name: coding
+ tool: coder
+`)
+
+ // Act
+ err := v.ValidateWorkflow(data)
+
+ // Assert
+ if err != nil {
+ t.Fatalf("YAML with comments should be valid: %v", err)
+ }
+}
+
+// TestValidateWorkflow_DecodeError 验证 decode 阶段的错误会被包装返回。
+func TestValidateWorkflow_DecodeError(t *testing.T) {
+ v := newValidator(t)
+
+ cases := []struct {
+ name string
+ data string
+ }{
+ {name: "empty", data: ""},
+ {name: "whitespace_only", data: " \n\t "},
+ {name: "invalid_yaml", data: "version: 1\n bad: : : indent"},
+ }
+
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ // Act
+ err := v.ValidateWorkflow([]byte(tc.data))
+
+ // Assert
+ if err == nil {
+ t.Fatalf("expected decode error, got nil")
+ }
+ if !strings.Contains(err.Error(), "decode") {
+ t.Fatalf("expected decode error message, got: %v", err)
+ }
+ })
+ }
+}
+
+// testCase 表示一个测试用例的元信息。
+type testCase struct {
+ name string
+ path string
+}
+
+// loadCases 遍历指定目录下的所有 .yaml/.yml/.json 文件作为测试用例。
+//
+// 设计说明:
+// - 用文件名(去后缀)作为子测试名,避免硬编码用例列表
+// - 新增 testdata 文件即自动加入测试,无需改测试代码
+func loadCases(t *testing.T, dir string) []testCase {
+ t.Helper()
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("read testdata dir %s: %v", dir, err)
+ }
+
+ var cases []testCase
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ ext := filepath.Ext(entry.Name())
+ if ext != ".yaml" && ext != ".yml" && ext != ".json" {
+ continue
+ }
+
+ name := strings.TrimSuffix(entry.Name(), ext)
+ cases = append(cases, testCase{
+ name: name,
+ path: filepath.Join(dir, entry.Name()),
+ })
+ }
+
+ if len(cases) == 0 {
+ t.Fatalf("no test cases found in %s", dir)
+ }
+ return cases
+}
+
+// newValidator 是测试用的便捷构造函数。
+func newValidator(t *testing.T) *Validator {
+ t.Helper()
+ v, err := NewValidator()
+ if err != nil {
+ t.Fatalf("NewValidator: %v", err)
+ }
+ return v
+}