package engine // agent_loader.go 负责从文件系统发现和加载 AgentDefinition 文件. // // 模块定位: // // 与 skill_loader.go 对称--Skill 是"任务模板",AgentDef 是"能力档案". // 二者都从目录扫描 YAML frontmatter 文件,但结构不同: // Skill: skills/ → name/SKILL.md 或 name.md // AgentDef: agents/ → name/AGENT.md 或 name.md // // YAML frontmatter 格式(支持字段): // // --- // agent_type: WarehouseAudit // description: Audits warehouse operations // when_to_use: When you need to verify warehouse data // allowed_tools: // - Read // - Grep // disallowed_tools: // - Write // background: true // background_allowed_tools: // - Grep // - Glob // model: "" // max_turns: 20 // allowed_sub_agent_types: // - Explore // - Plan // --- // Optional long description / context for the agent. // // 设计决策: // - 零外部依赖:YAML frontmatter 用手写行扫描器(复用 parseFrontmatter 逻辑) // - AgentDefLoader 接口:允许非文件系统来源(数据库,内存,远程) // - ScanAgentDefsDir:发现目录下所有 Agent 定义文件 // - 同名时子目录格式(name/AGENT.md)优先于扁平格式(name.md) // // 升华改进(ELEVATED): 早期实现是 TS 专用格式,Go SDK 嵌入无法直接使用. // 我们设计 AgentDefLoader 接口 + YAML 文件格式,任何语言/框架都可以生成 YAML 然后被加载. // 替代方案:<直接在 Go 代码中调用 Register()> - 否决:不支持运营团队在不改代码的情况下 // // 添加新 Agent 类型(仓储运维人员不应该需要懂 Go). import ( "io" "os" "path/filepath" "strconv" "strings" ) // maxAgentDefBytes 是单个 AgentDefinition 文件的最大允许大小. // // 升华改进(ELEVATED): 早期方案 os.ReadFile 无大小限制-- // 恶意或错误的超大文件会将全部内容读入内存(OOM 风险). // 1MB 远超正常 Agent 定义文件(通常 < 10KB),足够容纳最复杂的描述文本. // 替代方案:<不限制> - 否决:单个文件可拖垮整个扫描流程. const maxAgentDefBytes = 1 << 20 // 1MB // AgentDefLoader 是从外部来源加载 AgentDefinition 的接口. // // 升华改进(ELEVATED): 早期实现只有函数,无接口抽象. // 我们定义接口--测试中可注入 stub(返回固定列表),生产中用 FileAgentDefLoader. // 跨行业扩展: // - 数据库加载:RemoteAgentDefLoader(从 Neon/MySQL 读取) // - 动态注册:PluginAgentDefLoader(插件提供 AgentDef) // - 内存加载:StaticAgentDefLoader(测试用) // // 替代方案:<直接暴露 ScanAgentDefsDir 函数> - 否决:无法注入替代实现,测试困难. type AgentDefLoader interface { // LoadAgentDefs 加载并返回所有 AgentDefinition. // 如果没有可加载的定义,返回空切片(不是错误). LoadAgentDefs() ([]*AgentDefinition, error) } // FileAgentDefLoader 从文件系统目录加载 AgentDefinition. // // 实现 AgentDefLoader 接口. // 支持两种目录格式(与 ScanSkillsDir 一致): // 1. 子目录格式(推荐):dir/agent-name/AGENT.md // 2. 扁平格式(向后兼容):dir/agent-name.md type FileAgentDefLoader struct { // Dir 是要扫描的目录路径 Dir string } // LoadAgentDefs 扫描 Dir 目录,加载所有 AgentDefinition 文件. func (l *FileAgentDefLoader) LoadAgentDefs() ([]*AgentDefinition, error) { return ScanAgentDefsDir(l.Dir) } // ScanAgentDefsDir 扫描目录下所有 Agent 定义文件并解析返回. // // 支持两种格式(与 ScanSkillsDir 对称): // 1. 子目录格式:dir/agent-name/AGENT.md // 2. 扁平格式:dir/agent-name.md // // 同名时子目录格式优先(新格式覆盖旧格式). // 目录不存在时返回空切片(不是错误). // // 精妙之处(CLEVER): 使用 defMap 去重--同一 agent_type 只保留最新加载的定义. // 子目录格式优先:扁平 .md 先加入,子目录 AGENT.md 后覆盖(两遍遍历). // 替代方案:单遍遍历 + 跳过已存在 key(顺序依赖,不直观). func ScanAgentDefsDir(dir string) ([]*AgentDefinition, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil // 目录不存在不是错误 } return nil, err } // defMap: agentType → *AgentDefinition(用于去重,子目录格式优先覆盖扁平格式) defMap := make(map[string]*AgentDefinition) for _, entry := range entries { if entry.IsDir() { // 尝试子目录格式:dir/entry-name/AGENT.md agentFile := filepath.Join(dir, entry.Name(), "AGENT.md") if def, err := LoadAgentDefFile(agentFile); err == nil { if def.AgentType == "" { def.AgentType = entry.Name() } defMap[def.AgentType] = def // 子目录格式优先覆盖同名扁平格式 } } else { name := entry.Name() if !strings.HasSuffix(name, ".md") { continue } // 扁平格式:dir/agent-name.md filePath := filepath.Join(dir, name) def, err := LoadAgentDefFile(filePath) if err != nil { continue // 解析失败,跳过(容错) } if def.AgentType == "" { def.AgentType = strings.TrimSuffix(name, ".md") } // 只在没有同名子目录定义时才加入(子目录格式优先) if _, exists := defMap[def.AgentType]; !exists { defMap[def.AgentType] = def } } } result := make([]*AgentDefinition, 0, len(defMap)) for _, def := range defMap { result = append(result, def) } return result, nil } // LoadAgentDefFile 从单个文件加载并解析 AgentDefinition. // // 文件格式:YAML frontmatter(---...---)+ 可选正文(作为 WhenToUse 的补充描述). // frontmatter 缺失时返回空 AgentDefinition(AgentType 为空,调用方需补充). func LoadAgentDefFile(path string) (*AgentDefinition, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, maxAgentDefBytes)) if err != nil { return nil, err } def := &AgentDefinition{} content := string(data) // 复用 parseFrontmatter 解析器(与 skill_loader.go 共用) fm, body := parseFrontmatter(content) if fm != "" { parseAgentDefFrontmatter(fm, def) } // 正文追加到 WhenToUse(如果 frontmatter 里没有设置 when_to_use,正文作为补充) body = strings.TrimSpace(body) if body != "" && def.WhenToUse == "" { def.WhenToUse = body } return def, nil } // parseAgentDefFrontmatter 解析 YAML frontmatter 并填充 AgentDefinition 字段. // // 支持的字段(与 YAML frontmatter 格式文档对齐): // // agent_type → string // description → string // when_to_use → string // allowed_tools → []string(列表格式 或 逗号分隔) // disallowed_tools → []string // background_allowed_tools→ []string // allowed_sub_agent_types → []string // model → string // max_turns → int(字符串解析) // background → bool("true"/"false"/"yes"/"no") // // 精妙之处(CLEVER): 不使用 YAML 库,手写行扫描器(零外部依赖原则). // 和 skill_loader.go 的 parseFrontmatterFields 逻辑相同,但处理不同字段集. // 替代方案:<引入 gopkg.in/yaml.v3> - 否决:破坏零外部依赖原则,仅为解析简单 frontmatter 得不偿失. func parseAgentDefFrontmatter(fm string, def *AgentDefinition) { lines := strings.Split(fm, "\n") var currentKey string // 当前正在收集列表的 key for _, line := range lines { // 跳过空行(不重置 currentKey,允许列表之间有空行) if strings.TrimSpace(line) == "" { continue } // 列表项 " - value" if strings.HasPrefix(strings.TrimSpace(line), "- ") { item := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "- ")) item = strings.Trim(item, `"'`) // 去除引号 if item == "" { continue } switch currentKey { case "allowed_tools": def.AllowedTools = append(def.AllowedTools, item) case "disallowed_tools": def.DisallowedTools = append(def.DisallowedTools, item) case "background_allowed_tools": def.BackgroundAllowedTools = append(def.BackgroundAllowedTools, item) case "allowed_sub_agent_types": def.AllowedSubAgentTypes = append(def.AllowedSubAgentTypes, item) } continue } // key: value 行 idx := strings.Index(line, ":") if idx < 0 { continue } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) val = strings.Trim(val, `"'`) // 去除引号 currentKey = key // 更新当前 key(为后续列表项做准备) switch key { case "agent_type": def.AgentType = val case "description": def.Description = val case "when_to_use": def.WhenToUse = val case "model": def.Model = val case "max_turns": // 升华改进(ELEVATED): strconv.Atoi 替代手写整数解析-- // 原方案静默忽略非数字字符("20hello" → 20), // 导致格式错误的配置被静默接受,难以排查. // strconv.Atoi 严格解析,失败时保持 MaxTurns=0(默认值), // 调用方已处理 0 → 默认 10 的转换,行为不变. if n, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { def.MaxTurns = n } case "background": // 支持 true/yes/1 和 false/no/0 lower := strings.ToLower(val) def.Background = lower == "true" || lower == "yes" || lower == "1" case "allowed_tools": // 可能是单行逗号分隔格式(与列表格式互斥) if val != "" { // 精妙之处(CLEVER): 单行逗号分隔 vs 多行列表-- // 如果值非空,说明是 "key: v1, v2" 格式; // 如果值为空(下一行是 "- item"),currentKey 已设好,等列表项填充. def.AllowedTools = splitCommaSeparated(val) } case "disallowed_tools": if val != "" { def.DisallowedTools = splitCommaSeparated(val) } case "background_allowed_tools": if val != "" { def.BackgroundAllowedTools = splitCommaSeparated(val) } case "allowed_sub_agent_types": if val != "" { def.AllowedSubAgentTypes = splitCommaSeparated(val) } } } } // splitCommaSeparated 将逗号分隔的字符串拆分为去空格的切片. // // 精妙之处(CLEVER): 复用 Skill frontmatter 的相同逻辑-- // "Read, Grep, Glob" → ["Read", "Grep", "Glob"]. // 过滤空字符串(防止末尾逗号产生空元素). func splitCommaSeparated(s string) []string { parts := strings.Split(s, ",") result := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) p = strings.Trim(p, `"'`) if p != "" { result = append(result, p) } } return result }