Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients. Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test. Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
8.4 KiB
Go
217 lines
8.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"agent-squad/squad"
|
|
)
|
|
|
|
// demoBackend 脚本化后端:按子工作区目录名回放预设产出(离线演示/测试)。
|
|
type demoBackend struct {
|
|
// script: 子目录名(如 01_researcher)→ target → 文件内容。
|
|
script map[string]map[string]string
|
|
}
|
|
|
|
func (b *demoBackend) Name() string { return "demo" }
|
|
|
|
func (b *demoBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
|
key := filepath.Base(filepath.Clean(req.WorkDir))
|
|
files, ok := b.script[key]
|
|
if !ok {
|
|
// 未脚本化的步骤退化为写一个说明文件到首个 target。
|
|
files = map[string]string{}
|
|
for i, t := range req.Targets {
|
|
if i == 0 {
|
|
files[t] = fmt.Sprintf("# 演示产出\n\n步骤目录 %s 未脚本化,这是默认产出。\n", key)
|
|
}
|
|
}
|
|
}
|
|
patches := map[string][]byte{}
|
|
for _, t := range req.Targets {
|
|
content := files[t]
|
|
if content == "" {
|
|
content = "\n"
|
|
}
|
|
out := filepath.Join(req.WorkDir, t)
|
|
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
|
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
patches[t] = []byte(content)
|
|
}
|
|
return &squad.ExecResult{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
|
|
}
|
|
|
|
// cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 →
|
|
// 人工回应 → 继续 → 质检 → 交付。全程 stub/脚本驱动,无需模型与外部智能体。
|
|
func cmdDemo(args []string) int {
|
|
fs := flag.NewFlagSet("demo", flag.ContinueOnError)
|
|
keep := fs.Bool("keep", false, "保留演示数据目录")
|
|
if err := fs.Parse(args); err != nil {
|
|
return 2
|
|
}
|
|
dir, err := os.MkdirTemp("", "agent-squad-demo-")
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
defer func() {
|
|
if *keep {
|
|
fmt.Println("\n演示工作区保留于:", dir)
|
|
} else {
|
|
_ = os.RemoveAll(dir)
|
|
}
|
|
}()
|
|
|
|
fmt.Println("═══ agent-squad 离线演示(stub 智能体 + mock 规划器 + HITL)═══")
|
|
fmt.Println("数据目录:", dir)
|
|
|
|
// 1. 组装:空注册表起步。
|
|
reg := squad.NewRoleRegistry()
|
|
bps := squad.NewBlueprintRegistry()
|
|
backends := squad.NewBackendResolver()
|
|
demoScript := map[string]map[string]string{
|
|
"01_researcher": {
|
|
"notes.md": "# 调研笔记\n\n- 目标产品:便携式超声诊断仪\n- 竞品:A/B/C 三款\n- 法规路径:二类器械,注册检验 + 临床评价\n",
|
|
"fields.json": `{"target_market":"中国","device_class":"二类","budget":"15万"}`,
|
|
"gaps.json": `[{"item":"目标年产量未提供,影响成本测算","blocking":true}]`,
|
|
},
|
|
"02_writer": {
|
|
"output.md": "# 便携式超声诊断仪调研报告\n\n## 概述与背景\n\n本报告基于输入材料与调研产出,梳理便携式超声诊断仪的市场与技术背景。目标市场为中国(二类器械),预算 15 万元。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n\n## 基准假设与待确认清单\n\n- 年产量按人工澄清结论执行\n",
|
|
},
|
|
"03_reviewer": {
|
|
"output.md": "# 便携式超声诊断仪调研报告(终审版)\n\n## 概述与背景\n\n本报告由小队协作产出:调研专员梳理事实与缺口,撰写专员成文,审核专员终审。目标市场为中国(二类器械),预算 15 万元,年产量 10 万套。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。成本测算基于年产量 10 万套摊薄产线投入。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n- 年产量波动超过 ±30% 时需重估成本模型\n\n## 基准假设与待确认清单\n\n- 灭菌方式采用环氧乙烷(EO)灭菌(行业常规)\n",
|
|
"fields.json": `{"annual_volume":"100000","review":"passed"}`,
|
|
},
|
|
}
|
|
backends.RegisterInstance("demo", &demoBackend{script: demoScript})
|
|
|
|
planner := &squad.MockPlanner{Script: []squad.StepSpec{
|
|
{ID: "step_01_research", Role: "researcher", Title: "市场与技术调研,回填事实与缺口",
|
|
Targets: []string{"notes.md", "fields.json", "gaps.json"}},
|
|
{ID: "step_02_draft", Role: "writer", Title: "汇总调研产出,撰写报告初稿",
|
|
Targets: []string{"output.md"}},
|
|
{ID: "step_03_review", Role: "reviewer", Title: "一致性终审并产出终版",
|
|
Targets: []string{"output.md", "fields.json"}},
|
|
}}
|
|
sink := squad.NewFanoutSink(4096)
|
|
engine, err := squad.NewEngine(squad.Options{
|
|
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
|
Backends: backends, Sink: sink,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "组装失败:", err)
|
|
return 1
|
|
}
|
|
|
|
// 2. 注册角色(演示小队编制——底座本身无预设角色)。
|
|
fmt.Println("\n[1] 注册角色(手动注册,backend=demo 脚本智能体)")
|
|
for _, spec := range []squad.RoleSpec{
|
|
{Role: "researcher", Name: "调研专员", Backend: "demo", Keywords: []string{"调研", "市场", "竞品"},
|
|
Responsibilities: []string{"市场与技术调研", "事实回填"}},
|
|
{Role: "writer", Name: "撰写专员", Backend: "demo", Keywords: []string{"撰写", "报告"},
|
|
Responsibilities: []string{"汇总产出并成文"}},
|
|
{Role: "reviewer", Name: "审核专员", Backend: "demo", Keywords: []string{"审核", "终审"},
|
|
Responsibilities: []string{"一致性终审"}},
|
|
} {
|
|
if err := reg.Register(spec); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return 1
|
|
}
|
|
fmt.Printf(" + %-12s %s\n", spec.Role, spec.Name)
|
|
}
|
|
|
|
// 3. 发起运行。
|
|
goal := squad.Goal{Text: "调研便携式超声诊断仪并产出报告"}
|
|
fmt.Printf("\n[2] 发起运行:%s\n", goal.Text)
|
|
res, err := engine.Run(context.Background(), goal, squad.RunOptions{})
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "运行失败:", err)
|
|
return 1
|
|
}
|
|
reportSteps(res)
|
|
|
|
// 4. HITL 挂起 → 回应 → 续跑。
|
|
for res.Status.IsHeld() && res.Pending != nil {
|
|
fmt.Printf("\n[3] ⏸ HITL 挂起(%s):%s\n", res.Pending.Kind, res.Pending.Question)
|
|
for _, line := range strings.Split(res.Pending.Context, "\n") {
|
|
if line != "" {
|
|
fmt.Println(" ", line)
|
|
}
|
|
}
|
|
answer := demoAnswer(res.Pending)
|
|
fmt.Printf(" → 自动回应:choice=%q answer=%q\n", answer.Choice, answer.Answer)
|
|
res, err = engine.Respond(context.Background(), res.RunID, answer)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "回应失败:", err)
|
|
return 1
|
|
}
|
|
reportSteps(res)
|
|
}
|
|
|
|
// 5. 结果。
|
|
fmt.Printf("\n[4] 运行终态:%s\n", res.Status)
|
|
if res.Summary != "" {
|
|
fmt.Println(" ", res.Summary)
|
|
}
|
|
if res.Deliverable != "" {
|
|
path := filepath.Join(res.WorkDir, res.Deliverable)
|
|
fmt.Println(" 交付物:", path)
|
|
if head := headLines(path, 6); head != "" {
|
|
fmt.Println(" ┌─ 交付物预览")
|
|
for _, l := range strings.Split(strings.TrimRight(head, "\n"), "\n") {
|
|
fmt.Println(" │", l)
|
|
}
|
|
fmt.Println(" └─")
|
|
}
|
|
}
|
|
bb, _ := engine.Blackboard(res.RunID)
|
|
if bb != nil {
|
|
enc, _ := json.MarshalIndent(bb.Fields, "", " ")
|
|
fmt.Println(" 黑板字段:", string(enc))
|
|
}
|
|
fmt.Println("\n演示完成。真实使用:agent-squad role add / run / serve。")
|
|
return 0
|
|
}
|
|
|
|
func demoAnswer(req *squad.HumanRequest) squad.RespondDecision {
|
|
switch req.Kind {
|
|
case squad.HitlGap:
|
|
return squad.RespondDecision{RequestID: req.ID, Choice: "resolved", Answer: "年产量 10 万套"}
|
|
default:
|
|
choice := "approve"
|
|
if len(req.Options) > 0 {
|
|
choice = req.Options[0].Value
|
|
}
|
|
return squad.RespondDecision{RequestID: req.ID, Choice: choice}
|
|
}
|
|
}
|
|
|
|
func reportSteps(res *squad.RunResult) {
|
|
if res.Plan == nil {
|
|
return
|
|
}
|
|
for _, s := range res.Plan.Steps {
|
|
mark := map[string]string{"completed": "✔", "failed": "✘", "skipped": "⤼", "running": "…", "pending": " "}[s.Status]
|
|
fmt.Printf(" %s %-22s %-12s %s\n", mark, s.ID, s.Role, s.Title)
|
|
}
|
|
}
|
|
|
|
func headLines(path string, n int) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
if len(lines) > n {
|
|
lines = lines[:n]
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|