Files
Agent-Native/runtime/loop_guardrails_test.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

169 lines
5.8 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 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)
}
}