Why: N1 increment three (WP1/WP4) - agents need a canonical spec format with plugin restrictions, hosts need typed input/output/tool guardrails, delegation needs lifecycle events, and squad adapters need to pass the permission ask port through. Evidence: go vet clean; go test ./... 26 packages ok, including new agentspec (12 tests), guardrails (5), loop_guardrails (5), and subagent hook tests (3). Impact: new runtime/agentspec and runtime/guardrails packages; loop consumes UserPromptSubmit outcomes (deny skips the loop, modify appends the rewrite); subagent gains RegisterWithHooks emitting SubagentStart/Stop around delegation; GateOptions gains PermissionAsk forwarded into the kernel config. All additions are opt-in: nil ports preserve prior behavior exactly. Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
217 lines
5.9 KiB
Go
217 lines
5.9 KiB
Go
// 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
|
||
}
|