- 落点=Dispatcher.Invoke 入口(唯一执行口,覆盖主循环/子代理/编排全部调用方) - 语义=**不硬阻断**:越界调用照常执行,把 not_allowed 提示与软判理由挂到真实结果上回灌模型 (符合 design/14 §1「状态改变不构成能力边界、紧急时也能调」与 §4「入口前置软判」) - 允许集单一真源:loop 发布 capacity 过滤后的 schema 名单(= 模型本轮实际看到的工具面), 分发器不在本地另算一套判定 - 审计:复用既有 AuditEvent/OnAudit 通道(what=tool.reject.not_allowed,design/14 §4 原文), 前端小字与审计日志同源;日志带 source=runtime/tool - ctx 载体:Run 入口每 Run 播种允许集持有者(run.go +6 行,越界但必需——ctx 只能由 Run 或 request.go 播种,后者归 G5-A) - 与既有硬闸门零重叠:restrict(群聊/陌生人)与 ToolWhitelist(子代理)仍在更早处硬拦 + 各自审计 - 复核:`go build`/`go vet`/tool+loop 包测试绿,`-race` 下 SoftJudge/AllowSet 无竞态; 变异 M1(软判恒空)4 例红、M2(不发布允许集)仅端到端 1 例红 ⇒ 两侧各被独立覆盖
220 lines
7.3 KiB
Go
220 lines
7.3 KiB
Go
package tool
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
)
|
||
|
||
// softTool 是软判用例的夹具:它记录自己是否真的被执行过,
|
||
// 使「软判不硬阻断」可以被直接断言,而不是靠返回值反推。
|
||
type softTool struct {
|
||
name string
|
||
mu sync.Mutex
|
||
ran int
|
||
}
|
||
|
||
func (s *softTool) Name() string { return s.name }
|
||
func (s *softTool) Description() string { return "soft judge fixture " + s.name }
|
||
|
||
func (s *softTool) Parameters() (map[string]any, error) {
|
||
return map[string]any{"type": "object", "properties": map[string]any{}}, nil
|
||
}
|
||
|
||
func (s *softTool) Execute(_ context.Context, _ any) (any, error) {
|
||
s.mu.Lock()
|
||
s.ran++
|
||
s.mu.Unlock()
|
||
return "ran:" + s.name, nil
|
||
}
|
||
|
||
// runs 返回该工具被执行过的次数。
|
||
func (s *softTool) runs() int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.ran
|
||
}
|
||
|
||
// softFixture 构建一个带两个工具的 dispatcher:terminal 在允许集内,
|
||
// web-search 在外,并返回二者与软判记录收集器。
|
||
func softFixture(t *testing.T, opts ...Option) (*Dispatcher, *softTool, *softTool, func() []SoftJudgeEvent) {
|
||
t.Helper()
|
||
d, _ := newDispatcher(t, opts...)
|
||
inside := &softTool{name: "terminal"}
|
||
outside := &softTool{name: "web-search"}
|
||
for _, tk := range []*softTool{inside, outside} {
|
||
if err := d.Register(tk); err != nil {
|
||
t.Fatalf("Register %q: %v", tk.name, err)
|
||
}
|
||
}
|
||
var mu sync.Mutex
|
||
var events []SoftJudgeEvent
|
||
d.SetSoftJudgeSink(func(_ context.Context, ev SoftJudgeEvent) {
|
||
mu.Lock()
|
||
events = append(events, ev)
|
||
mu.Unlock()
|
||
})
|
||
read := func() []SoftJudgeEvent {
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
return append([]SoftJudgeEvent(nil), events...)
|
||
}
|
||
return d, inside, outside, read
|
||
}
|
||
|
||
// TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked 锁住裁决 14.4.2
|
||
// 的「软判并回」:越出本轮允许集的调用不被硬阻断(确实执行了,
|
||
// 能力≠边界),但结果里显式带上 not_allowed 提示回灌给模型,
|
||
// 并留下一条可观测的判定记录(sink + 带来源的日志见下一个用例)。
|
||
func TestInvoke_OutsideAllowSet_SoftNotAllowedNotBlocked(t *testing.T) {
|
||
t.Parallel()
|
||
d, _, outside, events := softFixture(t)
|
||
|
||
ctx := WithRoundAllowSet(context.Background())
|
||
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
|
||
|
||
got, err := d.Invoke(ctx, "web-search", map[string]any{"q": "天气"})
|
||
if err != nil {
|
||
t.Fatalf("Invoke outside the allow set must not hard-block, got %v", err)
|
||
}
|
||
if outside.runs() != 1 {
|
||
t.Fatalf("tool ran %d times, want 1 (软判不得阻断执行)", outside.runs())
|
||
}
|
||
soft, ok := got.(SoftNotAllowed)
|
||
if !ok {
|
||
t.Fatalf("result = %#v, want SoftNotAllowed(软结果并回)", got)
|
||
}
|
||
if soft.Tool != "web-search" {
|
||
t.Fatalf("soft result tool = %q, want web-search", soft.Tool)
|
||
}
|
||
if soft.Result != "ran:web-search" {
|
||
t.Fatalf("soft result payload = %#v, want the real tool result", soft.Result)
|
||
}
|
||
for _, want := range []string{"not_allowed", "web-search", "capacity:quiet"} {
|
||
if !strings.Contains(soft.NotAllowed, want) {
|
||
t.Fatalf("notice %q missing %q", soft.NotAllowed, want)
|
||
}
|
||
}
|
||
read := events()
|
||
if len(read) != 1 {
|
||
t.Fatalf("soft judge events = %d, want 1 (%+v)", len(read), read)
|
||
}
|
||
if read[0].Tool != "web-search" || read[0].Source != "capacity:quiet" {
|
||
t.Fatalf("event = %+v, want tool/source recorded", read[0])
|
||
}
|
||
if read[0].Reason != soft.NotAllowed {
|
||
t.Fatalf("event reason = %q, want the model-facing notice %q", read[0].Reason, soft.NotAllowed)
|
||
}
|
||
}
|
||
|
||
// TestInvoke_InsideAllowSet_ResultUntouched 证明允许集内的调用
|
||
// 逐字节保持既有行为:原始结果、不包装、无软判记录。
|
||
func TestInvoke_InsideAllowSet_ResultUntouched(t *testing.T) {
|
||
t.Parallel()
|
||
d, inside, _, events := softFixture(t)
|
||
|
||
ctx := WithRoundAllowSet(context.Background())
|
||
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
|
||
|
||
got, err := d.Invoke(ctx, "terminal", map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Invoke: %v", err)
|
||
}
|
||
if got != "ran:terminal" {
|
||
t.Fatalf("result = %#v, want the raw tool result", got)
|
||
}
|
||
if inside.runs() != 1 {
|
||
t.Fatalf("tool ran %d times, want 1", inside.runs())
|
||
}
|
||
if n := len(events()); n != 0 {
|
||
t.Fatalf("soft judge events = %d, want 0 for an allowed call", n)
|
||
}
|
||
}
|
||
|
||
// TestInvoke_NoAllowSetPublished_Unchanged 证明「未发布允许集」语义
|
||
// 为不做软判:未播种,以及播种但从未发布,两条路径都与接通前一致。
|
||
func TestInvoke_NoAllowSetPublished_Unchanged(t *testing.T) {
|
||
t.Parallel()
|
||
d, _, _, events := softFixture(t)
|
||
|
||
// ① 完全没播种。
|
||
got, err := d.Invoke(context.Background(), "web-search", map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("Invoke: %v", err)
|
||
}
|
||
if got != "ran:web-search" {
|
||
t.Fatalf("result = %#v, want the raw tool result", got)
|
||
}
|
||
// ② 播种了但本轮没有发布(例如不经 capacity 过滤的内联流程)。
|
||
seeded := WithRoundAllowSet(context.Background())
|
||
if _, err := d.Invoke(seeded, "web-search", map[string]any{}); err != nil {
|
||
t.Fatalf("Invoke: %v", err)
|
||
}
|
||
if n := len(events()); n != 0 {
|
||
t.Fatalf("soft judge events = %d, want 0 without a published allow set", n)
|
||
}
|
||
}
|
||
|
||
// TestSoftJudge_LogsWithSource 证明软判留下可观测的判定记录,
|
||
// 且日志带模块来源(项目规范 §10),不是悄悄放行。
|
||
func TestSoftJudge_LogsWithSource(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
d, _, _, _ := softFixture(t, WithLogger(slog.New(slog.NewJSONHandler(&buf, nil))))
|
||
|
||
ctx := WithRoundAllowSet(context.Background())
|
||
PublishRoundAllowSet(ctx, []string{"terminal"}, "capacity:quiet")
|
||
if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil {
|
||
t.Fatalf("Invoke: %v", err)
|
||
}
|
||
|
||
line := buf.String()
|
||
for _, want := range []string{`"source":"runtime/tool"`, "web-search", "capacity:quiet", "soft judge"} {
|
||
if !strings.Contains(line, want) {
|
||
t.Fatalf("log %q missing %q", line, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestPublishRoundAllowSet_CopiesNames 证明发布时会复制名单:
|
||
// 调用方随后改自己那份切片,不得影响已发布的允许集。
|
||
func TestPublishRoundAllowSet_CopiesNames(t *testing.T) {
|
||
t.Parallel()
|
||
d, _, _, events := softFixture(t)
|
||
|
||
names := []string{"terminal"}
|
||
ctx := WithRoundAllowSet(context.Background())
|
||
PublishRoundAllowSet(ctx, names, "capacity:quiet")
|
||
names[0] = "web-search"
|
||
|
||
// 允许集仍是 [terminal],因此 web-search 依旧越界。
|
||
if _, err := d.Invoke(ctx, "web-search", map[string]any{}); err != nil {
|
||
t.Fatalf("Invoke: %v", err)
|
||
}
|
||
if n := len(events()); n != 1 {
|
||
t.Fatalf("soft judge events = %d, want 1 (published names must be a copy)", n)
|
||
}
|
||
}
|
||
|
||
// TestWithRoundAllowSet_FreshHolderPerRun 证明重新播种得到独立持有者:
|
||
// 子代理 Run 不得继承父级 Run 已发布的允许集(两者的工具面不同)。
|
||
func TestWithRoundAllowSet_FreshHolderPerRun(t *testing.T) {
|
||
t.Parallel()
|
||
outer := WithRoundAllowSet(context.Background())
|
||
PublishRoundAllowSet(outer, []string{"terminal"}, "capacity:quiet")
|
||
|
||
inner := WithRoundAllowSet(outer)
|
||
if _, _, ok := roundAllowSetFrom(inner); ok {
|
||
t.Fatal("nested run must not inherit the parent's published allow set")
|
||
}
|
||
if names, source, ok := roundAllowSetFrom(outer); !ok || len(names) != 1 || source != "capacity:quiet" {
|
||
t.Fatalf("outer allow set = %v/%q/%v, want it untouched", names, source, ok)
|
||
}
|
||
if WithRoundAllowSet(nil) != nil {
|
||
t.Fatal("WithRoundAllowSet(nil) must stay nil")
|
||
}
|
||
}
|