Files
Cyrene_v2/internal/runtime/tool/allowset_test.go
T
AskaEth 38b4527949
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(tool,loop): 工具调用软判并回(裁决 14.4.2)
- 落点=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 例红 ⇒ 两侧各被独立覆盖
2026-10-04 12:45:48 +08:00

220 lines
7.3 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 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")
}
}