Files
Agent-Native/runtime/agentspec/agentspec.go
T
weichen 948ca36ca2
ci / vet-and-test (pull_request) Successful in 47s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add AgentSpec, three guardrail ports, subagent events, and ask passthrough
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
2026-10-07 05:04:58 +00:00

217 lines
5.9 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 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
}