// Package agentspec 定义子代理画像(AgentSpec)与 agent-*.md 文件形态。 // 宿主与内置画像可声明全部字段;插件来源的画像只允许身份与工具面, // 不得声明钩子、MCP 与权限模式(fail-closed,错误定位到字段)。 package agentspec import ( "fmt" "os" "path/filepath" "strconv" "strings" ) // AgentSpec 是一个子代理画像。字段对齐公开子代理 frontmatter 约定, // 另加 outputSchema。空字段表示沿用宿主内置。 type AgentSpec struct { Name string Description string Prompt string // 正文(frontmatter 之后的内容) Tools []string DisallowedTools []string Model string PermissionMode string Skills []string MaxTurns int MemoryScope string Effort string Background bool WorktreeIsolation bool OutputSchema string } // pluginBannedKeys 是插件来源画像禁带的键:钩子、MCP 与权限模式归宿主与基座。 var pluginBannedKeys = map[string]bool{ "hooks": true, "mcp": true, "permissionMode": true, } // Parse 解析宿主/内置画像文件:frontmatter(--- 包裹的 key: value 行, // 列表值用逗号分隔)+ 正文。未知键报错;name 必填且须小写连字符。 func Parse(content string) (AgentSpec, error) { return parse(content, nil) } // ParsePlugin 解析插件来源画像文件:在 Parse 之上禁带 hooks/mcp/permissionMode 键。 func ParsePlugin(content string) (AgentSpec, error) { return parse(content, pluginBannedKeys) } func parse(content string, banned map[string]bool) (AgentSpec, error) { fm, body, err := splitFrontmatter(content) if err != nil { return AgentSpec{}, err } var spec AgentSpec for key, value := range fm { if banned[key] { return AgentSpec{}, fmt.Errorf("agentspec: 插件画像不得声明 %s", key) } if err := assign(&spec, key, value); err != nil { return AgentSpec{}, err } } spec.Prompt = strings.TrimSpace(body) if spec.Name == "" { return AgentSpec{}, fmt.Errorf("agentspec: name required") } if !validName(spec.Name) { return AgentSpec{}, fmt.Errorf("agentspec: name %q 须为小写连字符(如 doc-reviewer)", spec.Name) } return spec, nil } // LoadDir 整批加载目录下的 *.md 画像:任一文件解析失败则全批拒绝; // 重名拒绝。parser 按来源选 Parse 或 ParsePlugin。返回按 Name 索引。 func LoadDir(dir string, parser func(string) (AgentSpec, error)) (map[string]AgentSpec, error) { if parser == nil { parser = Parse } names, err := filepath.Glob(filepath.Join(dir, "*.md")) if err != nil { return nil, fmt.Errorf("agentspec: glob %s: %w", dir, err) } out := map[string]AgentSpec{} for _, name := range names { raw, err := os.ReadFile(name) if err != nil { return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err) } spec, err := parser(string(raw)) if err != nil { return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err) } if _, dup := out[spec.Name]; dup { return nil, fmt.Errorf("agentspec: duplicate name %q in %s", spec.Name, dir) } out[spec.Name] = spec } return out, nil } func splitFrontmatter(content string) (map[string]string, string, error) { text := strings.ReplaceAll(content, "\r\n", "\n") if !strings.HasPrefix(text, "---\n") { return nil, "", fmt.Errorf("agentspec: missing frontmatter(须以 --- 开头)") } rest := text[len("---\n"):] end := strings.Index(rest, "\n---") if end < 0 { return nil, "", fmt.Errorf("agentspec: frontmatter 未闭合(缺 --- 结尾)") } fm := map[string]string{} for _, line := range strings.Split(rest[:end], "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } i := strings.Index(line, ":") if i <= 0 { return nil, "", fmt.Errorf("agentspec: 无法解析的行 %q(须为 key: value)", line) } fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:]) } body := rest[end+len("\n---"):] body = strings.TrimLeft(body, "\n") return fm, body, nil } func assign(spec *AgentSpec, key, value string) error { switch key { case "name": spec.Name = value case "description": spec.Description = value case "tools": spec.Tools = splitList(value) case "disallowedTools": spec.DisallowedTools = splitList(value) case "model": spec.Model = value case "permissionMode": spec.PermissionMode = value case "skills": spec.Skills = splitList(value) case "maxTurns": n, err := strconv.Atoi(value) if err != nil || n < 0 { return fmt.Errorf("agentspec: maxTurns %q 须为非负整数", value) } spec.MaxTurns = n case "memoryScope": spec.MemoryScope = value case "effort": spec.Effort = value case "background": b, err := parseBool(key, value) if err != nil { return err } spec.Background = b case "worktreeIsolation": b, err := parseBool(key, value) if err != nil { return err } spec.WorktreeIsolation = b case "outputSchema": spec.OutputSchema = value default: return fmt.Errorf("agentspec: 未知键 %q", key) } return nil } func parseBool(key, value string) (bool, error) { switch value { case "true": return true, nil case "false": return false, nil default: return false, fmt.Errorf("agentspec: %s %q 须为 true/false", key, value) } } func splitList(value string) []string { if strings.TrimSpace(value) == "" { return nil } parts := strings.Split(value, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if s := strings.TrimSpace(p); s != "" { out = append(out, s) } } return out } func validName(name string) bool { if name == "" || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") { return false } for _, seg := range strings.Split(name, "-") { if seg == "" { return false } for _, r := range seg { if (r < 'a' || r > 'z') && (r < '0' || r > '9') { return false } } } return true }