// Package agents 从 agents/ 目录加载 Markdown 代理定义(子代理 + 可选主代理 orchestrator.md / kind: orchestrator)。 package agents import ( "fmt" "os" "path/filepath" "sort" "strings" "unicode" "cyberstrike-ai/internal/config" "gopkg.in/yaml.v3" ) // OrchestratorMarkdownFilename 固定文件名:存在则视为 Deep 主代理定义,且不参与子代理列表。 const OrchestratorMarkdownFilename = "orchestrator.md" // FrontMatter 对应 Markdown 文件头部字段(与文档示例一致)。 type FrontMatter struct { Name string `yaml:"name"` ID string `yaml:"id"` Description string `yaml:"description"` Tools interface{} `yaml:"tools"` // 字符串 "A, B" 或 []string MaxIterations int `yaml:"max_iterations"` BindRole string `yaml:"bind_role,omitempty"` Kind string `yaml:"kind,omitempty"` // orchestrator = 主代理(亦可仅用文件名 orchestrator.md) } // OrchestratorMarkdown 从 agents 目录解析出的主代理(Deep 协调者)定义。 type OrchestratorMarkdown struct { Filename string EinoName string // 写入 deep.Config.Name / 流式事件过滤 DisplayName string Description string Instruction string } // MarkdownDirLoad 一次扫描 agents 目录的结果(子代理不含主代理文件)。 type MarkdownDirLoad struct { SubAgents []config.MultiAgentSubConfig Orchestrator *OrchestratorMarkdown FileEntries []FileAgent // 含主代理与所有子代理,供管理 API 列表 } // IsOrchestratorMarkdown 判断该文件是否表示主代理:固定文件名 orchestrator.md,或 front matter kind: orchestrator。 func IsOrchestratorMarkdown(filename string, fm FrontMatter) bool { base := filepath.Base(strings.TrimSpace(filename)) if strings.EqualFold(base, OrchestratorMarkdownFilename) { return true } return strings.EqualFold(strings.TrimSpace(fm.Kind), "orchestrator") } // WantsMarkdownOrchestrator 保存前判断是否会把该文件作为主代理(用于唯一性校验)。 func WantsMarkdownOrchestrator(filename string, kindField string, raw string) bool { if strings.EqualFold(strings.TrimSpace(kindField), "orchestrator") { return true } base := filepath.Base(strings.TrimSpace(filename)) if strings.EqualFold(base, OrchestratorMarkdownFilename) { return true } if strings.TrimSpace(raw) == "" { return false } sub, err := ParseMarkdownSubAgent(filename, raw) if err != nil { return false } return strings.EqualFold(strings.TrimSpace(sub.Kind), "orchestrator") } // SplitFrontMatter 分离 YAML front matter 与正文(--- ... ---)。 func SplitFrontMatter(content string) (frontYAML string, body string, err error) { s := strings.TrimSpace(content) if !strings.HasPrefix(s, "---") { return "", s, nil } rest := strings.TrimPrefix(s, "---") rest = strings.TrimLeft(rest, "\r\n") end := strings.Index(rest, "\n---") if end < 0 { return "", "", fmt.Errorf("agents: 缺少结束的 --- 分隔符") } fm := strings.TrimSpace(rest[:end]) body = strings.TrimSpace(rest[end+4:]) body = strings.TrimLeft(body, "\r\n") return fm, body, nil } func parseToolsField(v interface{}) []string { if v == nil { return nil } switch t := v.(type) { case string: return splitToolList(t) case []interface{}: var out []string for _, x := range t { if s, ok := x.(string); ok && strings.TrimSpace(s) != "" { out = append(out, strings.TrimSpace(s)) } } return out case []string: var out []string for _, s := range t { if strings.TrimSpace(s) != "" { out = append(out, strings.TrimSpace(s)) } } return out default: return nil } } func splitToolList(s string) []string { s = strings.TrimSpace(s) if s == "" { return nil } parts := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ';' || r == '|' }) var out []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // SlugID 从 name 生成可用的代理 id(小写、连字符)。 func SlugID(name string) string { var b strings.Builder name = strings.TrimSpace(strings.ToLower(name)) lastDash := false for _, r := range name { switch { case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r): b.WriteRune(r) lastDash = false case r == ' ' || r == '_' || r == '/' || r == '.': if !lastDash && b.Len() > 0 { b.WriteByte('-') lastDash = true } } } s := strings.Trim(b.String(), "-") if s == "" { return "agent" } return s } // sanitizeEinoAgentID 规范化 Deep 主代理在 Eino 中的 Name:小写 ASCII、数字、连字符,与默认 cyberstrike-deep 一致。 func sanitizeEinoAgentID(s string) string { s = strings.TrimSpace(strings.ToLower(s)) var b strings.Builder for _, r := range s { switch { case unicode.IsLetter(r) && r < unicode.MaxASCII, unicode.IsDigit(r): b.WriteRune(r) case r == '-': b.WriteRune(r) } } out := strings.Trim(b.String(), "-") if out == "" { return "cyberstrike-deep" } return out } func parseMarkdownAgentRaw(filename string, content string) (FrontMatter, string, error) { var fm FrontMatter fmStr, body, err := SplitFrontMatter(content) if err != nil { return fm, "", err } if strings.TrimSpace(fmStr) == "" { return fm, "", fmt.Errorf("agents: %s 无 YAML front matter", filename) } if err := yaml.Unmarshal([]byte(fmStr), &fm); err != nil { return fm, "", fmt.Errorf("agents: 解析 front matter: %w", err) } return fm, body, nil } func orchestratorFromParsed(filename string, fm FrontMatter, body string) (*OrchestratorMarkdown, error) { display := strings.TrimSpace(fm.Name) if display == "" { display = "Orchestrator" } rawID := strings.TrimSpace(fm.ID) if rawID == "" { rawID = SlugID(display) } eino := sanitizeEinoAgentID(rawID) return &OrchestratorMarkdown{ Filename: filepath.Base(strings.TrimSpace(filename)), EinoName: eino, DisplayName: display, Description: strings.TrimSpace(fm.Description), Instruction: strings.TrimSpace(body), }, nil } func orchestratorConfigFromOrchestrator(o *OrchestratorMarkdown) config.MultiAgentSubConfig { if o == nil { return config.MultiAgentSubConfig{} } return config.MultiAgentSubConfig{ ID: o.EinoName, Name: o.DisplayName, Description: o.Description, Instruction: o.Instruction, Kind: "orchestrator", } } func subAgentFromFrontMatter(filename string, fm FrontMatter, body string) (config.MultiAgentSubConfig, error) { var out config.MultiAgentSubConfig name := strings.TrimSpace(fm.Name) if name == "" { return out, fmt.Errorf("agents: %s 缺少 name 字段", filename) } id := strings.TrimSpace(fm.ID) if id == "" { id = SlugID(name) } out.ID = id out.Name = name out.Description = strings.TrimSpace(fm.Description) out.Instruction = strings.TrimSpace(body) out.RoleTools = parseToolsField(fm.Tools) out.MaxIterations = fm.MaxIterations out.BindRole = strings.TrimSpace(fm.BindRole) out.Kind = strings.TrimSpace(fm.Kind) return out, nil } func collectMarkdownBasenames(dir string) ([]string, error) { if strings.TrimSpace(dir) == "" { return nil, nil } st, err := os.Stat(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } if !st.IsDir() { return nil, fmt.Errorf("agents: 不是目录: %s", dir) } entries, err := os.ReadDir(dir) if err != nil { return nil, err } var names []string for _, e := range entries { if e.IsDir() { continue } n := e.Name() if strings.HasPrefix(n, ".") { continue } if !strings.EqualFold(filepath.Ext(n), ".md") { continue } if strings.EqualFold(n, "README.md") { continue } names = append(names, n) } sort.Strings(names) return names, nil } // LoadMarkdownAgentsDir 扫描 agents 目录:拆出至多一个主代理与其余子代理。 func LoadMarkdownAgentsDir(dir string) (*MarkdownDirLoad, error) { out := &MarkdownDirLoad{} names, err := collectMarkdownBasenames(dir) if err != nil { return nil, err } for _, n := range names { p := filepath.Join(dir, n) b, err := os.ReadFile(p) if err != nil { return nil, err } fm, body, err := parseMarkdownAgentRaw(n, string(b)) if err != nil { return nil, fmt.Errorf("%s: %w", n, err) } if IsOrchestratorMarkdown(n, fm) { if out.Orchestrator != nil { return nil, fmt.Errorf("agents: 仅能定义一个主代理(Deep 协调者),已有 %s,又与 %s 冲突", out.Orchestrator.Filename, n) } orch, err := orchestratorFromParsed(n, fm, body) if err != nil { return nil, fmt.Errorf("%s: %w", n, err) } out.Orchestrator = orch out.FileEntries = append(out.FileEntries, FileAgent{ Filename: n, Config: orchestratorConfigFromOrchestrator(orch), IsOrchestrator: true, }) continue } sub, err := subAgentFromFrontMatter(n, fm, body) if err != nil { return nil, fmt.Errorf("%s: %w", n, err) } out.SubAgents = append(out.SubAgents, sub) out.FileEntries = append(out.FileEntries, FileAgent{Filename: n, Config: sub, IsOrchestrator: false}) } return out, nil } // ParseMarkdownSubAgent 将单个 Markdown 文件解析为 MultiAgentSubConfig。 func ParseMarkdownSubAgent(filename string, content string) (config.MultiAgentSubConfig, error) { fm, body, err := parseMarkdownAgentRaw(filename, content) if err != nil { return config.MultiAgentSubConfig{}, err } if IsOrchestratorMarkdown(filename, fm) { orch, err := orchestratorFromParsed(filename, fm, body) if err != nil { return config.MultiAgentSubConfig{}, err } return orchestratorConfigFromOrchestrator(orch), nil } return subAgentFromFrontMatter(filename, fm, body) } // LoadMarkdownSubAgents 读取目录下所有子代理 .md(不含主代理 orchestrator.md / kind: orchestrator)。 func LoadMarkdownSubAgents(dir string) ([]config.MultiAgentSubConfig, error) { load, err := LoadMarkdownAgentsDir(dir) if err != nil { return nil, err } return load.SubAgents, nil } // FileAgent 单个 Markdown 文件及其解析结果。 type FileAgent struct { Filename string Config config.MultiAgentSubConfig IsOrchestrator bool } // LoadMarkdownAgentFiles 列出目录下全部 .md(含主代理),供管理 API 使用。 func LoadMarkdownAgentFiles(dir string) ([]FileAgent, error) { load, err := LoadMarkdownAgentsDir(dir) if err != nil { return nil, err } return load.FileEntries, nil } // MergeYAMLAndMarkdown 合并 config.yaml 中的 sub_agents 与 Markdown 定义:同 id 时 Markdown 覆盖 YAML;仅存在于 Markdown 的条目追加在 YAML 顺序之后。 func MergeYAMLAndMarkdown(yamlSubs []config.MultiAgentSubConfig, mdSubs []config.MultiAgentSubConfig) []config.MultiAgentSubConfig { mdByID := make(map[string]config.MultiAgentSubConfig) for _, m := range mdSubs { id := strings.TrimSpace(m.ID) if id == "" { continue } mdByID[id] = m } yamlIDSet := make(map[string]bool) for _, y := range yamlSubs { yamlIDSet[strings.TrimSpace(y.ID)] = true } out := make([]config.MultiAgentSubConfig, 0, len(yamlSubs)+len(mdSubs)) for _, y := range yamlSubs { id := strings.TrimSpace(y.ID) if id == "" { continue } if m, ok := mdByID[id]; ok { out = append(out, m) } else { out = append(out, y) } } for _, m := range mdSubs { id := strings.TrimSpace(m.ID) if id == "" || yamlIDSet[id] { continue } out = append(out, m) } return out } // EffectiveSubAgents 供多代理运行时使用。 func EffectiveSubAgents(yamlSubs []config.MultiAgentSubConfig, agentsDir string) ([]config.MultiAgentSubConfig, error) { md, err := LoadMarkdownSubAgents(agentsDir) if err != nil { return nil, err } if len(md) == 0 { return yamlSubs, nil } return MergeYAMLAndMarkdown(yamlSubs, md), nil } // BuildMarkdownFile 根据配置序列化为可写回磁盘的 Markdown。 func BuildMarkdownFile(sub config.MultiAgentSubConfig) ([]byte, error) { fm := FrontMatter{ Name: sub.Name, ID: sub.ID, Description: sub.Description, MaxIterations: sub.MaxIterations, BindRole: sub.BindRole, } if k := strings.TrimSpace(sub.Kind); k != "" { fm.Kind = k } if len(sub.RoleTools) > 0 { fm.Tools = sub.RoleTools } head, err := yaml.Marshal(fm) if err != nil { return nil, err } var b strings.Builder b.WriteString("---\n") b.Write(head) b.WriteString("---\n\n") b.WriteString(strings.TrimSpace(sub.Instruction)) if !strings.HasSuffix(sub.Instruction, "\n") && sub.Instruction != "" { b.WriteString("\n") } return []byte(b.String()), nil }