// Package cli 提供 agent-squad 命令行:serve / role / blueprint / run / runs / demo。 package cli import ( "bufio" "context" "encoding/json" "flag" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "agent-native/runtime/llm" "agent-squad/server" "agent-squad/squad" "agent-squad/store" ) // Main CLI 入口;返回进程退出码。 func Main(args []string) int { if len(args) == 0 { usage() return 2 } switch args[0] { case "serve": return cmdServe(args[1:]) case "role": return cmdRole(args[1:]) case "blueprint": return cmdBlueprint(args[1:]) case "run": return cmdRun(args[1:]) case "runs": return cmdRuns(args[1:]) case "demo": return cmdDemo(args[1:]) case "help", "-h", "--help": usage() return 0 default: fmt.Fprintf(os.Stderr, "未知命令 %q\n\n", args[0]) usage() return 2 } } func usage() { fmt.Print(`agent-squad — 通用智能体小队编排底座 用法: agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M] agent-squad role list|add|rm|recommend|derive ... agent-squad blueprint list|add|rm ... agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled] agent-squad runs list|inspect|pending|respond|blackboard ... agent-squad demo [--keep] 环境变量: AGENT_SQUAD_DATA 数据目录(默认 ./agent-squad-data;role/blueprint 子命令也读它) AGENT_SQUAD_LLM_BASE_URL OpenAI 兼容端点(agent-native 后端与 llm 规划器) AGENT_SQUAD_LLM_API_KEY API key AGENT_SQUAD_LLM_MODEL 默认模型 `) } // app 一次 CLI 执行的组装根。 type app struct { engine *squad.SquadEngine sink *squad.FanoutSink files *store.FileEventSink st *store.FileStore } func dataDir() string { if v := os.Getenv("AGENT_SQUAD_DATA"); v != "" { return v } return "./agent-squad-data" } // assemble 组装引擎;planner=llm 且未配置模型时回退 rule 并提示。 func assemble(dir, plannerName, modelOverride string) (*app, error) { st, err := store.New(dir) if err != nil { return nil, err } reg := squad.NewRoleRegistry() if err := st.LoadRoles(reg); err != nil { return nil, err } bps := squad.NewBlueprintRegistry() if err := st.LoadBlueprints(bps); err != nil { return nil, err } backends := squad.NewBackendResolver() baseURL := os.Getenv("AGENT_SQUAD_LLM_BASE_URL") apiKey := os.Getenv("AGENT_SQUAD_LLM_API_KEY") model := first(modelOverride, os.Getenv("AGENT_SQUAD_LLM_MODEL")) planner := squad.Planner(squad.NewRulePlanner()) if plannerName == "llm" { if baseURL == "" || apiKey == "" || model == "" { fmt.Fprintln(os.Stderr, "提示:llm 规划器需要 AGENT_SQUAD_LLM_BASE_URL/API_KEY/MODEL,回退 rule 规划器") } else { client := &llm.Client{BaseURL: baseURL, APIKey: apiKey} planner = squad.NewLLMPlanner(client, model) } } // 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。 if baseURL != "" && apiKey != "" && model != "" { backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute) } // 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。 fanout := squad.NewFanoutSink(8192) fileSink := store.NewFileEventSink(dir, fanout) engine, err := squad.NewEngine(squad.Options{ DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner, Backends: backends, Sink: fileSink, }) if err != nil { return nil, err } return &app{engine: engine, sink: fanout, files: fileSink, st: st}, nil } func first(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" } // ---- serve ---- func cmdServe(args []string) int { fs := flag.NewFlagSet("serve", flag.ContinueOnError) addr := fs.String("addr", ":8090", "监听地址") data := fs.String("data", dataDir(), "数据目录") planner := fs.String("planner", "rule", "规划器:rule|llm") model := fs.String("model", "", "默认模型(llm 规划器/后端)") if err := fs.Parse(args); err != nil { return 2 } a, err := assemble(*data, *planner, *model) if err != nil { fmt.Fprintln(os.Stderr, "组装失败:", err) return 1 } srv := server.New(a.engine, a.sink, a.st) stopSweep := srv.StartWakeupSweeper(15 * time.Second) defer stopSweep() fmt.Printf("agent-squad 服务启动 http://localhost%s (data=%s, roles=%d, blueprints=%d, wakeup-sweep=15s)\n", *addr, *data, len(a.engine.Registry().List()), len(a.engine.Blueprints().List())) return httpListen(*addr, srv.Handler()) } func httpListen(addr string, h http.Handler) int { if err := http.ListenAndServe(addr, h); err != nil { fmt.Fprintln(os.Stderr, "服务退出:", err) return 1 } return 0 } // ---- role ---- func cmdRole(args []string) int { if len(args) == 0 { fmt.Println("用法: agent-squad role list|add|rm|recommend|derive ...") return 2 } data := dataDir() a, err := assemble(data, "rule", "") if err != nil { fmt.Fprintln(os.Stderr, "组装失败:", err) return 1 } switch args[0] { case "list": roles := a.engine.Registry().List() fmt.Printf("%-20s %-24s %-10s %-16s %s\n", "ROLE", "NAME", "SOURCE", "BACKEND", "MODEL") for _, r := range roles { fmt.Printf("%-20s %-24s %-10s %-16s %s\n", r.Role, r.Name, r.Source, r.Backend, r.Model) } if len(roles) == 0 { fmt.Println("(空注册表:通过 `agent-squad role add` 注册角色)") } return 0 case "add": fs := flag.NewFlagSet("role add", flag.ContinueOnError) f := fs.String("f", "", "角色 JSON 文件") role := fs.String("role", "", "角色 key") name := fs.String("name", "", "角色名") backend := fs.String("backend", "stub", "绑定智能体:stub|agent-native|pi-agent|…(真实执行用 agent-native)") model := fs.String("model", "", "角色模型") keywords := fs.String("keywords", "", "推荐关键词(逗号分隔)") instrFile := fs.String("instructions-file", "", "指令文件路径") if err := fs.Parse(args[1:]); err != nil { return 2 } var spec squad.RoleSpec if *f != "" { data, err := os.ReadFile(*f) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } if err := json.Unmarshal(data, &spec); err != nil { fmt.Fprintln(os.Stderr, "解析角色 JSON 失败:", err) return 1 } } if *role != "" { spec.Role = *role } if *name != "" { spec.Name = *name } if *backend != "" { spec.Backend = *backend } if *model != "" { spec.Model = *model } if *keywords != "" { for _, k := range strings.Split(*keywords, ",") { if k = strings.TrimSpace(k); k != "" { spec.Keywords = append(spec.Keywords, k) } } } if *instrFile != "" { spec.InstructionsFile = *instrFile } if err := a.engine.Registry().Register(spec); err != nil { fmt.Fprintln(os.Stderr, "注册失败:", err) return 1 } fmt.Printf("已注册角色 %s(backend=%s model=%s)→ %s\n", spec.Role, spec.Backend, spec.Model, filepath.Join(data, "roles.d", spec.Role+".json")) return 0 case "rm": if len(args) < 2 { fmt.Println("用法: agent-squad role rm ") return 2 } if !a.engine.Registry().Remove(args[1]) { fmt.Fprintf(os.Stderr, "角色 %s 不存在\n", args[1]) return 1 } a.st.DeleteRole(args[1]) fmt.Println("已删除", args[1]) return 0 case "recommend": if len(args) < 2 { fmt.Println("用法: agent-squad role recommend <查询文本>") return 2 } spec, ok := a.engine.Registry().Recommend(strings.Join(args[1:], " ")) if !ok { fmt.Println("无匹配角色") return 1 } fmt.Printf("%-20s %s\n", spec.Role, spec.Name) return 0 case "derive": if len(args) < 2 { fmt.Println(`用法: agent-squad role derive '{"role_key":"gmp_expert","base_role":"engineer","domain_topic":"GMP"}'`) return 2 } var req squad.DeriveRequest if err := json.Unmarshal([]byte(args[1]), &req); err != nil { fmt.Fprintln(os.Stderr, "解析派生请求 JSON 失败:", err) return 1 } spec, err := a.engine.Registry().Derive(req) if err != nil { fmt.Fprintln(os.Stderr, "派生失败:", err) return 1 } fmt.Printf("已派生角色 %s(基线 %s)\n", spec.Role, spec.BaseRole) return 0 default: fmt.Println("未知子命令", args[0]) return 2 } } // ---- blueprint ---- func cmdBlueprint(args []string) int { if len(args) == 0 { fmt.Println("用法: agent-squad blueprint list|add|rm ...") return 2 } a, err := assemble(dataDir(), "rule", "") if err != nil { fmt.Fprintln(os.Stderr, "组装失败:", err) return 1 } switch args[0] { case "list": for _, bp := range a.engine.Blueprints().List() { fmt.Printf("%-24s %2d 步 %s\n", bp.Key, len(bp.Steps), bp.Title) } return 0 case "add": if len(args) < 2 { fmt.Println("用法: agent-squad blueprint add ") return 2 } data, err := os.ReadFile(args[1]) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } var bp squad.Blueprint if err := json.Unmarshal(data, &bp); err != nil { fmt.Fprintln(os.Stderr, "解析蓝图 JSON 失败:", err) return 1 } if err := a.engine.Blueprints().Register(bp); err != nil { fmt.Fprintln(os.Stderr, "注册失败:", err) return 1 } fmt.Printf("已注册蓝图 %s(%d 步)\n", bp.Key, len(bp.Steps)) return 0 case "rm": if len(args) < 2 { fmt.Println("用法: agent-squad blueprint rm ") return 2 } if !a.engine.Blueprints().Remove(args[1]) { fmt.Fprintf(os.Stderr, "蓝图 %s 不存在\n", args[1]) return 1 } a.st.DeleteBlueprint(args[1]) fmt.Println("已删除", args[1]) return 0 default: fmt.Println("未知子命令", args[0]) return 2 } } // ---- run(终端交互式 HITL)---- func cmdRun(args []string) int { // 目标文本在前、flag 在后(标准 flag 包遇位置参数即停止解析,需手动切分)。 goalArgs, flagArgs := splitPositionalThenFlags(args) fs := flag.NewFlagSet("run", flag.ContinueOnError) taskKey := fs.String("task-key", "", "显式蓝图模板键") backend := fs.String("backend", "", "运行级默认后端") materialsDir := fs.String("materials", "", "输入材料目录") hitlMode := fs.String("hitl-mode", "interactive", "interactive|auto|disabled") planner := fs.String("planner", "rule", "rule|llm") model := fs.String("model", "", "默认模型") timeout := fs.Duration("timeout", 30*time.Minute, "总超时") mode := fs.String("mode", "standard", "编排模式:standard|leader(leader 委派循环)") leaderRole := fs.String("leader-role", "", "leader 模式的 leader 角色 key") maxTurns := fs.Int("max-leader-turns", 8, "leader 模式轮次上限") if err := fs.Parse(flagArgs); err != nil { return 2 } goalText := strings.Join(goalArgs, " ") if goalText == "" { fmt.Println("用法: agent-squad run \"目标文本\" [flags]") return 2 } a, err := assemble(dataDir(), *planner, *model) if err != nil { fmt.Fprintln(os.Stderr, "组装失败:", err) return 1 } goal := squad.Goal{Text: goalText, HitlMode: squad.HitlMode(*hitlMode)} if *materialsDir != "" { ents, err := os.ReadDir(*materialsDir) if err != nil { fmt.Fprintln(os.Stderr, "读取材料目录失败:", err) return 1 } for _, ent := range ents { if ent.IsDir() { continue } goal.Materials = append(goal.Materials, squad.MaterialFile{ Filename: ent.Name(), Path: filepath.Join(*materialsDir, ent.Name()), }) } } ctx, cancel := context.WithTimeout(context.Background(), *timeout) defer cancel() res, err := a.engine.Run(ctx, goal, squad.RunOptions{ TaskKey: *taskKey, Backend: *backend, Mode: *mode, LeaderRole: *leaderRole, MaxLeaderTurns: *maxTurns, }) if err != nil { fmt.Fprintln(os.Stderr, "运行失败:", err) return 1 } reader := bufio.NewReader(os.Stdin) for err == nil && res.Status.IsHeld() && res.Pending != nil { printPending(res.Pending) decision := readDecision(reader, res.Pending) res, err = a.engine.Respond(ctx, res.RunID, decision) } if err != nil { fmt.Fprintln(os.Stderr, "运行失败:", err) return 1 } printResult(res) if res.Status == squad.RunFailed { return 1 } return 0 } func printPending(req *squad.HumanRequest) { fmt.Println("\n⏸ 需要人工输入 —", req.Question) if req.Context != "" { for _, line := range strings.Split(req.Context, "\n") { fmt.Println(" ", line) } } for i, o := range req.Options { fmt.Printf(" [%d] %s(%s)\n", i+1, o.Label, o.Value) } fmt.Println("输入选项序号/值,或直接输入自由文本回答:") } func readDecision(reader *bufio.Reader, req *squad.HumanRequest) squad.RespondDecision { for attempt := 0; attempt < 3; attempt++ { line, _ := reader.ReadString('\n') line = strings.TrimSpace(line) if line == "" && len(req.Options) > 0 { return squad.RespondDecision{RequestID: req.ID, Choice: req.Options[0].Value} } // 选项序号匹配。 if idx, err := strconv.Atoi(line); err == nil && idx >= 1 && idx <= len(req.Options) { return squad.RespondDecision{RequestID: req.ID, Choice: req.Options[idx-1].Value} } // 选项值匹配。 for _, o := range req.Options { if strings.EqualFold(o.Value, line) { return squad.RespondDecision{RequestID: req.ID, Choice: o.Value} } } // 带选项的请求不接受自由文本(防误触 abort);两类例外:纯文本请求 // 直接作为回答;澄清类请求自由文本即答案(choice 留空 = 继续并采纳)。 if len(req.Options) == 0 || req.Kind == squad.HitlClarification { return squad.RespondDecision{RequestID: req.ID, Answer: line} } fmt.Println(" 无法识别,请输入选项序号或选项值:") } choice := "abort" if len(req.Options) > 0 { choice = req.Options[len(req.Options)-1].Value } return squad.RespondDecision{RequestID: req.ID, Choice: choice} } func printResult(res *squad.RunResult) { fmt.Printf("\n▶ 运行 %s 终态:%s\n", res.RunID, res.Status) if res.Summary != "" { fmt.Println(" ", res.Summary) } if res.Error != "" { fmt.Println(" 错误:", res.Error) } if res.Deliverable != "" && res.WorkDir != "" { fmt.Printf(" 交付物: %s\n", filepath.Join(res.WorkDir, res.Deliverable)) } if res.Quality != nil { fmt.Printf(" 质检: passed=%v score=%.0f manual=%v\n", res.Quality.Passed, res.Quality.Score, res.Quality.ManualPass) } if res.WorkDir != "" { fmt.Println(" 工作区:", res.WorkDir) } } // ---- runs ---- func cmdRuns(args []string) int { if len(args) == 0 { fmt.Println("用法: agent-squad runs list|inspect|pending|respond|blackboard ...") return 2 } a, err := assemble(dataDir(), "rule", "") if err != nil { fmt.Fprintln(os.Stderr, "组装失败:", err) return 1 } ctx := context.Background() switch args[0] { case "list": runs, err := a.engine.ListRuns() if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } for _, m := range runs { fmt.Printf("%-32s %-22s %s\n", m.ID, m.Status, truncateOne(m.Goal.Text, 48)) } return 0 case "inspect": if len(args) < 2 { fmt.Println("用法: agent-squad runs inspect ") return 2 } res, err := a.engine.Inspect(args[1]) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } enc, _ := json.MarshalIndent(res, "", " ") fmt.Println(string(enc)) return 0 case "pending": if len(args) < 2 { fmt.Println("用法: agent-squad runs pending ") return 2 } req, _ := a.engine.Pending(args[1]) if req == nil { fmt.Println("(无挂起请求)") return 0 } enc, _ := json.MarshalIndent(req, "", " ") fmt.Println(string(enc)) return 0 case "respond": pos, flagArgs := splitPositionalThenFlags(args[1:]) fs := flag.NewFlagSet("runs respond", flag.ContinueOnError) choice := fs.String("choice", "", "选项值:approve/reject/skip/abort/…") answer := fs.String("answer", "", "自由文本回答") reqID := fs.String("request-id", "", "请求 ID(多挂起时校验)") if err := fs.Parse(flagArgs); err != nil { return 2 } if len(pos) < 1 { fmt.Println("用法: agent-squad runs respond --choice approve [--answer …]") return 2 } res, err := a.engine.Respond(ctx, pos[0], squad.RespondDecision{ RequestID: *reqID, Choice: *choice, Answer: *answer, }) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } printResult(res) return 0 case "steer": if len(args) < 3 { fmt.Println("用法: agent-squad runs steer \"补充指令文本\"") return 2 } if err := a.engine.Steer(args[1], strings.Join(args[2:], " ")); err != nil { fmt.Fprintln(os.Stderr, err) return 1 } fmt.Println("已加入补充指令(下一次步骤边界注入当前运行)") return 0 case "blackboard": if len(args) < 2 { fmt.Println("用法: agent-squad runs blackboard ") return 2 } bb, err := a.engine.Blackboard(args[1]) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } enc, _ := json.MarshalIndent(bb, "", " ") fmt.Println(string(enc)) return 0 default: fmt.Println("未知子命令", args[0]) return 2 } } func truncateOne(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" } // splitPositionalThenFlags 把参数切为前缀位置参数与其后的 flag 段 // (标准 flag 包遇到首个位置参数即停止解析,目标文本须先取出)。 // 负数不是本 CLI 的合法位置参数,直接按 "-" 前缀判定。 func splitPositionalThenFlags(args []string) (positional, flags []string) { for i, a := range args { if strings.HasPrefix(a, "-") { return append([]string{}, args[:i]...), append([]string{}, args[i:]...) } } return append([]string{}, args...), nil }