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
This commit is contained in:
@@ -77,6 +77,9 @@ type GateOptions struct {
|
||||
Hooks *hooks.Bus
|
||||
// Ask 在 MCP 工具要求用户输入时调用。nil 则把问题作为错误返回。
|
||||
Ask func(ctx context.Context, question string) (string, error)
|
||||
// PermissionAsk 在内核钩子对工具表态 ask 时向宿主请求裁决。
|
||||
// nil 时内核保持旧行为:ask 与 deny 一样按拒绝处理。
|
||||
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||||
}
|
||||
|
||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||
@@ -370,6 +373,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Reminders: b.opts.Reminders,
|
||||
RepeatGuard: b.opts.RepeatGuard,
|
||||
Hooks: gateBus(b.opts.GateOptions),
|
||||
PermissionAsk: b.opts.PermissionAsk,
|
||||
Trace: b.opts.Trace,
|
||||
OnEvent: func(ev runtime.Event) {
|
||||
logs.WriteString(ev.Type)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package agentspec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const goodSpec = `---
|
||||
name: doc-reviewer
|
||||
description: 评审体系文档章节
|
||||
tools: read_file, grep, write_file
|
||||
disallowedTools: run_shell
|
||||
model: lightweight
|
||||
skills: house-style, rdm-review
|
||||
maxTurns: 8
|
||||
memoryScope: project
|
||||
effort: high
|
||||
background: true
|
||||
worktreeIsolation: false
|
||||
outputSchema: review-result
|
||||
---
|
||||
你是章节评审代理。逐条核对来源标注。
|
||||
`
|
||||
|
||||
func TestParseFullFields(t *testing.T) {
|
||||
spec, err := Parse(goodSpec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Name != "doc-reviewer" || spec.Description == "" {
|
||||
t.Fatalf("identity = %+v", spec)
|
||||
}
|
||||
if len(spec.Tools) != 3 || spec.Tools[2] != "write_file" {
|
||||
t.Fatalf("tools = %v", spec.Tools)
|
||||
}
|
||||
if len(spec.DisallowedTools) != 1 || spec.DisallowedTools[0] != "run_shell" {
|
||||
t.Fatalf("disallowed = %v", spec.DisallowedTools)
|
||||
}
|
||||
if spec.Model != "lightweight" || spec.MaxTurns != 8 || spec.MemoryScope != "project" || spec.Effort != "high" {
|
||||
t.Fatalf("scalars = %+v", spec)
|
||||
}
|
||||
if !spec.Background || spec.WorktreeIsolation {
|
||||
t.Fatalf("bools = %+v", spec)
|
||||
}
|
||||
if len(spec.Skills) != 2 || spec.OutputSchema != "review-result" {
|
||||
t.Fatalf("skills/schema = %+v", spec)
|
||||
}
|
||||
if spec.Prompt == "" {
|
||||
t.Fatal("prompt body empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDefaultsAndMinimal(t *testing.T) {
|
||||
spec, err := Parse("---\nname: helper\n---\n做杂事。\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.MaxTurns != 0 || spec.Background || len(spec.Tools) != 0 || spec.PermissionMode != "" {
|
||||
t.Fatalf("defaults = %+v", spec)
|
||||
}
|
||||
if spec.Prompt != "做杂事。" {
|
||||
t.Fatalf("prompt = %q", spec.Prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsBadInputs(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"missing frontmatter": "name: x\n---\n正文\n",
|
||||
"unclosed frontmatter": "---\nname: x\n正文\n",
|
||||
"unknown key": "---\nname: x\nbogus: 1\n---\n正文\n",
|
||||
"missing name": "---\ndescription: x\n---\n正文\n",
|
||||
"bad name uppercase": "---\nname: Bad\n---\n正文\n",
|
||||
"bad name underscore": "---\nname: has_under\n---\n正文\n",
|
||||
"bad maxTurns": "---\nname: x\nmaxTurns: many\n---\n正文\n",
|
||||
"bad bool": "---\nname: x\nbackground: yes\n---\n正文\n",
|
||||
"malformed line": "---\nname: x\nno-colon-here\n---\n正文\n",
|
||||
}
|
||||
for label, content := range cases {
|
||||
if _, err := Parse(content); err == nil {
|
||||
t.Fatalf("%s: expected error", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePluginBansHostOnlyKeys(t *testing.T) {
|
||||
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
||||
content := "---\nname: plug-agent\n" + key + ": anything\n---\n正文\n"
|
||||
if _, err := ParsePlugin(content); err == nil {
|
||||
t.Fatalf("plugin key %s: expected rejection", key)
|
||||
} else if got := err.Error(); !strings.Contains(got, "不得声明 "+key) {
|
||||
t.Fatalf("plugin key %s: error %q 未定位到字段", key, got)
|
||||
}
|
||||
}
|
||||
// 宿主来源不受此限:permissionMode 在 Parse 下可用。
|
||||
if _, err := Parse("---\nname: host-agent\npermissionMode: plan\n---\n正文\n"); err != nil {
|
||||
t.Fatalf("host parse with permissionMode: %v", err)
|
||||
}
|
||||
// 插件的合法字段照常解析。
|
||||
spec, err := ParsePlugin("---\nname: plug-agent\ntools: read_file\n---\n正文\n")
|
||||
if err != nil || len(spec.Tools) != 1 {
|
||||
t.Fatalf("plugin good spec: %+v err=%v", spec, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDirAtomicAndDuplicate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("a.md", "---\nname: alpha\n---\nA\n")
|
||||
write("b.md", "---\nname: beta\n---\nB\n")
|
||||
got, err := LoadDir(dir, nil)
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("load = %d err=%v", len(got), err)
|
||||
}
|
||||
|
||||
write("c.md", "---\nname: alpha\n---\nC\n")
|
||||
if _, err := LoadDir(dir, nil); err == nil {
|
||||
t.Fatal("duplicate name accepted")
|
||||
}
|
||||
os.Remove(filepath.Join(dir, "c.md"))
|
||||
|
||||
write("bad.md", "---\nname: x\nbogus: 1\n---\n")
|
||||
if _, err := LoadDir(dir, nil); err == nil {
|
||||
t.Fatal("bad file did not fail the batch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package guardrails 提供三类语义化护栏端口:输入(只作用于入口智能体)、
|
||||
// 输出(只作用于给出最终结果的智能体)、工具(每次工具调用)。
|
||||
// 护栏以钩子总线注册:它是类型化语义层,钩子仍是通用事件层。
|
||||
// 当前形态护栏拿不到运行 ctx(事件载荷不含它);需要取消语义时直接写钩子。
|
||||
package guardrails
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
)
|
||||
|
||||
// Verdict 是一次护栏表态。空 Decision 表示不插手。
|
||||
// Deny 拒绝(Output 层表现为要求模型改进后继续);Modify 改写载荷
|
||||
// (Input 改用户输入、Output 改最终文本、Tool 改参数);Reason 附说明。
|
||||
type Verdict struct {
|
||||
Decision hooks.Decision
|
||||
Reason string
|
||||
Replacement string
|
||||
}
|
||||
|
||||
type InputGuardrail func(ctx context.Context, text string) Verdict
|
||||
type OutputGuardrail func(ctx context.Context, text string) Verdict
|
||||
type ToolGuardrail func(ctx context.Context, name, args string) Verdict
|
||||
|
||||
// Set 是一组护栏;nil 字段表示该层不启用。
|
||||
type Set struct {
|
||||
Input InputGuardrail
|
||||
Output OutputGuardrail
|
||||
Tool ToolGuardrail
|
||||
}
|
||||
|
||||
// Scope 标注本 run 的角色:Entry=本 run 处理入口用户输入;
|
||||
// Final=本 run 给出最终答案。子代理 run 两者都为 false,
|
||||
// Input/Output 护栏不会作用于它;Tool 护栏作用于一切工具调用。
|
||||
type Scope struct {
|
||||
Entry bool
|
||||
Final bool
|
||||
}
|
||||
|
||||
// Register 把护栏挂到总线。语义:
|
||||
// - Input(仅 scope.Entry):挂 UserPromptSubmit。deny → 循环不启动,
|
||||
// 以理由直接收尾;modify → 改写后的输入追加为新的用户消息(原文留痕)。
|
||||
// - Output(仅 scope.Final):挂 Stop。deny → 视同续跑,理由交给模型改进
|
||||
// (计入内核续跑上限,达上限后收尾并附 Notice);modify → 替换最终文本并续跑。
|
||||
// - Tool:挂 PreToolUse,与其他钩子按既有优先级合并
|
||||
// (deny/block > ask > modify > allow);modify 的 Replacement 改写工具参数。
|
||||
func Register(bus *hooks.Bus, set Set, scope Scope) {
|
||||
if bus == nil {
|
||||
return
|
||||
}
|
||||
if set.Input != nil && scope.Entry {
|
||||
g := set.Input
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.UserPromptSubmit {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.LastText)
|
||||
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason, Replacement: v.Replacement}
|
||||
})
|
||||
}
|
||||
if set.Output != nil && scope.Final {
|
||||
g := set.Output
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.Stop {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.LastText)
|
||||
switch v.Decision {
|
||||
case hooks.Deny, hooks.Block:
|
||||
// 对 Stop 事件而言「拒绝最终答案」= 要求模型改进后再来一轮。
|
||||
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason}
|
||||
case hooks.Modify:
|
||||
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason, Replacement: v.Replacement}
|
||||
default:
|
||||
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||
}
|
||||
})
|
||||
}
|
||||
if set.Tool != nil {
|
||||
g := set.Tool
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event != hooks.PreToolUse {
|
||||
return hooks.Outcome{}
|
||||
}
|
||||
v := g(context.Background(), c.Tool, c.Args)
|
||||
out := hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||
if v.Decision == hooks.Modify {
|
||||
out.Args = v.Replacement
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package guardrails
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
)
|
||||
|
||||
func TestToolGuardrailMergesOnPreToolUse(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||
if name == "run_shell" {
|
||||
return Verdict{Decision: hooks.Deny, Reason: "shell 被护栏禁用"}
|
||||
}
|
||||
return Verdict{}
|
||||
},
|
||||
}, Scope{})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "run_shell"})
|
||||
if out.Decision != hooks.Deny || out.Reason == "" {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
out = bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "read_file"})
|
||||
if out.Decision != "" {
|
||||
t.Fatalf("read_file out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGuardrailModifyRewritesArgs(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||
return Verdict{Decision: hooks.Modify, Replacement: `{"path":"safe.txt"}`}
|
||||
},
|
||||
}, Scope{})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "write_file"})
|
||||
if out.Decision != hooks.Modify || out.Args != `{"path":"safe.txt"}` {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputGuardrailDenyBecomesContinuation(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
Register(bus, Set{
|
||||
Output: func(ctx context.Context, text string) Verdict {
|
||||
return Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||||
},
|
||||
}, Scope{Final: true})
|
||||
out := bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "结论"})
|
||||
// Output deny 必须表达为续跑(Block),而不是把 run 掐死。
|
||||
if out.Decision != hooks.Block || out.Reason != "缺少来源标注" {
|
||||
t.Fatalf("out = %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeGatesInputAndOutput(t *testing.T) {
|
||||
fired := map[string]bool{}
|
||||
mk := func() Set {
|
||||
return Set{
|
||||
Input: func(ctx context.Context, text string) Verdict { fired["input"] = true; return Verdict{} },
|
||||
Output: func(ctx context.Context, text string) Verdict { fired["output"] = true; return Verdict{} },
|
||||
}
|
||||
}
|
||||
// 子代理 run(Entry/Final 都为 false):Input/Output 不注册。
|
||||
bus := hooks.New()
|
||||
Register(bus, mk(), Scope{})
|
||||
bus.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||
bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||
if fired["input"] || fired["output"] {
|
||||
t.Fatalf("sub-run scope fired: %v", fired)
|
||||
}
|
||||
// 入口且最终:两层都触发。
|
||||
bus2 := hooks.New()
|
||||
Register(bus2, mk(), Scope{Entry: true, Final: true})
|
||||
bus2.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||
bus2.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||
if !fired["input"] || !fired["output"] {
|
||||
t.Fatalf("entry/final scope did not fire: %v", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBusIsNoop(t *testing.T) {
|
||||
Register(nil, Set{
|
||||
Input: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
Output: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
Tool: func(ctx context.Context, name, args string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||
}, Scope{Entry: true, Final: true})
|
||||
}
|
||||
+21
-1
@@ -204,7 +204,27 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
messages = append(messages, user)
|
||||
emit(cfg, Event{Type: "agent_start"})
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionStart})
|
||||
cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content})
|
||||
if out := cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content}); out.Decision != "" {
|
||||
switch out.Decision {
|
||||
case hooks.Deny, hooks.Block:
|
||||
// 输入护栏拒绝:不进入循环,以理由直接收尾(原文已持久化留痕)。
|
||||
why := out.Reason
|
||||
if why == "" {
|
||||
why = "输入被护栏拒绝"
|
||||
}
|
||||
return Result{Text: why, Messages: messages, StopReason: llm.StopEnd, Notice: why}, nil
|
||||
case hooks.Modify:
|
||||
// 改写后的输入追加为新的用户消息(原文保留,可审计)。
|
||||
if out.Replacement != "" {
|
||||
note := llm.Message{Role: llm.RoleUser, Content: out.Replacement}
|
||||
if err := persist(cfg, note); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
messages = append(messages, note)
|
||||
user = note
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var last llm.Message
|
||||
var usage llm.Usage
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/guardrails"
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// TestInputGuardrailDenySkipsLoop:输入护栏 deny 时不进循环、不调模型,
|
||||
// 以理由直接收尾;原文已进转录(留痕)。
|
||||
func TestInputGuardrailDenySkipsLoop(t *testing.T) {
|
||||
calls := 0
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "含敏感指令"}
|
||||
},
|
||||
}, guardrails.Scope{Entry: true})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "bad input"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("model called %d times after input deny", calls)
|
||||
}
|
||||
if res.Text != "含敏感指令" {
|
||||
t.Fatalf("result text = %q", res.Text)
|
||||
}
|
||||
if !strings.Contains(transcriptText(res.Messages), "bad input") {
|
||||
t.Fatal("original input missing from transcript")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInputGuardrailModifyAppendsRewrite:modify 时改写内容追加为新的用户消息,
|
||||
// 模型最终看到的是改写后的输入;原文保留在转录里。
|
||||
func TestInputGuardrailModifyAppendsRewrite(t *testing.T) {
|
||||
var saw string
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Modify, Replacement: "改写后的输入"}
|
||||
},
|
||||
}, guardrails.Scope{Entry: true})
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
saw = transcriptText(messages)
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "原始输入"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(saw, "原始输入") || !strings.Contains(saw, "改写后的输入") {
|
||||
t.Fatalf("request messages = %q", saw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutputGuardrailDenyContinuesThenCaps:输出护栏 deny 视同续跑,
|
||||
// 理由进转录;达内核续跑上限后收尾。
|
||||
func TestOutputGuardrailDenyContinuesThenCaps(t *testing.T) {
|
||||
calls := 0
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Output: func(ctx context.Context, text string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||||
},
|
||||
}, guardrails.Scope{Final: true})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
MaxTurns: 8,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v1", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v2", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v3", StopReason: llm.StopEnd},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "v4", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = calls
|
||||
if strings.Count(transcriptText(res.Messages), "缺少来源标注") != 3 {
|
||||
t.Fatalf("reason count = %s", transcriptText(res.Messages))
|
||||
}
|
||||
if res.Text != "v4" {
|
||||
t.Fatalf("final text = %q", res.Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolGuardrailDenyPairsResult:工具护栏 deny 写配对错误结果且不执行工具。
|
||||
func TestToolGuardrailDenyPairsResult(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Tool: func(ctx context.Context, name, args string) guardrails.Verdict {
|
||||
return guardrails.Verdict{Decision: hooks.Deny, Reason: "护栏禁用"}
|
||||
},
|
||||
}, guardrails.Scope{})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool executed %d times", hits)
|
||||
}
|
||||
paired := false
|
||||
for _, m := range res.Messages {
|
||||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "护栏禁用" {
|
||||
paired = true
|
||||
}
|
||||
}
|
||||
if !paired {
|
||||
t.Fatalf("missing paired result: %s", transcriptText(res.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubRunScopeSkipsInputOutput:子代理 run(Entry/Final=false)注册同名护栏也不触发。
|
||||
func TestSubRunScopeSkipsInputOutput(t *testing.T) {
|
||||
bus := hooks.New()
|
||||
guardrails.Register(bus, guardrails.Set{
|
||||
Input: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "x"} },
|
||||
Output: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "y"} },
|
||||
}, guardrails.Scope{})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Hooks: bus,
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Text != "ok" {
|
||||
t.Fatalf("sub-scope run text = %q", res.Text)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
@@ -42,14 +43,23 @@ type Result struct {
|
||||
// Factory 由宿主注入;ctx 取消必须传播到子执行。
|
||||
type Factory func(ctx context.Context, req Request) (Result, error)
|
||||
|
||||
type delegateTool struct{ factory Factory }
|
||||
type delegateTool struct {
|
||||
factory Factory
|
||||
bus *hooks.Bus
|
||||
}
|
||||
|
||||
// Register 把 delegate_task 工具注册进注册表(factory 为 nil 时不注册)。
|
||||
func Register(r *tools.Registry, factory Factory) {
|
||||
RegisterWithHooks(r, factory, nil)
|
||||
}
|
||||
|
||||
// RegisterWithHooks 同 Register;bus 非空时,每次委派执行前后分别发出
|
||||
// SubagentStart / SubagentStop 事件(Start 带目标摘要,Stop 带成败标记)。
|
||||
func RegisterWithHooks(r *tools.Registry, factory Factory, bus *hooks.Bus) {
|
||||
if factory == nil {
|
||||
return
|
||||
}
|
||||
r.Register(&delegateTool{factory: factory})
|
||||
r.Register(&delegateTool{factory: factory, bus: bus})
|
||||
}
|
||||
|
||||
func (t *delegateTool) Name() string { return "delegate_task" }
|
||||
@@ -78,7 +88,18 @@ func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools
|
||||
if ctx.Err() != nil {
|
||||
return tools.Result{Content: "delegation canceled before start: " + ctx.Err().Error(), IsError: true}, nil
|
||||
}
|
||||
if t.bus != nil {
|
||||
goal := req.Goal
|
||||
if len(goal) > 200 {
|
||||
goal = goal[:200] + "…"
|
||||
}
|
||||
t.bus.Emit(hooks.Call{Event: hooks.SubagentStart, Tool: "delegate_task", LastText: goal})
|
||||
}
|
||||
res, err := t.factory(ctx, req)
|
||||
if t.bus != nil {
|
||||
failed := err != nil || !res.Success
|
||||
t.bus.Emit(hooks.Call{Event: hooks.SubagentStop, Tool: "delegate_task", IsError: failed})
|
||||
}
|
||||
if err != nil {
|
||||
return tools.Result{Content: fmt.Sprintf("sub-agent failed: %v", err), IsError: true}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package subagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
func TestDelegateEmitsStartAndStop(t *testing.T) {
|
||||
var seen []hooks.Event
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
seen = append(seen, c.Event)
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
RegisterWithHooks(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: true, Text: "done"}, nil
|
||||
}, bus)
|
||||
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"核对第三章来源","done_criteria":"逐条标注"}`))
|
||||
if res.IsError {
|
||||
t.Fatalf("delegate result: %s", res.Content)
|
||||
}
|
||||
if len(seen) != 2 || seen[0] != hooks.SubagentStart || seen[1] != hooks.SubagentStop {
|
||||
t.Fatalf("events = %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelegateStopMarksFailure(t *testing.T) {
|
||||
failed := false
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.SubagentStop {
|
||||
failed = c.IsError
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
RegisterWithHooks(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: false, Text: "nope"}, nil
|
||||
}, bus)
|
||||
reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"x","done_criteria":"y"}`))
|
||||
if !failed {
|
||||
t.Fatal("stop event did not mark the failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithoutBusKeepsSilent(t *testing.T) {
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
Register(reg, func(ctx context.Context, req Request) (Result, error) {
|
||||
return Result{Success: true, Text: "ok"}, nil
|
||||
})
|
||||
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"x","done_criteria":"y"}`))
|
||||
if res.IsError {
|
||||
t.Fatalf("result: %s", res.Content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user