From 5481c8c1d5ae5630421be154dd42c311731dc043 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 11:11:30 +0800 Subject: [PATCH 01/13] feat(schema): add workflow schema and config loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三层 JSON Schema (draft-07): tool/stage/condition/loop/pipeline_step/workflow - 工具别名支持 (name + display_name + aliases), 解决硬编码角色锁定场景问题 - 基于 santhosh-tekuri/jsonschema/v5 的 Schema 校验器, embed 打包 - YAML 配置加载器 + 跨字段语义校验 (工具引用完整性/别名唯一性/stage 可见性) - 用户配置统一 YAML, JSON 仅用于 schema 定义文件 - 87.6% 测试覆盖率, 含 8 valid + 12 invalid 用例 Refs #11 --- go.mod | 5 + go.sum | 5 + internal/schema/errors.go | 25 + internal/schema/loader.go | 291 +++++++++++ internal/schema/loader_test.go | 451 ++++++++++++++++++ internal/schema/schemas/v1/README.md | 71 +++ internal/schema/schemas/v1/condition.json | 16 + .../schema/schemas/v1/exit_condition.json | 29 ++ internal/schema/schemas/v1/loop.json | 51 ++ internal/schema/schemas/v1/loop_judge.json | 33 ++ internal/schema/schemas/v1/pipeline_step.json | 48 ++ .../schema/schemas/v1/result_contract.json | 27 ++ internal/schema/schemas/v1/stage.json | 46 ++ internal/schema/schemas/v1/tool.json | 101 ++++ internal/schema/schemas/v1/workflow.json | 53 ++ .../testdata/invalid/agent_missing_skill.yaml | 10 + .../testdata/invalid/bad_tool_name.yaml | 12 + .../testdata/invalid/command_missing_run.yaml | 14 + .../testdata/invalid/empty_aliases.yaml | 15 + .../schema/testdata/invalid/empty_config.yaml | 0 .../invalid/exit_condition_bad_operator.yaml | 28 ++ .../testdata/invalid/loop_max_iterations.yaml | 16 + .../testdata/invalid/loop_missing_body.yaml | 12 + .../invalid/missing_required_fields.yaml | 3 + .../invalid/parallel_single_step.yaml | 13 + .../invalid/pipeline_step_unknown_key.yaml | 12 + .../testdata/invalid/wrong_version.yaml | 12 + .../schema/testdata/valid/command_tool.yaml | 19 + .../schema/testdata/valid/function_tool.yaml | 18 + .../testdata/valid/loop_with_judge.yaml | 56 +++ internal/schema/testdata/valid/minimal.yaml | 11 + .../schema/testdata/valid/nested_loop.yaml | 35 ++ .../testdata/valid/parallel_pipeline.yaml | 25 + .../testdata/valid/tool_with_alias.yaml | 28 ++ internal/schema/types.go | 137 ++++++ internal/schema/validator.go | 231 +++++++++ internal/schema/validator_test.go | 357 ++++++++++++++ 37 files changed, 2316 insertions(+) create mode 100644 internal/schema/errors.go create mode 100644 internal/schema/loader.go create mode 100644 internal/schema/loader_test.go create mode 100644 internal/schema/schemas/v1/README.md create mode 100644 internal/schema/schemas/v1/condition.json create mode 100644 internal/schema/schemas/v1/exit_condition.json create mode 100644 internal/schema/schemas/v1/loop.json create mode 100644 internal/schema/schemas/v1/loop_judge.json create mode 100644 internal/schema/schemas/v1/pipeline_step.json create mode 100644 internal/schema/schemas/v1/result_contract.json create mode 100644 internal/schema/schemas/v1/stage.json create mode 100644 internal/schema/schemas/v1/tool.json create mode 100644 internal/schema/schemas/v1/workflow.json create mode 100644 internal/schema/testdata/invalid/agent_missing_skill.yaml create mode 100644 internal/schema/testdata/invalid/bad_tool_name.yaml create mode 100644 internal/schema/testdata/invalid/command_missing_run.yaml create mode 100644 internal/schema/testdata/invalid/empty_aliases.yaml create mode 100644 internal/schema/testdata/invalid/empty_config.yaml create mode 100644 internal/schema/testdata/invalid/exit_condition_bad_operator.yaml create mode 100644 internal/schema/testdata/invalid/loop_max_iterations.yaml create mode 100644 internal/schema/testdata/invalid/loop_missing_body.yaml create mode 100644 internal/schema/testdata/invalid/missing_required_fields.yaml create mode 100644 internal/schema/testdata/invalid/parallel_single_step.yaml create mode 100644 internal/schema/testdata/invalid/pipeline_step_unknown_key.yaml create mode 100644 internal/schema/testdata/invalid/wrong_version.yaml create mode 100644 internal/schema/testdata/valid/command_tool.yaml create mode 100644 internal/schema/testdata/valid/function_tool.yaml create mode 100644 internal/schema/testdata/valid/loop_with_judge.yaml create mode 100644 internal/schema/testdata/valid/minimal.yaml create mode 100644 internal/schema/testdata/valid/nested_loop.yaml create mode 100644 internal/schema/testdata/valid/parallel_pipeline.yaml create mode 100644 internal/schema/testdata/valid/tool_with_alias.yaml create mode 100644 internal/schema/types.go create mode 100644 internal/schema/validator.go create mode 100644 internal/schema/validator_test.go diff --git a/go.mod b/go.mod index f9782e1..883a212 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 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..ae056b7 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,5 @@ +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/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/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..133a849 --- /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..af0a041 --- /dev/null +++ b/internal/schema/schemas/v1/README.md @@ -0,0 +1,71 @@ +# code-bee Workflow Schema v1 + +三层 JSON Schema,描述可配置的蜂巢调度策略。 + +## 层次结构 + +``` +Layer 3: workflow.json (顶层) + │ +Layer 2: pipeline_step.json (编排原语:stage / parallel / loop 判别联合) + loop.json + loop_judge.json + │ +Layer 1: tool.json (基础类型) + stage.json + condition.json + exit_condition.json + result_contract.json +``` + +## 关键设计 + +### 工具别名(@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.json b/internal/schema/schemas/v1/condition.json new file mode 100644 index 0000000..019a588 --- /dev/null +++ b/internal/schema/schemas/v1/condition.json @@ -0,0 +1,16 @@ +{ + "$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.json b/internal/schema/schemas/v1/exit_condition.json new file mode 100644 index 0000000..b1fe4a2 --- /dev/null +++ b/internal/schema/schemas/v1/exit_condition.json @@ -0,0 +1,29 @@ +{ + "$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.json b/internal/schema/schemas/v1/loop.json new file mode 100644 index 0000000..f6ba13d --- /dev/null +++ b/internal/schema/schemas/v1/loop.json @@ -0,0 +1,51 @@ +{ + "$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.json b/internal/schema/schemas/v1/loop_judge.json new file mode 100644 index 0000000..50291a1 --- /dev/null +++ b/internal/schema/schemas/v1/loop_judge.json @@ -0,0 +1,33 @@ +{ + "$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.json b/internal/schema/schemas/v1/pipeline_step.json new file mode 100644 index 0000000..bf811b9 --- /dev/null +++ b/internal/schema/schemas/v1/pipeline_step.json @@ -0,0 +1,48 @@ +{ + "$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.json b/internal/schema/schemas/v1/result_contract.json new file mode 100644 index 0000000..e077092 --- /dev/null +++ b/internal/schema/schemas/v1/result_contract.json @@ -0,0 +1,27 @@ +{ + "$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.json b/internal/schema/schemas/v1/stage.json new file mode 100644 index 0000000..6cab5f7 --- /dev/null +++ b/internal/schema/schemas/v1/stage.json @@ -0,0 +1,46 @@ +{ + "$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.json b/internal/schema/schemas/v1/tool.json new file mode 100644 index 0000000..721fd40 --- /dev/null +++ b/internal/schema/schemas/v1/tool.json @@ -0,0 +1,101 @@ +{ + "$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.json b/internal/schema/schemas/v1/workflow.json new file mode 100644 index 0000000..d4bc971 --- /dev/null +++ b/internal/schema/schemas/v1/workflow.json @@ -0,0 +1,53 @@ +{ + "$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..36d2520 --- /dev/null +++ b/internal/schema/types.go @@ -0,0 +1,137 @@ +// Package schema 的类型定义文件。 +// +// 本文件定义与 schemas/v1/*.json 一一对应的 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..b4053fe --- /dev/null +++ b/internal/schema/validator.go @@ -0,0 +1,231 @@ +// Package schema 提供 code-bee workflow 配置的 JSON Schema 加载与校验。 +// +// 设计目标: +// 1. 把 schemas/v1/*.json 通过 go:embed 打包进二进制,避免外部文件依赖 +// 2. 用 santhosh-tekuri/jsonschema/v5 做完整 draft-07 校验 +// 3. 对外暴露最小接口:NewValidator + ValidateWorkflow +// +// 开发维护: 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/*.json +var schemaFS embed.FS + +const ( + // workflowSchemaPath 是顶层 schema 在 embed FS 中的路径。 + workflowSchemaPath = "schemas/v1/workflow.json" + + // schemaIDBase 是所有 v1 schema 的 $id 前缀。 + 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/*.json,逐个注册到 compiler +// - 编译 workflow.json 作为顶层入口 +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。 +// +// 关键点: +// - $id 是文件名(如 tool.json),$ref 通过 https://codebee.dev/schemas/v1/.json 引用 +// - 必须先注册全部资源再 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(), ".json") { + continue + } + + data, readErr := fs.ReadFile(schemaFS, "schemas/v1/"+entry.Name()) + if readErr != nil { + return fmt.Errorf("read schema %s: %w", entry.Name(), readErr) + } + + url := schemaIDBase + entry.Name() + c.AddResource(url, bytes.NewReader(data)) + } + + return nil +} + +// ValidateWorkflow 校验一份 YAML 格式的 workflow 配置。 +// +// 设计说明: +// - 用户配置文件统一用 YAML(可读性好、支持注释、与 workflow 生态一致) +// - 不接受 JSON 格式的用户配置;JSON 仅用于 schema 定义文件本身 +// +// 输入参数: +// - 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 生态一致) +// - JSON Schema 定义文件本身仍是 JSON,但那是 schema 标准格式,与用户配置无关 +// - 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 +} From 1f77397480e4d67c8a6c1b87a75c4628459444de Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 11:28:09 +0800 Subject: [PATCH 02/13] refactor(schema): unify schema files to YAML format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 9 个 schema 定义文件从 JSON 转为 YAML 格式,与用户配置格式统一。 改动: - schemas/v1/*.json → schemas/v1/*.yaml(内容等价,YAML 语法) - $id 和 $ref 的 URL 保持 .json 后缀作为 URI 标识符(JSON Schema 标准约定) - loadEmbeddedSchemas: 读 YAML → 转 JSON → 注册到 compiler - go:embed 改为 *.yaml - 更新 types.go 注释和 README.md 文件名引用 测试覆盖: 87.0% (全项目测试通过) Refs #11 --- internal/schema/schemas/v1/README.md | 22 ++-- internal/schema/schemas/v1/condition.json | 16 --- internal/schema/schemas/v1/condition.yaml | 9 ++ .../schema/schemas/v1/exit_condition.json | 29 ----- .../schema/schemas/v1/exit_condition.yaml | 34 ++++++ internal/schema/schemas/v1/loop.json | 51 --------- internal/schema/schemas/v1/loop.yaml | 45 ++++++++ internal/schema/schemas/v1/loop_judge.json | 33 ------ internal/schema/schemas/v1/loop_judge.yaml | 39 +++++++ internal/schema/schemas/v1/pipeline_step.json | 48 --------- internal/schema/schemas/v1/pipeline_step.yaml | 45 ++++++++ .../schema/schemas/v1/result_contract.json | 27 ----- .../schema/schemas/v1/result_contract.yaml | 23 ++++ internal/schema/schemas/v1/stage.json | 46 -------- internal/schema/schemas/v1/stage.yaml | 41 +++++++ internal/schema/schemas/v1/tool.json | 101 ------------------ internal/schema/schemas/v1/tool.yaml | 92 ++++++++++++++++ internal/schema/schemas/v1/workflow.json | 53 --------- internal/schema/schemas/v1/workflow.yaml | 51 +++++++++ internal/schema/types.go | 2 +- internal/schema/validator.go | 41 +++++-- 21 files changed, 422 insertions(+), 426 deletions(-) delete mode 100644 internal/schema/schemas/v1/condition.json create mode 100644 internal/schema/schemas/v1/condition.yaml delete mode 100644 internal/schema/schemas/v1/exit_condition.json create mode 100644 internal/schema/schemas/v1/exit_condition.yaml delete mode 100644 internal/schema/schemas/v1/loop.json create mode 100644 internal/schema/schemas/v1/loop.yaml delete mode 100644 internal/schema/schemas/v1/loop_judge.json create mode 100644 internal/schema/schemas/v1/loop_judge.yaml delete mode 100644 internal/schema/schemas/v1/pipeline_step.json create mode 100644 internal/schema/schemas/v1/pipeline_step.yaml delete mode 100644 internal/schema/schemas/v1/result_contract.json create mode 100644 internal/schema/schemas/v1/result_contract.yaml delete mode 100644 internal/schema/schemas/v1/stage.json create mode 100644 internal/schema/schemas/v1/stage.yaml delete mode 100644 internal/schema/schemas/v1/tool.json create mode 100644 internal/schema/schemas/v1/tool.yaml delete mode 100644 internal/schema/schemas/v1/workflow.json create mode 100644 internal/schema/schemas/v1/workflow.yaml diff --git a/internal/schema/schemas/v1/README.md b/internal/schema/schemas/v1/README.md index af0a041..f5431c0 100644 --- a/internal/schema/schemas/v1/README.md +++ b/internal/schema/schemas/v1/README.md @@ -1,21 +1,23 @@ # code-bee Workflow Schema v1 -三层 JSON Schema,描述可配置的蜂巢调度策略。 +三层 JSON Schema(draft-07),描述可配置的蜂巢调度策略。 + +> **文件格式说明**:schema 定义文件统一用 YAML 格式(`.yaml`),加载时转 JSON 喂给校验库。`$id` 和 `$ref` 中的 URL 保持 `.json` 后缀作为 URI 标识符(JSON Schema 标准约定)。 ## 层次结构 ``` -Layer 3: workflow.json (顶层) +Layer 3: workflow.yaml (顶层) │ -Layer 2: pipeline_step.json (编排原语:stage / parallel / loop 判别联合) - loop.json - loop_judge.json +Layer 2: pipeline_step.yaml (编排原语:stage / parallel / loop 判别联合) + loop.yaml + loop_judge.yaml │ -Layer 1: tool.json (基础类型) - stage.json - condition.json - exit_condition.json - result_contract.json +Layer 1: tool.yaml (基础类型) + stage.yaml + condition.yaml + exit_condition.yaml + result_contract.yaml ``` ## 关键设计 diff --git a/internal/schema/schemas/v1/condition.json b/internal/schema/schemas/v1/condition.json deleted file mode 100644 index 019a588..0000000 --- a/internal/schema/schemas/v1/condition.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/exit_condition.json deleted file mode 100644 index b1fe4a2..0000000 --- a/internal/schema/schemas/v1/exit_condition.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/loop.json deleted file mode 100644 index f6ba13d..0000000 --- a/internal/schema/schemas/v1/loop.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "$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.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.json b/internal/schema/schemas/v1/loop_judge.json deleted file mode 100644 index 50291a1..0000000 --- a/internal/schema/schemas/v1/loop_judge.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/pipeline_step.json deleted file mode 100644 index bf811b9..0000000 --- a/internal/schema/schemas/v1/pipeline_step.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/result_contract.json deleted file mode 100644 index e077092..0000000 --- a/internal/schema/schemas/v1/result_contract.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/stage.json deleted file mode 100644 index 6cab5f7..0000000 --- a/internal/schema/schemas/v1/stage.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/tool.json deleted file mode 100644 index 721fd40..0000000 --- a/internal/schema/schemas/v1/tool.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "$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/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.json b/internal/schema/schemas/v1/workflow.json deleted file mode 100644 index d4bc971..0000000 --- a/internal/schema/schemas/v1/workflow.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$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/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/types.go b/internal/schema/types.go index 36d2520..d7284ca 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -1,6 +1,6 @@ // Package schema 的类型定义文件。 // -// 本文件定义与 schemas/v1/*.json 一一对应的 Go 数据结构。 +// 本文件定义与 schemas/v1/*.yaml 一一对应的 Go 数据结构。 // 设计原则: // 1. 结构体字段顺序与 schema properties 顺序一致,便于对照 // 2. yaml tag 与 schema 字段名严格一致 diff --git a/internal/schema/validator.go b/internal/schema/validator.go index b4053fe..0aad03e 100644 --- a/internal/schema/validator.go +++ b/internal/schema/validator.go @@ -1,10 +1,14 @@ // Package schema 提供 code-bee workflow 配置的 JSON Schema 加载与校验。 // // 设计目标: -// 1. 把 schemas/v1/*.json 通过 go:embed 打包进二进制,避免外部文件依赖 +// 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 @@ -23,14 +27,16 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed schemas/v1/*.json +//go:embed schemas/v1/*.yaml var schemaFS embed.FS const ( // workflowSchemaPath 是顶层 schema 在 embed FS 中的路径。 - workflowSchemaPath = "schemas/v1/workflow.json" + workflowSchemaPath = "schemas/v1/workflow.yaml" // schemaIDBase 是所有 v1 schema 的 $id 前缀。 + // 注意:$id URL 保持 .json 后缀作为 URI 标识符(JSON Schema 标准约定), + // 实际文件用 .yaml 格式,加载时转 JSON 注册到 compiler。 schemaIDBase = "https://codebee.dev/schemas/v1/" ) @@ -46,8 +52,8 @@ type Validator struct { // NewValidator 创建并初始化一个 schema 校验器。 // // 核心逻辑: -// - 遍历 embed FS 中的 schemas/v1/*.json,逐个注册到 compiler -// - 编译 workflow.json 作为顶层入口 +// - 遍历 embed FS 中的 schemas/v1/*.yaml,逐个转 JSON 后注册到 compiler +// - 编译 workflow schema 作为顶层入口 func NewValidator() (*Validator, error) { c := jsonschema.NewCompiler() @@ -66,7 +72,8 @@ func NewValidator() (*Validator, error) { // loadEmbeddedSchemas 把 embed FS 中所有 v1 schema 文件注册到 compiler。 // // 关键点: -// - $id 是文件名(如 tool.json),$ref 通过 https://codebee.dev/schemas/v1/.json 引用 +// - 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") @@ -75,7 +82,7 @@ func loadEmbeddedSchemas(c *jsonschema.Compiler) error { } for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { continue } @@ -84,8 +91,21 @@ func loadEmbeddedSchemas(c *jsonschema.Compiler) error { return fmt.Errorf("read schema %s: %w", entry.Name(), readErr) } - url := schemaIDBase + entry.Name() - c.AddResource(url, bytes.NewReader(data)) + // 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" + c.AddResource(url, bytes.NewReader(jsonBytes)) } return nil @@ -95,7 +115,7 @@ func loadEmbeddedSchemas(c *jsonschema.Compiler) error { // // 设计说明: // - 用户配置文件统一用 YAML(可读性好、支持注释、与 workflow 生态一致) -// - 不接受 JSON 格式的用户配置;JSON 仅用于 schema 定义文件本身 +// - schema 定义文件也用 YAML,加载时统一转 JSON 喂给校验库 // // 输入参数: // - data: YAML 字节流 @@ -134,7 +154,6 @@ func (v *Validator) ValidateTool(data []byte) error { // // 设计说明: // - 用户配置文件统一用 YAML(可读性好、支持注释和多行字符串、与 K8s/GitHub Actions 生态一致) -// - JSON Schema 定义文件本身仍是 JSON,但那是 schema 标准格式,与用户配置无关 // - YAML 解出的数值类型与 jsonschema 期望不一致,需通过 JSON 往返统一类型 func decodeConfig(data []byte) (any, error) { trimmed := bytes.TrimSpace(data) From abcc923d70f0f1e7662be070934012e86c9eade1 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 13:11:12 +0800 Subject: [PATCH 03/13] feat(runtime): implement configurable workflow engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建 internal/runtime 包,实现基于 schema.Workflow 的可配置调度引擎。 核心组件: - Tool 接口: 统一 agent/command/function 三种工具类型 - AgentTool: 包装 agent.Runner,调用 AI 智能体 - CommandTool: 通过 exec.CommandContext 执行 shell 命令 - ToolRegistry: 支持 name + alias 查找 - ExecutionContext: stage 间数据传递 + loop 状态维护 - Expression: 结构化 ExitCondition 求值(6 operator)+ 字符串表达式 - StageExecutor: 串行执行(when/on_blocked/input_from) - ParallelExecutor: 并行执行(all/any/first_success 三种 join) - LoopExecutor: 循环执行(exit_when/judge/max_iterations/max_consecutive_unknown) - Engine: 顶层入口,消费 *schema.Workflow Prompt 模板外部化: - prompts/*.md: 5 个模板文件(coding/review/issue-handling/issue-post/loop-judge) - PromptBuilder: 从 embed FS 加载 text/template 渲染 测试覆盖: 76.9% (76 个测试用例) 全项目测试通过 Refs #11 --- .trae/documents/runtime-scheduler-design.md | 255 ++++++++++++ internal/runtime/agent_tool.go | 234 +++++++++++ internal/runtime/command_tool.go | 93 +++++ internal/runtime/context.go | 237 +++++++++++ internal/runtime/context_test.go | 231 +++++++++++ internal/runtime/doc.go | 17 + internal/runtime/engine.go | 155 ++++++++ internal/runtime/engine_test.go | 189 +++++++++ internal/runtime/executor.go | 39 ++ internal/runtime/expression.go | 359 +++++++++++++++++ internal/runtime/expression_test.go | 339 ++++++++++++++++ internal/runtime/function_tool.go | 44 +++ internal/runtime/loop_executor.go | 275 +++++++++++++ internal/runtime/loop_executor_test.go | 417 ++++++++++++++++++++ internal/runtime/parallel_executor.go | 173 ++++++++ internal/runtime/parallel_executor_test.go | 227 +++++++++++ internal/runtime/prompt_builder.go | 149 +++++++ internal/runtime/prompt_builder_test.go | 259 ++++++++++++ internal/runtime/prompts/coding.md | 56 +++ internal/runtime/prompts/issue-handling.md | 44 +++ internal/runtime/prompts/issue-post.md | 43 ++ internal/runtime/prompts/loop-judge.md | 46 +++ internal/runtime/prompts/review.md | 58 +++ internal/runtime/stage_executor.go | 138 +++++++ internal/runtime/stage_executor_test.go | 261 ++++++++++++ internal/runtime/tool.go | 154 ++++++++ internal/runtime/tool_registry.go | 115 ++++++ internal/runtime/tool_test.go | 396 +++++++++++++++++++ 28 files changed, 5003 insertions(+) create mode 100644 .trae/documents/runtime-scheduler-design.md create mode 100644 internal/runtime/agent_tool.go create mode 100644 internal/runtime/command_tool.go create mode 100644 internal/runtime/context.go create mode 100644 internal/runtime/context_test.go create mode 100644 internal/runtime/doc.go create mode 100644 internal/runtime/engine.go create mode 100644 internal/runtime/engine_test.go create mode 100644 internal/runtime/executor.go create mode 100644 internal/runtime/expression.go create mode 100644 internal/runtime/expression_test.go create mode 100644 internal/runtime/function_tool.go create mode 100644 internal/runtime/loop_executor.go create mode 100644 internal/runtime/loop_executor_test.go create mode 100644 internal/runtime/parallel_executor.go create mode 100644 internal/runtime/parallel_executor_test.go create mode 100644 internal/runtime/prompt_builder.go create mode 100644 internal/runtime/prompt_builder_test.go create mode 100644 internal/runtime/prompts/coding.md create mode 100644 internal/runtime/prompts/issue-handling.md create mode 100644 internal/runtime/prompts/issue-post.md create mode 100644 internal/runtime/prompts/loop-judge.md create mode 100644 internal/runtime/prompts/review.md create mode 100644 internal/runtime/stage_executor.go create mode 100644 internal/runtime/stage_executor_test.go create mode 100644 internal/runtime/tool.go create mode 100644 internal/runtime/tool_registry.go create mode 100644 internal/runtime/tool_test.go 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/internal/runtime/agent_tool.go b/internal/runtime/agent_tool.go new file mode 100644 index 0000000..9c1ef57 --- /dev/null +++ b/internal/runtime/agent_tool.go @@ -0,0 +1,234 @@ +// 本文件实现 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 + data.DefaultAgent = inv.Config.DefaultAgent + data.ReviewerAgent = inv.Config.ReviewerAgent + data.IssuePostAgent = inv.Config.IssuePostAgent + } + + 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: + // 从 issue-handling 拿 agent/summary/acceptance + if v, ok := inv.EC.Lookup("issue-handling", "agent"); ok { + if s, ok := v.(string); ok { + data.Agent = s + } + } + if v, ok := inv.EC.Lookup("issue-handling", "summary"); ok { + if s, ok := v.(string); ok { + data.IssueSummary = s + } + } + if v, ok := inv.EC.Lookup("issue-handling", "acceptance"); ok { + if s, ok := v.(string); ok { + data.Acceptance = s + } + } + + case promptReview: + // 从 issue-handling 拿 summary/acceptance + if v, ok := inv.EC.Lookup("issue-handling", "summary"); ok { + if s, ok := v.(string); ok { + data.IssueSummary = s + } + } + if v, ok := inv.EC.Lookup("issue-handling", "acceptance"); ok { + if s, ok := v.(string); ok { + data.Acceptance = s + } + } + // 从 coding 拿 summary/evidence/acceptance_check + if v, ok := inv.EC.Lookup("coding", "summary"); ok { + if s, ok := v.(string); ok { + data.CodingSummary = s + } + } + if v, ok := inv.EC.Lookup("coding", "evidence"); ok { + if s, ok := v.(string); ok { + data.CodingEvidence = s + } + } + if v, ok := inv.EC.Lookup("coding", "acceptance_check"); ok { + if s, ok := v.(string); ok { + data.CodingAcceptanceCheck = s + } + } + + case promptIssuePost: + // source file path 从 input_from stage 的结果文件获取 + if inv.EC.Artifacts != nil { + sourceStage, _ := getStringArg(inv.Args, "source_stage") + if sourceStage == "" { + // 默认从 input_from 字段推断 + sourceStage = "" + } + if sourceStage != "" { + data.SourceFilePath = inv.EC.Artifacts.ResolveResultFile(sourceStage, inv.Args, 0) + } + } + if v, ok := getStringArg(inv.Args, "purpose"); ok { + data.Purpose = v + } + + case promptLoopJudge: + // history file path 从 artifacts 获取 + if inv.EC.Artifacts != nil { + data.HistoryFilePath = inv.EC.Artifacts.LoopHistoryPath() + } + } + } + + return data +} + +// 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 "" +} + +// 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/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..2c15c06 --- /dev/null +++ b/internal/runtime/engine.go @@ -0,0 +1,155 @@ +// 本文件实现 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 +} + +// NewEngine 创建 Engine 实例。 +// +// 构造逻辑: +// 1. 加载 PromptBuilder(内置模板) +// 2. 根据 workflow.Tools 构建 ToolRegistry +// 3. 创建三种 Executor,通过 dispatch 函数支持嵌套 +func NewEngine(wf *schema.Workflow, runner Runner) (*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") + } + + pb, err := NewPromptBuilder() + 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..616a571 --- /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..a9375d7 --- /dev/null +++ b/internal/runtime/expression.go @@ -0,0 +1,359 @@ +// 本文件实现条件表达式求值,支持 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": + 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(cond.Value) + 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 + } + + return false, fmt.Errorf("unknown operator: %s", cond.Operator) +} + +// 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..bbd4411 --- /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..2fe158c --- /dev/null +++ b/internal/runtime/loop_executor.go @@ -0,0 +1,275 @@ +// 本文件实现 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 + 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 + // blocked 处理 + if r.Blocked && bodyStep.Stage != nil { + onBlocked := bodyStep.Stage.OnBlocked + if onBlocked == "" { + onBlocked = "stop" + } + if onBlocked == "stop" { + return r, nil + } + } + } + + // 每步后检查 exit_when + 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 + } + } + } + + // 4. 维护 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), + }, nil + } + + // 5. 执行 judge + if loop.Judge != nil && round >= judgeStartRound(loop.Judge) { + judgeResult, err := e.runJudge(ctx, ec, loop.Judge, loopState) + if err != nil { + return judgeResult, err + } + if judgeResult != nil { + decision := getDecision(judgeResult) + action := mapJudgeDecision(decision, loop.Judge.OnDecision) + + switch action { + case "exit": + return &StepResult{ + Success: true, + ManualRequired: decision == "STOP_MANUAL", + Blocked: decision == "STOP_BLOCKED", + StageName: "loop", + Output: judgeResult.Output, + Data: judgeResult.Data, + }, nil + case "continue": + if decision == "SHRINK_TASK" { + fb := buildShrinkFeedback(judgeResult) + if fb != "" { + loopState.LastFeedback = fb + "\n\n" + loopState.LastFeedback + } + } + } + } + } + + // 6. 更新 LastFeedback(用最后一个 stage 的 next_action 作为下一轮反馈) + if lastResult != nil { + fb := buildFeedbackFromResult(lastResult) + if fb != "" { + loopState.LastFeedback = fb + } + } + } + + // 跑完 maxIterations 仍无退出 + return &StepResult{ + Success: true, + ManualRequired: true, + StageName: "loop", + Output: fmt.Sprintf("loop %s: max iterations (%d) reached", loop.ID, loopState.MaxIterations), + }, nil +} + +// runJudge 执行价值评估员。 +// +// 通过 dispatch 执行,便于测试 mock。dispatch 会路由到 StageExecutor, +// StageExecutor 负责解析 tool、计算路径、调用 tool.Execute。 +func (e *LoopExecutor) runJudge(ctx context.Context, ec *ExecutionContext, judge *schema.LoopJudge, loopState *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..e4e2bf4 --- /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..55b925b --- /dev/null +++ b/internal/runtime/parallel_executor.go @@ -0,0 +1,173 @@ +// 本文件实现 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, + base 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 + // all 模式:所有 status 都 PASS 才 PASS + // any/first_success:取首个成功的 status + if join == "all" { + allPass := true + for _, r := range results { + if r == nil || r.Status != "PASS" { + if r != nil && r.Status != "" && r.Status != "PASS" { + allPass = false + break + } + } + } + if allPass && len(results) > 0 { + agg.Status = "PASS" + } + } else if firstSuccessIdx >= 0 && results[firstSuccessIdx] != nil { + agg.Status = results[firstSuccessIdx].Status + } else if len(successBranches) > 0 && results[successBranches[0]] != nil { + agg.Status = results[successBranches[0]].Status + } + + return agg, nil +} 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..d424271 --- /dev/null +++ b/internal/runtime/prompt_builder.go @@ -0,0 +1,149 @@ +// 本文件实现 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" + "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 模板。 +func NewPromptBuilder() (*PromptBuilder, error) { + templates := make(map[string]*template.Template) + + 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 + } + + return &PromptBuilder{templates: templates}, 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..b490292 --- /dev/null +++ b/internal/runtime/prompt_builder_test.go @@ -0,0 +1,259 @@ +package runtime + +import ( + "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) + } + } +} 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..8e01c96 --- /dev/null +++ b/internal/runtime/stage_executor.go @@ -0,0 +1,138 @@ +// 本文件实现 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 + if result.Blocked { + onBlocked := stage.OnBlocked + if onBlocked == "" { + onBlocked = "stop" + } + switch onBlocked { + case "skip": + result.Skipped = true + // 继续存储并返回 + case "continue": + // 继续存储并返回 + case "stop": + // 默认行为:存储后返回,上层 Engine 会终止 + default: + // 未知值默认 stop + } + } + + // 10. 存储结果 + ec.SetResult(stage.Name, result) + + return result, nil +} diff --git a/internal/runtime/stage_executor_test.go b/internal/runtime/stage_executor_test.go new file mode 100644 index 0000000..c84d0d2 --- /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..7873ff9 --- /dev/null +++ b/internal/runtime/tool_test.go @@ -0,0 +1,396 @@ +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 +} + +func TestAgentTool_Execute_IssueHandling(t *testing.T) { + tool, runner, _ := newTestAgentTool(t, promptIssueHandling) + + ec := NewExecutionContext(nil, &fakeArtifacts{}, PlatformContext{ + WorkerID: "w1", + IssueURL: "https://github.com/o/r/issues/1", + PlatformName: "github", + PlatformGuide: "guide", + }, &config.Config{Repo: "o/r", IssueNumber: 1, DefaultAgent: "开发者"}) + + 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 + tool, 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) + } + } +} + +// 确保 fakeArtifacts 满足 ArtifactResolver 接口 +var _ ArtifactResolver = (*fakeArtifacts)(nil) From 7d34eb954b02ee44501842b80145d29b2bed77cf Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 14:12:56 +0800 Subject: [PATCH 04/13] refactor(pipeline): replace hardcoded dispatch with runtime.Engine Stage 8 of scenario-driven scheduling refactor. - dispatch.go: 624 -> 152 lines; Service now holds *runtime.Engine and *schema.Workflow, Dispatch delegates to engine.Run - delete prompts.go; 5 prompt templates migrated to internal/runtime/prompts/*.md (rendered by prompt_builder.go) - add runtime_adapter.go: ArtifactResolverAdapter bridges pipeline.ArtifactSet <-> runtime.ArtifactResolver, routing LoadResult by stage name to typed loaders - add runtime/default_workflow.{go,yaml}: go:embed built-in default workflow; --workflow flag supports external override - cmd/worker/main.go: serviceFactory signature returns error to surface workflow load failures; --workflow flag wired through buildService - tests: add TestToPipelineResult + 2 end-to-end integration tests (BlockedPath, CompletedPath); drop 9 dispatch state-machine tests whose behavior is now covered by internal/runtime engine tests - .gitignore: anchor 'worker' pattern to repo root ('/worker') so cmd/worker/ is no longer ignored; add .trae/ Known gap: issue-post-blocked stage removed -- Engine terminates on Blocked, so the stage could never run. issue-handling BLOCKED now returns Blocked directly without posting a blocking comment. Future Engine enhancement (on_blocked: continue) can restore this. Verification: go build/vet/test all pass; runtime 76.1%, pipeline 70.3% coverage. Refs: #11 --- .gitignore | 4 +- cmd/worker/main.go | 92 +++- cmd/worker/main_test.go | 42 +- internal/pipeline/coverage_test.go | 347 +----------- internal/pipeline/dispatch.go | 620 +++------------------- internal/pipeline/dispatch_test.go | 197 ++++++- internal/pipeline/loop_judge_test.go | 274 +--------- internal/pipeline/prompts.go | 421 --------------- internal/pipeline/runtime_adapter.go | 168 ++++++ internal/pipeline/runtime_adapter_test.go | 169 ++++++ internal/runtime/default_workflow.go | 40 ++ internal/runtime/default_workflow.yaml | 138 +++++ 12 files changed, 908 insertions(+), 1604 deletions(-) delete mode 100644 internal/pipeline/prompts.go create mode 100644 internal/pipeline/runtime_adapter.go create mode 100644 internal/pipeline/runtime_adapter_test.go create mode 100644 internal/runtime/default_workflow.go create mode 100644 internal/runtime/default_workflow.yaml 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/cmd/worker/main.go b/cmd/worker/main.go index e4c0fd0..f6640f9 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,79 @@ 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。 +func buildService(args []string) (*pipeline.Service, error) { + workflowPath := parseWorkflowFlag(args) + + wf, err := loadWorkflow(workflowPath) + if err != nil { + return nil, fmt.Errorf("加载 workflow 失败: %w", err) + } + + service, err := pipeline.NewService( + platformgithub.NewClient(), + agent.New(), + wf, + ) + if err != nil { + return nil, fmt.Errorf("创建调度服务失败: %w", err) + } + + return service, nil +} + +// parseWorkflowFlag 从参数中解析 --workflow flag,不解析其他 flag。 +// +// 单独解析是为了避免与 runCLI 的 flag.Parse 冲突(仅提取 workflow 路径)。 +func parseWorkflowFlag(args []string) string { + fs := flag.NewFlagSet("workflow-probe", flag.ContinueOnError) + fs.SetOutput(io.Discard) + workflowPath := fs.String("workflow", "", "workflow 配置文件路径(留空则使用内置默认)") + _ = fs.Parse(args) + return *workflowPath +} + +// 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 ]\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") return 1 } @@ -61,13 +113,15 @@ 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 flag 仅用于文档展示,实际解析在 buildService 中完成 + _ = fs.String("workflow", "", "workflow 配置文件路径(留空则使用内置默认)") if err := fs.Parse(args); err != nil { return usageError(stderr) @@ -88,11 +142,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..cfbfcf1 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 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..a923fc2 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,99 @@ 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 配置,驱动整个调度流程 +// +// 返回值: +// - *Service: 可用于 Dispatch 的服务实例 +// - error: 当 runtime.Engine 构造失败时返回 +func NewService(platformClient platform.Client, runner Runner, wf *schema.Workflow) (*Service, error) { + engine, err := runtime.NewEngine(wf, runner) + 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 +136,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 +150,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..afe82c0 100644 --- a/internal/pipeline/dispatch_test.go +++ b/internal/pipeline/dispatch_test.go @@ -3,16 +3,22 @@ // 核心功能: // 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" "testing" + + "github.com/JiGuangWorker/code-bee/internal/agent" + "github.com/JiGuangWorker/code-bee/internal/config" + "github.com/JiGuangWorker/code-bee/internal/runtime" ) const testFilePermission = 0o600 @@ -112,3 +118,192 @@ 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) + } + }) +} diff --git a/internal/pipeline/loop_judge_test.go b/internal/pipeline/loop_judge_test.go index 2a7ea16..71f6248 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 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..a6223bd --- /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/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 From ea0070e8c68c58b4fae1114a8e9a82e6917dbfd3 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 15:06:35 +0800 Subject: [PATCH 05/13] refactor(config): externalize agent roles to workflow tools Complete Issue #11 verification standard: agent role definitions are now loaded from workflow config, no longer hardcoded in BuiltinAgents(). - delete Config fields: DefaultAgent, ReviewerAgent, IssuePostAgent, LoopJudgeAgent, MaxCodingReviewRounds, LoopJudgeStartRound (last 3 were dead code; first 3 now derived from workflow) - delete BuiltinAgents(), AgentByName(), AgentNames(), AgentRole struct (dead code: only config_test.go self-tested them) - delete defaultMaxCodingReviewRounds / defaultLoopJudgeStartRound consts - add runtime.lookupDisplayNameByPromptTemplate: derives display name from workflow.Tools by prompt_template, with fallback chain display_name > aliases[0] > name > empty - modify agent_tool.buildPromptData: derive DefaultAgent/ReviewerAgent/ IssuePostAgent from EC.Workflow instead of Config - Config now holds only Repo/IssueNumber/GitHubToken (3 runtime params) - update tool_test.go: add testWorkflow helper, pass workflow to IssueHandling test so prompt derivation works - add TestLookupDisplayNameByPromptTemplate (5 sub-cases) - update config_test.go: drop BuiltinAgents tests, simplify defaults test Backward compatible: default workflow's display_names match the old hardcoded values (developer/QA lead/PM), so prompts render identically without --workflow flag. Verification: go build/vet/test pass; runtime 76.5%, config 100% coverage. Refs: #11 --- internal/config/config.go | 101 +++++---------------------------- internal/config/config_test.go | 80 +++----------------------- internal/runtime/agent_tool.go | 37 +++++++++++- internal/runtime/tool_test.go | 82 +++++++++++++++++++++++++- 4 files changed, 135 insertions(+), 165 deletions(-) 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/runtime/agent_tool.go b/internal/runtime/agent_tool.go index 9c1ef57..6dcee10 100644 --- a/internal/runtime/agent_tool.go +++ b/internal/runtime/agent_tool.go @@ -118,9 +118,15 @@ func (t *AgentTool) buildPromptData(inv Invocation) PromptData { if inv.Config != nil { data.Repo = inv.Config.Repo data.IssueNumber = inv.Config.IssueNumber - data.DefaultAgent = inv.Config.DefaultAgent - data.ReviewerAgent = inv.Config.ReviewerAgent - data.IssuePostAgent = inv.Config.IssuePostAgent + } + // 角色展示名从 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 { @@ -220,6 +226,31 @@ func extractStatus(data map[string]any) string { 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 { diff --git a/internal/runtime/tool_test.go b/internal/runtime/tool_test.go index 7873ff9..592b2a5 100644 --- a/internal/runtime/tool_test.go +++ b/internal/runtime/tool_test.go @@ -68,15 +68,27 @@ func newTestAgentTool(t *testing.T, template string) (*AgentTool, *scriptedRunne 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(nil, &fakeArtifacts{}, PlatformContext{ + 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, DefaultAgent: "开发者"}) + }, &config.Config{Repo: "o/r", IssueNumber: 1}) inv := Invocation{ StageName: "issue-handling", @@ -392,5 +404,71 @@ func TestExtractStatus(t *testing.T) { } } +// 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) From 21e12999b4b5295ada25df7da24e232205b725c1 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 15:45:38 +0800 Subject: [PATCH 06/13] docs: update README for scenario-driven workflow architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Issue #11 documentation standard: explain how to customize scheduling strategy via workflow config. - update 'why code-bee' bullets: replace '极简内核' with '场景驱动调度' - update tech stack table: scheduler now describes workflow-driven orchestration - add architecture overview with ASCII pipeline diagram - update usage example: add --workflow flag, fix output message - update project structure: add internal/runtime/ and internal/schema/, refresh package descriptions - add '自定义 Workflow' section: config structure, 3编排原语, 3 tool types, role name derivation, links to schema/default-workflow/prompts - update roadmap: mark scenario-driven scheduling, composable pipeline, tool aliases as completed; add on_blocked recovery item Refs: #11 --- README.md | 133 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c0010d6..6366ac3 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,11 +27,25 @@ |------|------|------| | 🧠 编码智能体 | [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 通过 **workflow 配置**驱动整个调度流程:读取 Issue → 判断接单/阻塞 → 编码-审查循环 → 提交评论。角色体系和管线流程完全可配置,内置默认 workflow 开箱即用。 + +``` +Issue @agent → runtime.Engine 遍历 workflow.Pipeline + │ + ├─ stage: issue-handling (判断 READY/BLOCKED) + ├─ stage: issue-post-intake (when: READY → 提交接单评论) + └─ loop: coding-review (max_iterations: 3) + ├─ stage: coding + ├─ stage: review + ├─ stage: issue-post-completion (when: PASS → 提交完成评论) + └─ judge: loop-judge (价值评估,决定 continue/stop) +``` + ![四阶段架构](https://raw.githubusercontent.com/JiGuangWorker/code-bee/main/docs/images/readme/02-architecture.png) --- @@ -74,7 +88,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 +101,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 +112,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 一起建设!每个想法都值得被讨论。 From ebbf77739ffab4f1b4683a679da10dd5aed36a75 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 19:21:23 +0800 Subject: [PATCH 07/13] feat(runtime): support --prompts-dir for external prompt templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PromptBuilder 加载外部目录 .md 模板,overlay 同名内置模板 - Engine 通过 Functional Options 接收 WithPromptsDir - Service 透传 EngineOption,CLI 新增 --prompts-dir flag - 新增 overlay 单元测试 + 自定义 workflow 端到端集成测试 Closes #11 (prompts externalization gap) --- cmd/worker/main.go | 25 +++++++- internal/pipeline/dispatch.go | 5 +- internal/pipeline/dispatch_test.go | 81 +++++++++++++++++++++++++ internal/runtime/engine.go | 33 ++++++++-- internal/runtime/prompt_builder.go | 50 ++++++++++++++- internal/runtime/prompt_builder_test.go | 78 +++++++++++++++++++++--- internal/runtime/stage_executor_test.go | 6 +- internal/runtime/tool_test.go | 12 ++-- 8 files changed, 263 insertions(+), 27 deletions(-) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index f6640f9..6624604 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -51,18 +51,26 @@ func main() { // buildService 构造真实的 pipeline.Service,包含 workflow 加载。 // // workflowPath 为空时使用内置默认 workflow。 +// promptsDir 为空时只用内置 prompt 模板。 func buildService(args []string) (*pipeline.Service, error) { workflowPath := parseWorkflowFlag(args) + promptsDir := parsePromptsDirFlag(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) @@ -82,6 +90,17 @@ func parseWorkflowFlag(args []string) string { return *workflowPath } +// parsePromptsDirFlag 从参数中解析 --prompts-dir flag,不解析其他 flag。 +// +// 单独解析是为了避免与 runCLI 的 flag.Parse 冲突(仅提取 prompts 目录路径)。 +func parsePromptsDirFlag(args []string) string { + fs := flag.NewFlagSet("prompts-dir-probe", flag.ContinueOnError) + fs.SetOutput(io.Discard) + promptsDir := fs.String("prompts-dir", "", "外部 prompt 模板目录(overlay 内置模板,留空则只用内置)") + _ = fs.Parse(args) + return *promptsDir +} + // loadWorkflow 加载 workflow 配置。 // // 加载策略: @@ -101,10 +120,11 @@ func loadWorkflow(path string) (*schema.Workflow, error) { // usageError 当参数缺失或解析失败时,向 stderr 输出统一的使用说明。 func usageError(stderr io.Writer) int { - fmt.Fprintf(stderr, "用法: code-bee --repo --issue [--workflow ]\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 } @@ -120,8 +140,9 @@ func runCLI(args []string, stdout, stderr io.Writer, factory serviceFactory) int repo := fs.String("repo", "", "仓库地址,如 owner/repo") issueNumber := fs.Int("issue", 0, "Issue 编号") showVersion := fs.Bool("version", false, "输出版本信息") - // --workflow flag 仅用于文档展示,实际解析在 buildService 中完成 + // --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) diff --git a/internal/pipeline/dispatch.go b/internal/pipeline/dispatch.go index a923fc2..69146c9 100644 --- a/internal/pipeline/dispatch.go +++ b/internal/pipeline/dispatch.go @@ -52,12 +52,13 @@ type Service struct { // - 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) (*Service, error) { - engine, err := runtime.NewEngine(wf, runner) +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) } diff --git a/internal/pipeline/dispatch_test.go b/internal/pipeline/dispatch_test.go index afe82c0..1c78092 100644 --- a/internal/pipeline/dispatch_test.go +++ b/internal/pipeline/dispatch_test.go @@ -14,11 +14,13 @@ 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 @@ -307,3 +309,82 @@ func TestServiceDispatchCompletedPath(t *testing.T) { } }) } + +// 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/runtime/engine.go b/internal/runtime/engine.go index 2c15c06..2600a12 100644 --- a/internal/runtime/engine.go +++ b/internal/runtime/engine.go @@ -28,13 +28,31 @@ type Engine struct { 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. 加载 PromptBuilder(内置模板) -// 2. 根据 workflow.Tools 构建 ToolRegistry -// 3. 创建三种 Executor,通过 dispatch 函数支持嵌套 -func NewEngine(wf *schema.Workflow, runner Runner) (*Engine, error) { +// 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") } @@ -42,7 +60,12 @@ func NewEngine(wf *schema.Workflow, runner Runner) (*Engine, error) { return nil, fmt.Errorf("runtime.NewEngine: runner is nil") } - pb, err := NewPromptBuilder() + cfg := &engineConfig{} + for _, opt := range opts { + opt(cfg) + } + + pb, err := NewPromptBuilder(cfg.promptsDir) if err != nil { return nil, fmt.Errorf("runtime.NewEngine: %w", err) } diff --git a/internal/runtime/prompt_builder.go b/internal/runtime/prompt_builder.go index d424271..d77d8d9 100644 --- a/internal/runtime/prompt_builder.go +++ b/internal/runtime/prompt_builder.go @@ -14,6 +14,9 @@ import ( "bytes" "embed" "fmt" + "os" + "path/filepath" + "strings" "text/template" ) @@ -83,10 +86,17 @@ type PromptBuilder struct { templates map[string]*template.Template } -// NewPromptBuilder 加载所有内置 prompt 模板。 -func NewPromptBuilder() (*PromptBuilder, error) { +// 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, @@ -109,9 +119,45 @@ func NewPromptBuilder() (*PromptBuilder, error) { 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] diff --git a/internal/runtime/prompt_builder_test.go b/internal/runtime/prompt_builder_test.go index b490292..2cffe8d 100644 --- a/internal/runtime/prompt_builder_test.go +++ b/internal/runtime/prompt_builder_test.go @@ -1,12 +1,14 @@ package runtime import ( + "os" + "path/filepath" "strings" "testing" ) func TestNewPromptBuilder_LoadsAllTemplates(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -19,7 +21,7 @@ func TestNewPromptBuilder_LoadsAllTemplates(t *testing.T) { } func TestPromptBuilder_Build_IssueHandling(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -60,7 +62,7 @@ func TestPromptBuilder_Build_IssueHandling(t *testing.T) { } func TestPromptBuilder_Build_Coding(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -102,7 +104,7 @@ func TestPromptBuilder_Build_Coding(t *testing.T) { } func TestPromptBuilder_Build_Review(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -148,7 +150,7 @@ func TestPromptBuilder_Build_Review(t *testing.T) { } func TestPromptBuilder_Build_IssuePost(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -187,7 +189,7 @@ func TestPromptBuilder_Build_IssuePost(t *testing.T) { } func TestPromptBuilder_Build_LoopJudge(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -223,7 +225,7 @@ func TestPromptBuilder_Build_LoopJudge(t *testing.T) { } func TestPromptBuilder_Build_UnknownTemplate(t *testing.T) { - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder() error: %v", err) } @@ -257,3 +259,65 @@ func TestMapTemplateToKind(t *testing.T) { } } } + +// 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/stage_executor_test.go b/internal/runtime/stage_executor_test.go index c84d0d2..6db8826 100644 --- a/internal/runtime/stage_executor_test.go +++ b/internal/runtime/stage_executor_test.go @@ -11,7 +11,7 @@ import ( func newStageExecutorForTest(t *testing.T, template string) (*StageExecutor, *scriptedRunner, *ExecutionContext) { t.Helper() - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") runner := &scriptedRunner{output: "ok"} wf := &schema.Workflow{ Tools: []schema.Tool{ @@ -112,7 +112,7 @@ func TestStageExecutor_Execute_WhenMatched(t *testing.T) { } func TestStageExecutor_Execute_ToolNotFound(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") wf := &schema.Workflow{Tools: []schema.Tool{}} registry, _ := NewToolRegistry(wf, &scriptedRunner{}, pb) ec := NewExecutionContext(wf, &fakeArtifacts{}, PlatformContext{}, &config.Config{}) @@ -205,7 +205,7 @@ func TestStageExecutor_Execute_OnBlockedDefault(t *testing.T) { } func TestStageExecutor_Execute_RunnerError(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") runner := &scriptedRunner{err: errors.New("runner failed")} wf := &schema.Workflow{ Tools: []schema.Tool{ diff --git a/internal/runtime/tool_test.go b/internal/runtime/tool_test.go index 592b2a5..ffbdaf1 100644 --- a/internal/runtime/tool_test.go +++ b/internal/runtime/tool_test.go @@ -51,7 +51,7 @@ func (f *fakeArtifacts) LoopHistoryPath() string { return "/tmp/loop_history.jso func newTestAgentTool(t *testing.T, template string) (*AgentTool, *scriptedRunner, *PromptBuilder) { t.Helper() - pb, err := NewPromptBuilder() + pb, err := NewPromptBuilder("") if err != nil { t.Fatalf("NewPromptBuilder: %v", err) } @@ -195,7 +195,7 @@ func TestAgentTool_Execute_Blocked(t *testing.T) { } func TestAgentTool_Execute_RunnerError(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") runner := &scriptedRunner{err: errors.New("runner failed")} def := &schema.Tool{Name: "bad", Type: "agent", PromptTemplate: promptCoding} tool, _ := NewAgentTool(def, runner, pb) @@ -217,7 +217,7 @@ func TestAgentTool_Execute_RunnerError(t *testing.T) { } func TestNewAgentTool_Validation(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") runner := &scriptedRunner{} cases := []struct { @@ -297,7 +297,7 @@ func TestNewCommandTool_Validation(t *testing.T) { } func TestToolRegistry_BuildAndResolve(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") runner := &scriptedRunner{} wf := &schema.Workflow{ @@ -347,7 +347,7 @@ func TestToolRegistry_BuildAndResolve(t *testing.T) { } func TestToolRegistry_DuplicateName(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") wf := &schema.Workflow{ Tools: []schema.Tool{ {Name: "dup", Type: "command", Run: "echo 1"}, @@ -361,7 +361,7 @@ func TestToolRegistry_DuplicateName(t *testing.T) { } func TestToolRegistry_AliasConflict(t *testing.T) { - pb, _ := NewPromptBuilder() + pb, _ := NewPromptBuilder("") wf := &schema.Workflow{ Tools: []schema.Tool{ {Name: "a", Type: "command", Run: "echo 1", Aliases: []string{"shared"}}, From 5c9421964141e343ab14a3054a2922c62c2e347d Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 22:25:27 +0800 Subject: [PATCH 08/13] fix(cli): parse --workflow/--prompts-dir when --repo precedes them Two-stage flag parsing had a bug: parseWorkflowFlag and parsePromptsDirFlag each registered only a single flag in their own FlagSet. Go's flag package stops parsing at the first unregistered flag, so when --repo (registered only in runCLI's FlagSet) appeared before --workflow/--prompts-dir, the probe functions returned empty strings and the flags were silently ignored (falling back to builtin workflow / builtin prompts). Discovered via smoke test: --prompts-dir /nonexistent/prompts did not error out, and the binary proceeded to dispatch with builtin prompts. Fix: merge the two probe functions into parseExtraFlags, which registers all known flags (--repo/--issue/--version as placeholders) so parsing proceeds past them. Add table-driven regression test covering 6 arg orderings including the previously broken 'flags after --repo' case. The same bug affected --workflow since its introduction; this commit fixes both. --- cmd/worker/main.go | 34 +++++++++++----------- cmd/worker/main_test.go | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 18 deletions(-) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 6624604..80de54d 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -53,8 +53,7 @@ func main() { // workflowPath 为空时使用内置默认 workflow。 // promptsDir 为空时只用内置 prompt 模板。 func buildService(args []string) (*pipeline.Service, error) { - workflowPath := parseWorkflowFlag(args) - promptsDir := parsePromptsDirFlag(args) + workflowPath, promptsDir := parseExtraFlags(args) wf, err := loadWorkflow(workflowPath) if err != nil { @@ -79,26 +78,25 @@ func buildService(args []string) (*pipeline.Service, error) { return service, nil } -// parseWorkflowFlag 从参数中解析 --workflow flag,不解析其他 flag。 +// parseExtraFlags 从参数中解析 --workflow 和 --prompts-dir 两个 flag。 // -// 单独解析是为了避免与 runCLI 的 flag.Parse 冲突(仅提取 workflow 路径)。 -func parseWorkflowFlag(args []string) string { - fs := flag.NewFlagSet("workflow-probe", flag.ContinueOnError) - fs.SetOutput(io.Discard) - workflowPath := fs.String("workflow", "", "workflow 配置文件路径(留空则使用内置默认)") - _ = fs.Parse(args) - return *workflowPath -} - -// parsePromptsDirFlag 从参数中解析 --prompts-dir flag,不解析其他 flag。 +// 单独解析(而非复用 runCLI 的 FlagSet)是因为 buildService 在 factory() 调用时 +// 执行,而 factory 在 runCLI 的 fs.Parse 之后才被调用,无法拿到已解析的值。 // -// 单独解析是为了避免与 runCLI 的 flag.Parse 冲突(仅提取 prompts 目录路径)。 -func parsePromptsDirFlag(args []string) string { - fs := flag.NewFlagSet("prompts-dir-probe", flag.ContinueOnError) +// 必须注册所有 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) - promptsDir := fs.String("prompts-dir", "", "外部 prompt 模板目录(overlay 内置模板,留空则只用内置)") + 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) - return *promptsDir + return *workflow, *prompts } // loadWorkflow 加载 workflow 配置。 diff --git a/cmd/worker/main_test.go b/cmd/worker/main_test.go index cfbfcf1..e17d8c3 100644 --- a/cmd/worker/main_test.go +++ b/cmd/worker/main_test.go @@ -178,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) + } + }) + } +} From 820e5c0736d82d590ed98a8ed4a8b0ecc5eae3e9 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 22:36:30 +0800 Subject: [PATCH 09/13] docs(README): redraw architecture diagrams with Mermaid at implementation level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single ASCII tree diagram with three Mermaid diagrams that show how the architecture is actually implemented: 1. Layered component architecture (flowchart TB) — CLI/pipeline/runtime/ schema four layers with all key structs (Engine, ToolRegistry, StageExecutor, AgentTool, PromptBuilder, ExecutionContext, ArtifactResolverAdapter, etc.) and interface-implementation edges (ArtifactResolver <- Adapter, Runner <- agent.Runner). 2. Dispatch call chain (sequenceDiagram) — full call timeline from runCLI -> Service.Dispatch -> Engine.Run -> StageExecutor -> AgentTool -> runner.Run, including result-file reset/read/write timing and early-termination semantics. 3. Config loading & data flow (flowchart LR) — workflow dual-source (go:embed builtin + --workflow override) and prompt overlay (--prompts-dir covers same-name builtin), plus result-file flow between stages via ExecutionContext. Add an Engine termination semantics table documenting how Blocked/ManualRequired/Completed trigger early return, with a note on the known issue-post-blocked gap. --- README.md | 212 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 201 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6366ac3..9b82b1a 100644 --- a/README.md +++ b/README.md @@ -32,21 +32,211 @@ ### 架构一览 -code-bee 通过 **workflow 配置**驱动整个调度流程:读取 Issue → 判断接单/阻塞 → 编码-审查循环 → 提交评论。角色体系和管线流程完全可配置,内置默认 workflow 开箱即用。 +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 ``` -Issue @agent → runtime.Engine 遍历 workflow.Pipeline - │ - ├─ stage: issue-handling (判断 READY/BLOCKED) - ├─ stage: issue-post-intake (when: READY → 提交接单评论) - └─ loop: coding-review (max_iterations: 3) - ├─ stage: coding - ├─ stage: review - ├─ stage: issue-post-completion (when: PASS → 提交完成评论) - └─ judge: loop-judge (价值评估,决定 continue/stop) + +**关键时序点**: +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 ``` -![四阶段架构](https://raw.githubusercontent.com/JiGuangWorker/code-bee/main/docs/images/readme/02-architecture.png) +**配置加载语义**: +- **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` 增强恢复该阶段。 --- From 04880fbcd55c5ac49b9b23e434d9bdf2150292d9 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 22:44:04 +0800 Subject: [PATCH 10/13] chore: sync go.mod/go.sum via go mod tidy --- go.mod | 4 ++-- go.sum | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 883a212..5b60592 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module github.com/JiGuangWorker/code-bee go 1.23.0 require ( - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + 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 ae056b7..70d82da 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +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= From 4e1f815071225454d31cf9cc7affc1bc8fa2c9ba Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 22:50:43 +0800 Subject: [PATCH 11/13] fix(lint): resolve goimports formatting and staticcheck unused variable - internal/runtime/expression_test.go: goimports formatting - internal/runtime/loop_executor_test.go: goimports formatting - internal/schema/loader.go: goimports formatting - internal/runtime/tool_test.go: SA4006 assign of tool is never used Found by CI golangci-lint. --- internal/runtime/expression_test.go | 6 +++--- internal/runtime/loop_executor_test.go | 2 +- internal/runtime/tool_test.go | 2 +- internal/schema/loader.go | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/runtime/expression_test.go b/internal/runtime/expression_test.go index bbd4411..75acd35 100644 --- a/internal/runtime/expression_test.go +++ b/internal/runtime/expression_test.go @@ -10,9 +10,9 @@ 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"}, + "status": "PASS", + "summary": "all good", + "tags": []any{"bug", "critical"}, "check_result": map[string]any{ "passed": true, "count": 42, diff --git a/internal/runtime/loop_executor_test.go b/internal/runtime/loop_executor_test.go index e4e2bf4..6927a3c 100644 --- a/internal/runtime/loop_executor_test.go +++ b/internal/runtime/loop_executor_test.go @@ -393,7 +393,7 @@ func TestMapJudgeDecision(t *testing.T) { // 自定义映射 custom := map[string]string{ - "CONTINUE": "exit", // 覆盖默认 + "CONTINUE": "exit", // 覆盖默认 "STOP_MANUAL": "continue", // 覆盖默认 } if got := mapJudgeDecision("CONTINUE", custom); got != "exit" { diff --git a/internal/runtime/tool_test.go b/internal/runtime/tool_test.go index ffbdaf1..5edf924 100644 --- a/internal/runtime/tool_test.go +++ b/internal/runtime/tool_test.go @@ -334,7 +334,7 @@ func TestToolRegistry_BuildAndResolve(t *testing.T) { } // 查 command - tool, ok = registry.Resolve("lint") + _, ok = registry.Resolve("lint") if !ok { t.Fatal("expected to resolve lint") } diff --git a/internal/schema/loader.go b/internal/schema/loader.go index 133a849..8a24be8 100644 --- a/internal/schema/loader.go +++ b/internal/schema/loader.go @@ -105,9 +105,9 @@ func (w *Workflow) FindTool(ref string) *Tool { // toolRegistry 是 name→Tool 的索引,O(1) 查找。 type toolRegistry struct { - byName map[string]*Tool - byAlias map[string]*Tool - allNames map[string]bool + byName map[string]*Tool + byAlias map[string]*Tool + allNames map[string]bool } // buildToolRegistry 构建 tool 索引并检测 name/alias 冲突。 From 55ebe6dd3bdb584bfe8daa5506e0068b552f7846 Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 23:00:41 +0800 Subject: [PATCH 12/13] fix(lint): reduce cyclomatic complexity and fix errcheck/unused/goimports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - expression.go: extract evalRegexCondition helper (18→reduced) - stage_executor.go: extract handleOnBlocked helper (19→reduced) - agent_tool.go: extract fillCoding/Review/IssuePost/LoopJudgePromptData helpers (30→reduced) - parallel_executor.go: extract aggregateStatus helper, remove unused base param (28→reduced) - loop_executor.go: extract executeBody & handleJudge helpers (31→reduced) - validator.go: check AddResource error, remove unused workflowSchemaPath const - main.go: add nolint:errcheck for fs.Parse in probe FlagSet - loop_judge_test.go: add nolint:unused for readLoopHistoryForTest All found by CI golangci-lint (errcheck, gocyclo, unused, goimports). --- cmd/worker/main.go | 2 +- cmd/worker/main_test.go | 4 +- internal/pipeline/loop_judge_test.go | 2 + internal/pipeline/runtime_adapter_test.go | 8 +- internal/runtime/agent_tool.go | 138 +++++++++++--------- internal/runtime/engine_test.go | 4 +- internal/runtime/expression.go | 37 +++--- internal/runtime/loop_executor.go | 152 +++++++++++++--------- internal/runtime/parallel_executor.go | 33 +++-- internal/runtime/stage_executor.go | 32 +++-- internal/schema/validator.go | 7 +- 11 files changed, 233 insertions(+), 186 deletions(-) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 80de54d..33500ab 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -95,7 +95,7 @@ func parseExtraFlags(args []string) (workflowPath, promptsDir string) { _ = fs.String("repo", "", "") _ = fs.Int("issue", 0, "") _ = fs.Bool("version", false, "") - _ = fs.Parse(args) + _ = fs.Parse(args) //nolint:errcheck // ContinueOnError 下 Parse 总是返回 nil return *workflow, *prompts } diff --git a/cmd/worker/main_test.go b/cmd/worker/main_test.go index e17d8c3..06c6b9b 100644 --- a/cmd/worker/main_test.go +++ b/cmd/worker/main_test.go @@ -185,8 +185,8 @@ func (f fakeDispatchService) Dispatch(context.Context, *config.Config) (*pipelin // 立即停止解析,排在 --repo 之后的 --workflow / --prompts-dir 永远解析不到。 func TestParseExtraFlags(t *testing.T) { cases := []struct { - name string - args []string + name string + args []string wantWorkflow string wantPrompts string }{ diff --git a/internal/pipeline/loop_judge_test.go b/internal/pipeline/loop_judge_test.go index 71f6248..9b8b64c 100644 --- a/internal/pipeline/loop_judge_test.go +++ b/internal/pipeline/loop_judge_test.go @@ -144,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/runtime_adapter_test.go b/internal/pipeline/runtime_adapter_test.go index a6223bd..62ce98f 100644 --- a/internal/pipeline/runtime_adapter_test.go +++ b/internal/pipeline/runtime_adapter_test.go @@ -25,10 +25,10 @@ func TestAdapterResolveResultFileMapping(t *testing.T) { adapter := NewArtifactResolverAdapter(artifacts) cases := []struct { - name string - stageName string - args map[string]any - loopRound int + name string + stageName string + args map[string]any + loopRound int wantSuffix string }{ {name: "issue-handling", stageName: "issue-handling", wantSuffix: "issue_intake_result.json"}, diff --git a/internal/runtime/agent_tool.go b/internal/runtime/agent_tool.go index 6dcee10..bb77e73 100644 --- a/internal/runtime/agent_tool.go +++ b/internal/runtime/agent_tool.go @@ -140,79 +140,89 @@ func (t *AgentTool) buildPromptData(inv Invocation) PromptData { if inv.EC != nil { switch t.def.PromptTemplate { case promptCoding: - // 从 issue-handling 拿 agent/summary/acceptance - if v, ok := inv.EC.Lookup("issue-handling", "agent"); ok { - if s, ok := v.(string); ok { - data.Agent = s - } - } - if v, ok := inv.EC.Lookup("issue-handling", "summary"); ok { - if s, ok := v.(string); ok { - data.IssueSummary = s - } - } - if v, ok := inv.EC.Lookup("issue-handling", "acceptance"); ok { - if s, ok := v.(string); ok { - data.Acceptance = s - } - } - + t.fillCodingPromptData(&data, inv) case promptReview: - // 从 issue-handling 拿 summary/acceptance - if v, ok := inv.EC.Lookup("issue-handling", "summary"); ok { - if s, ok := v.(string); ok { - data.IssueSummary = s - } - } - if v, ok := inv.EC.Lookup("issue-handling", "acceptance"); ok { - if s, ok := v.(string); ok { - data.Acceptance = s - } - } - // 从 coding 拿 summary/evidence/acceptance_check - if v, ok := inv.EC.Lookup("coding", "summary"); ok { - if s, ok := v.(string); ok { - data.CodingSummary = s - } - } - if v, ok := inv.EC.Lookup("coding", "evidence"); ok { - if s, ok := v.(string); ok { - data.CodingEvidence = s - } - } - if v, ok := inv.EC.Lookup("coding", "acceptance_check"); ok { - if s, ok := v.(string); ok { - data.CodingAcceptanceCheck = s - } - } - + t.fillReviewPromptData(&data, inv) case promptIssuePost: - // source file path 从 input_from stage 的结果文件获取 - if inv.EC.Artifacts != nil { - sourceStage, _ := getStringArg(inv.Args, "source_stage") - if sourceStage == "" { - // 默认从 input_from 字段推断 - sourceStage = "" - } - if sourceStage != "" { - data.SourceFilePath = inv.EC.Artifacts.ResolveResultFile(sourceStage, inv.Args, 0) - } - } - if v, ok := getStringArg(inv.Args, "purpose"); ok { - data.Purpose = v - } - + t.fillIssuePostPromptData(&data, inv) case promptLoopJudge: - // history file path 从 artifacts 获取 - if inv.EC.Artifacts != nil { - data.HistoryFilePath = inv.EC.Artifacts.LoopHistoryPath() - } + 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 { diff --git a/internal/runtime/engine_test.go b/internal/runtime/engine_test.go index 616a571..73bac65 100644 --- a/internal/runtime/engine_test.go +++ b/internal/runtime/engine_test.go @@ -128,8 +128,8 @@ func TestEngine_Run_Blocked(t *testing.T) { func TestEngine_Run_DispatchError(t *testing.T) { // 用一个 tool 不存在的 stage 触发 dispatch 错误 wf := &schema.Workflow{ - Version: "1", - Tools: []schema.Tool{}, + Version: "1", + Tools: []schema.Tool{}, Pipeline: []schema.PipelineStep{ {Stage: &schema.Stage{Name: "test", Tool: "nonexistent"}}, }, diff --git a/internal/runtime/expression.go b/internal/runtime/expression.go index a9375d7..89e853d 100644 --- a/internal/runtime/expression.go +++ b/internal/runtime/expression.go @@ -71,27 +71,32 @@ func EvalExitCondition(ec *ExecutionContext, cond schema.ExitCondition) (bool, e return ok, nil case "regex": - 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(cond.Value) - 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 + 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(无条件视为满足)。 diff --git a/internal/runtime/loop_executor.go b/internal/runtime/loop_executor.go index 2fe158c..4394ea8 100644 --- a/internal/runtime/loop_executor.go +++ b/internal/runtime/loop_executor.go @@ -73,38 +73,14 @@ func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec loopState.Round = round // 3. 执行 body - 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 - // blocked 处理 - if r.Blocked && bodyStep.Stage != nil { - onBlocked := bodyStep.Stage.OnBlocked - if onBlocked == "" { - onBlocked = "stop" - } - if onBlocked == "stop" { - return r, nil - } - } - } - - // 每步后检查 exit_when - 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 - } - } + var err error + lastResult, err = e.executeBody(ctx, loop, ec, lastResult) + if err != nil { + return lastResult, err + } + // executeBody 返回的终端结果需要立即退出 + if lastResult != nil && (lastResult.Completed || lastResult.Blocked) { + return lastResult, nil } // 4. 维护 ConsecutiveUnknown @@ -123,37 +99,15 @@ func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec } // 5. 执行 judge - if loop.Judge != nil && round >= judgeStartRound(loop.Judge) { - judgeResult, err := e.runJudge(ctx, ec, loop.Judge, loopState) - if err != nil { - return judgeResult, err - } - if judgeResult != nil { - decision := getDecision(judgeResult) - action := mapJudgeDecision(decision, loop.Judge.OnDecision) - - switch action { - case "exit": - return &StepResult{ - Success: true, - ManualRequired: decision == "STOP_MANUAL", - Blocked: decision == "STOP_BLOCKED", - StageName: "loop", - Output: judgeResult.Output, - Data: judgeResult.Data, - }, nil - case "continue": - if decision == "SHRINK_TASK" { - fb := buildShrinkFeedback(judgeResult) - if fb != "" { - loopState.LastFeedback = fb + "\n\n" + loopState.LastFeedback - } - } - } - } + judgeResult, err := e.handleJudge(ctx, ec, loop, loopState, lastResult, round) + if err != nil { + return judgeResult, err + } + if judgeResult != nil { + return judgeResult, nil } - // 6. 更新 LastFeedback(用最后一个 stage 的 next_action 作为下一轮反馈) + // 6. 更新 LastFeedback if lastResult != nil { fb := buildFeedbackFromResult(lastResult) if fb != "" { @@ -171,11 +125,85 @@ func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec }, nil } +// 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 *LoopState) (*StepResult, error) { +func (e *LoopExecutor) runJudge(ctx context.Context, ec *ExecutionContext, judge *schema.LoopJudge, _ *LoopState) (*StepResult, error) { judgeStep := schema.PipelineStep{ Stage: &schema.Stage{ Name: "loop-judge", diff --git a/internal/runtime/parallel_executor.go b/internal/runtime/parallel_executor.go index 55b925b..657b5ee 100644 --- a/internal/runtime/parallel_executor.go +++ b/internal/runtime/parallel_executor.go @@ -95,7 +95,7 @@ func (e *ParallelExecutor) joinResults( errs []error, join string, ec *ExecutionContext, - base map[string]*StepResult, + _ map[string]*StepResult, firstSuccessIdx int, ) (*StepResult, error) { // 收集成功分支 @@ -148,26 +148,31 @@ func (e *ParallelExecutor) joinResults( } // 成功:聚合 status - // all 模式:所有 status 都 PASS 才 PASS - // any/first_success:取首个成功的 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" { - allPass := true for _, r := range results { if r == nil || r.Status != "PASS" { if r != nil && r.Status != "" && r.Status != "PASS" { - allPass = false - break + return "" } } } - if allPass && len(results) > 0 { - agg.Status = "PASS" + if len(results) > 0 { + return "PASS" } - } else if firstSuccessIdx >= 0 && results[firstSuccessIdx] != nil { - agg.Status = results[firstSuccessIdx].Status - } else if len(successBranches) > 0 && results[successBranches[0]] != nil { - agg.Status = results[successBranches[0]].Status + return "" } - - return agg, nil + 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/stage_executor.go b/internal/runtime/stage_executor.go index 8e01c96..fda0896 100644 --- a/internal/runtime/stage_executor.go +++ b/internal/runtime/stage_executor.go @@ -113,26 +113,24 @@ func (e *StageExecutor) Execute(ctx context.Context, step schema.PipelineStep, e } // 9. 处理 on_blocked - if result.Blocked { - onBlocked := stage.OnBlocked - if onBlocked == "" { - onBlocked = "stop" - } - switch onBlocked { - case "skip": - result.Skipped = true - // 继续存储并返回 - case "continue": - // 继续存储并返回 - case "stop": - // 默认行为:存储后返回,上层 Engine 会终止 - default: - // 未知值默认 stop - } - } + 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/schema/validator.go b/internal/schema/validator.go index 0aad03e..8fe25fe 100644 --- a/internal/schema/validator.go +++ b/internal/schema/validator.go @@ -31,9 +31,6 @@ import ( var schemaFS embed.FS const ( - // workflowSchemaPath 是顶层 schema 在 embed FS 中的路径。 - workflowSchemaPath = "schemas/v1/workflow.yaml" - // schemaIDBase 是所有 v1 schema 的 $id 前缀。 // 注意:$id URL 保持 .json 后缀作为 URI 标识符(JSON Schema 标准约定), // 实际文件用 .yaml 格式,加载时转 JSON 注册到 compiler。 @@ -105,7 +102,9 @@ func loadEmbeddedSchemas(c *jsonschema.Compiler) error { // URL 用 .json 后缀(与 schema 文件内 $id 和 $ref 中的引用一致) baseName := strings.TrimSuffix(entry.Name(), ".yaml") url := schemaIDBase + baseName + ".json" - c.AddResource(url, bytes.NewReader(jsonBytes)) + if err := c.AddResource(url, bytes.NewReader(jsonBytes)); err != nil { + return fmt.Errorf("add resource %s: %w", baseName, err) + } } return nil From a410bfc14b281a1f09b96bee9bf5ee620aa8ffaa Mon Sep 17 00:00:00 2001 From: lyrics <3531587877@qq.com> Date: Sun, 5 Jul 2026 23:07:56 +0800 Subject: [PATCH 13/13] fix(lint): further reduce LoopExecutor.Execute cyclomatic complexity Extract postRound helper for per-round maintenance (ConsecutiveUnknown, judge, LastFeedback). Drops Execute from 18 to <=15 gocyclo threshold. Found by CI golangci-lint gocyclo check. --- internal/runtime/loop_executor.go | 71 +++++++++++++++++-------------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/internal/runtime/loop_executor.go b/internal/runtime/loop_executor.go index 4394ea8..5455e30 100644 --- a/internal/runtime/loop_executor.go +++ b/internal/runtime/loop_executor.go @@ -78,41 +78,13 @@ func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec if err != nil { return lastResult, err } - // executeBody 返回的终端结果需要立即退出 if lastResult != nil && (lastResult.Completed || lastResult.Blocked) { return lastResult, nil } - // 4. 维护 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), - }, nil - } - - // 5. 执行 judge - judgeResult, err := e.handleJudge(ctx, ec, loop, loopState, lastResult, round) - if err != nil { - return judgeResult, err - } - if judgeResult != nil { - return judgeResult, nil - } - - // 6. 更新 LastFeedback - if lastResult != nil { - fb := buildFeedbackFromResult(lastResult) - if fb != "" { - loopState.LastFeedback = fb - } + // 4. 维护并检查终止条件 + if result, exit := e.postRound(ctx, ec, loop, loopState, lastResult, round); exit { + return result, nil } } @@ -125,6 +97,43 @@ func (e *LoopExecutor) Execute(ctx context.Context, step schema.PipelineStep, ec }, 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 {