Files
Cyrene_v2/internal/runtime/loop/tool_policy_soft_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

187 lines
6.4 KiB
Go

package loop
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited 锁住裁决 14.4.2
// 的「加软判并回」全链路:闲聊轮只广告 quiet 允许集,模型却调了
// 允许集外的 web-search —— 调用不被硬阻断(确实执行,能力≠边界),
// 但判定并回:not_allowed 提示随 tool result 回灌给模型,
// 同一条判定落进既有审计通道(design/11 §3 §8)。
func TestRun_OutsideAllowSet_SoftJudgeFedBackAndAudited(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_soft_1",
Name: "web-search",
Args: map[string]any{"q": "天气"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}}
td := dispatcherWith(t,
&mockTool{name: "terminal", result: "ok"},
&mockTool{name: "web-search", result: "web result"},
)
a := newTestAgent(t, ad, td)
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
Agent: a,
MaxRounds: 8,
Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}),
OnStream: func(model.StreamEvent) {},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
// ① 不硬阻断:越界调用确实执行了。
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1(软判不得阻断执行)", res.ToolCallsExecuted)
}
// ② 该轮模型只被广告了 quiet 允许集 —— 发布出去的允许集与它一致。
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
set := toolSet(ad.streamReqs[0].Tools)
if !hasAll(set, "terminal") || set["web-search"] {
t.Fatalf("quiet tools = %v, want terminal only", toolNames(ad.streamReqs[0].Tools))
}
// ③ 软判并回:模型收到的 tool result 带 not_allowed 提示与来源标签。
last := ad.streamReqs[len(ad.streamReqs)-1].Messages
msg := last[len(last)-1]
if msg.Role != model.RoleTool {
t.Fatalf("last message role = %q, want tool", msg.Role)
}
text := msg.Content[0].Text
for _, want := range []string{"not_allowed", "web-search", "capacity:quiet", "web result"} {
if !strings.Contains(text, want) {
t.Fatalf("tool result %q missing %q", text, want)
}
}
// ④ 可观测:判定落进既有审计通道,前端小字与审计轨迹都能看到。
mu.Lock()
defer mu.Unlock()
if len(audited) != 1 {
t.Fatalf("audit events = %d, want 1 (%+v)", len(audited), audited)
}
if audited[0].What != auditToolRejectNotAllowed {
t.Fatalf("audit what = %q, want %q", audited[0].What, auditToolRejectNotAllowed)
}
if audited[0].Who != "owner" {
t.Fatalf("audit who = %q, want owner", audited[0].Who)
}
for _, want := range []string{"not_allowed", "web-search"} {
if !strings.Contains(audited[0].Result, want) {
t.Fatalf("audit result = %q missing %q", audited[0].Result, want)
}
}
}
// TestRun_InsideAllowSet_NoSoftJudge 是对照组:调用就在本轮允许集内时,
// 不产生软判 —— 结果不被包装、也不落审计(软判只在越界时说话)。
func TestRun_InsideAllowSet_NoSoftJudge(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_soft_2",
Name: "terminal",
Args: map[string]any{},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}}
td := dispatcherWith(t,
&mockTool{name: "terminal", result: "ok"},
&mockTool{name: "web-search", result: "web result"},
)
a := newTestAgent(t, ad, td)
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
Agent: a,
MaxRounds: 8,
Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}),
OnStream: func(model.StreamEvent) {},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
last := ad.streamReqs[len(ad.streamReqs)-1].Messages
text := last[len(last)-1].Content[0].Text
if strings.Contains(text, "not_allowed") {
t.Fatalf("tool result %q must not carry a soft notice for an allowed call", text)
}
mu.Lock()
defer mu.Unlock()
if len(audited) != 0 {
t.Fatalf("audit events = %d, want 0 for an allowed call", len(audited))
}
}
// TestRun_CapacityDisabled_NoSoftJudge 守住 design/14 §1 红线:没有一个
// 允许集被收敛(capacity 未启用)时,调用任何工具都不产生软判 ——
// 软判只描述「本轮允许集」,绝不把决策层变成能力边界。
func TestRun_CapacityDisabled_NoSoftJudge(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_soft_3",
Name: "web-search",
Args: map[string]any{"q": "天气"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "done"}}}}
td := dispatcherWith(t,
&mockTool{name: "terminal", result: "ok"},
&mockTool{name: "web-search", result: "web result"},
)
a := newTestAgent(t, ad, td)
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
Agent: a,
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
last := ad.streamReqs[len(ad.streamReqs)-1].Messages
if text := last[len(last)-1].Content[0].Text; strings.Contains(text, "not_allowed") {
t.Fatalf("tool result %q must not carry a soft notice without an allow set", text)
}
mu.Lock()
defer mu.Unlock()
if len(audited) != 0 {
t.Fatalf("audit events = %d, want 0 without an allow set", len(audited))
}
}