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") }