Files
Agent-Squad/cli/cli.go
T
weichenandCursor bfec3c8930
ci / vet-and-test (push) Failing after 3s
ci / docker-checkout-hostgateway (push) Failing after 3s
Import agent-squad as a standalone repository.
Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 12:38:35 +00:00

611 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <key>")
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 <file.json>")
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 <key>")
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 <run_id>")
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 <run_id>")
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 <run_id> --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 <run_id> \"补充指令文本\"")
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 <run_id>")
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
}