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 例红 ⇒ 两侧各被独立覆盖
This commit is contained in:
1 parent
3972ea4007
commit
38b4527949
6 files changed
+675
-4
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,11 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// 会话 id 搭同一个 context,使 capacity_awaken 的发出能
|
||||
// 定向到产出它的会话(design/14 §5)。
|
||||
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
|
||||
// 同时播种该 Run 的「本轮工具允许集」持有者(design/14 §4,裁决
|
||||
// 14.4.2):每轮由 restrictSchemas 发布模型可见的工具面,
|
||||
// Dispatcher.Invoke 据此做前置软判。作用域同样限于本次 Run,
|
||||
// 子代理 Run 各自播种一份,并发 run 绝不共享。
|
||||
ctx = tool.WithRoundAllowSet(ctx)
|
||||
ctx = withLoopSession(ctx, sessionID)
|
||||
|
||||
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// auditToolRejectNotAllowed 是群聊/陌生人环境中被
|
||||
// design/11 §3 安全门拒绝的工具调用的审计 "what"。
|
||||
// auditToolRejectNotAllowed 是「工具调用越出允许面」的审计 "what":
|
||||
// 群聊/陌生人环境中被 design/11 §3 安全门拒绝的调用,以及
|
||||
// capacity 允许集之外的越界调用(design/14 §4,裁决 14.4.2)共用它,
|
||||
// 因此审计轨迹与前端小字两侧不必各认一套标签。
|
||||
const auditToolRejectNotAllowed = "tool.reject.not_allowed"
|
||||
|
||||
// AuditEvent 向审计 sink 上报被安全门拦截的工具拒绝
|
||||
@@ -17,12 +19,16 @@ const auditToolRejectNotAllowed = "tool.reject.not_allowed"
|
||||
// 工具调用时发出一个,使该拒绝可审计,且
|
||||
// 前端可以渲染「xx 工具在群聊被拒」的小字。它经
|
||||
// Options.OnAudit 接到 gateway 审计轨迹。
|
||||
//
|
||||
// capacity 允许集之外的越界调用也走同一通道(见 softJudgeAudit):
|
||||
// 区别只在 Result 文案 —— 安全门写的是拒绝,软判写的是被让行。
|
||||
type AuditEvent struct {
|
||||
// Who 是实施行为的 actor 环境标签("group_member"/"stranger")。
|
||||
// Who 是实施行为的 actor 环境标签("group_member"/"stranger"/"owner")。
|
||||
Who string
|
||||
// What 是操作,如 "tool.reject.not_allowed"。
|
||||
What string
|
||||
// Result 是结果详情 —— 指名被拒工具的 not_allowed 提示。
|
||||
// Result 是结果详情 —— 被拦调用的 not_allowed 提示,或越界调用
|
||||
// 被让行时的 not_allowed 软提示。
|
||||
Result string
|
||||
}
|
||||
|
||||
@@ -32,7 +38,13 @@ type AuditEvent struct {
|
||||
// 关闭),而 owner 环境保留全集。它是对 capacity
|
||||
// 允许集视图的最终裁决 —— 是安全红线而非便利
|
||||
// 提示 —— 因此它最后运行,且只能收缩暴露面。
|
||||
//
|
||||
// 它同时把**收缩前**的工具面(即 capacity 过滤后的本轮允许集)
|
||||
// 发布到该 Run 的允许集持有者上,供 Dispatcher.Invoke 的前置软判
|
||||
// 使用(design/14 §4,裁决 14.4.2);env 安全门是硬红线,不在软判
|
||||
// 范围内(它拦下的调用在 Invoke 之前就已跳过)。
|
||||
func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) []model.ToolSpec {
|
||||
l.publishRoundAllowSet(ctx, d, schemas)
|
||||
policy := d.PolicyEngine(sessionEnvFrom(ctx).ActorEnv)
|
||||
kept := make([]model.ToolSpec, 0, len(schemas))
|
||||
for _, s := range schemas {
|
||||
@@ -43,6 +55,47 @@ func (l *Loop) restrictSchemas(ctx context.Context, d *tool.Dispatcher, schemas
|
||||
return kept
|
||||
}
|
||||
|
||||
// publishRoundAllowSet 把本轮模型可见的工具面发布到该 Run 的允许集
|
||||
// 持有者上,并把分发器的软判 sink 接进本循环的审计通道
|
||||
// (design/14 §4,裁决 14.4.2)。发布的是 capacity 过滤后的 schema
|
||||
// 名单 —— 判定「这次调用是否越界」以模型实际看到的那份工具面为准,
|
||||
// 不另算第二套策略。ctx 未播种持有者(不经 capacity 过滤的内联流程)
|
||||
// 或 dispatcher 缺失时为空操作。
|
||||
func (l *Loop) publishRoundAllowSet(ctx context.Context, d *tool.Dispatcher, schemas []model.ToolSpec) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(schemas))
|
||||
for _, s := range schemas {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
tool.PublishRoundAllowSet(ctx, names, l.allowSetSource(ctx))
|
||||
d.SetSoftJudgeSink(l.softJudgeAudit)
|
||||
}
|
||||
|
||||
// allowSetSource 是本轮允许集的来源标签,只用于软判日志与提示文案,
|
||||
// 使越界调用看得出是哪个 capacity 态收窄了工具面;capacity 未启用时
|
||||
// 标注 off(此时工具面没有被收敛,软判不会触发)。
|
||||
func (l *Loop) allowSetSource(ctx context.Context) string {
|
||||
if l.capacity == nil || !l.capacity.Enabled() {
|
||||
return "capacity:off"
|
||||
}
|
||||
return "capacity:" + string(capState(ctx).state)
|
||||
}
|
||||
|
||||
// softJudgeAudit 把 Dispatcher 的允许集软判记录接进既有审计通道
|
||||
// (design/11 §3 §8 的 AuditEvent → Options.OnAudit):与群聊/陌生人
|
||||
// 安全门复用同一个审计 "what" 与 sink,因此前端小字与审计轨迹都能
|
||||
// 看到越界调用。who 从该次调用的会话环境(actor 元数据)取,与安全门
|
||||
// 同一口径;result 写明调用是被让行而非被拒(文案由分发器给出)。
|
||||
func (l *Loop) softJudgeAudit(ctx context.Context, ev tool.SoftJudgeEvent) {
|
||||
l.emitAudit(AuditEvent{
|
||||
Who: string(sessionEnvFrom(ctx).ActorEnv),
|
||||
What: auditToolRejectNotAllowed,
|
||||
Result: ev.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
// applyToolSecurityGate 拦截所有工具在 ctx 会话环境中
|
||||
// 不被允许的调用(design/11 §3)。它就地把 actions 改写,
|
||||
// 把每个可运行的调用改为携带 not_allowed 提示的跳过,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user