diff --git a/adapters/native/backend.go b/adapters/native/backend.go index 8999e66..40a255d 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -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) diff --git a/runtime/agentspec/agentspec.go b/runtime/agentspec/agentspec.go new file mode 100644 index 0000000..9656ff5 --- /dev/null +++ b/runtime/agentspec/agentspec.go @@ -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 +} diff --git a/runtime/agentspec/agentspec_test.go b/runtime/agentspec/agentspec_test.go new file mode 100644 index 0000000..042f171 --- /dev/null +++ b/runtime/agentspec/agentspec_test.go @@ -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") + } +} diff --git a/runtime/guardrails/guardrails.go b/runtime/guardrails/guardrails.go new file mode 100644 index 0000000..bcb11cc --- /dev/null +++ b/runtime/guardrails/guardrails.go @@ -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 + }) + } +} diff --git a/runtime/guardrails/guardrails_test.go b/runtime/guardrails/guardrails_test.go new file mode 100644 index 0000000..2e39e4b --- /dev/null +++ b/runtime/guardrails/guardrails_test.go @@ -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}) +} diff --git a/runtime/loop.go b/runtime/loop.go index aa43ce2..a3df14d 100644 --- a/runtime/loop.go +++ b/runtime/loop.go @@ -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 diff --git a/runtime/loop_guardrails_test.go b/runtime/loop_guardrails_test.go new file mode 100644 index 0000000..ce5d9d4 --- /dev/null +++ b/runtime/loop_guardrails_test.go @@ -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) + } +} diff --git a/runtime/subagent/subagent.go b/runtime/subagent/subagent.go index 14a2d95..3da9297 100644 --- a/runtime/subagent/subagent.go +++ b/runtime/subagent/subagent.go @@ -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 } diff --git a/runtime/subagent/subagent_hooks_test.go b/runtime/subagent/subagent_hooks_test.go new file mode 100644 index 0000000..5cf4319 --- /dev/null +++ b/runtime/subagent/subagent_hooks_test.go @@ -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) + } +}