- 落点=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 例红 ⇒ 两侧各被独立覆盖
172 lines
6.6 KiB
Go
172 lines
6.6 KiB
Go
package tool
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
)
|
|
|
|
// 本文件承载「本轮允许集」的软判通道(DevDocs/design/14-唤醒式能力渐进.md
|
|
// §4,裁决 14.4.2「加软判并回」)。
|
|
//
|
|
// 背景:capacity 决策层每轮只把允许集内的工具 schema 广告给模型,而
|
|
// Dispatcher.Invoke 不做硬过滤 —— 状态改变不构成能力边界,静息态遇到
|
|
// 紧急情况也必须能调任何工具(design/14 §1 红线)。于是「模型调了本轮
|
|
// 没点亮的工具」既不能拦、也不能当没发生:Invoke 入口据此做一次
|
|
// **前置软判** —— 记一条带来源的日志、把记录交给接线方(循环把它接进
|
|
// 既有审计通道),并把 not_allowed 提示随结果一起回灌给模型。
|
|
// 调用照常执行,软判绝不变成悄悄放行,也绝不硬阻断。
|
|
|
|
// roundAllowSet 是本 Run 的允许集持有者:由 Run 播种一次,每轮由循环
|
|
// 发布「本轮模型可见的工具面」。同一 Run 内轮次串行,因此单份持有者
|
|
// 足够;它经 ctx 随 Run 走,子代理 Run 各自播种一份,并发 Run 互不串扰。
|
|
type roundAllowSet struct {
|
|
mu sync.RWMutex
|
|
names []string
|
|
source string
|
|
published bool
|
|
}
|
|
|
|
// roundAllowSetKey 是允许集持有者的 context key。
|
|
type roundAllowSetKey struct{}
|
|
|
|
// WithRoundAllowSet 每 Run 播种一份允许集持有者。它总是新建持有者(不
|
|
// 做幂等复用):子代理 Run 的工具面与父级不同,复用父级的持有者会把
|
|
// 子代理的调用判成父级的允许集。
|
|
func WithRoundAllowSet(ctx context.Context) context.Context {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
return context.WithValue(ctx, roundAllowSetKey{}, &roundAllowSet{})
|
|
}
|
|
|
|
// PublishRoundAllowSet 把本轮模型可见的工具面发布到该 Run 的持有者上
|
|
// (循环组装本轮请求时调用)。names 会被复制,调用方后续修改不影响已
|
|
// 发布值;source 是本轮允许集的来源标签(如 "capacity:quiet"),只用于
|
|
// 日志与提示文案,不参与判定。ctx 上未播种持有者时为空操作 ——
|
|
// 未发布即「本轮没有做允许集收敛」(例如不经 capacity 过滤的内联流程),
|
|
// 此时 Invoke 不做软判,行为与接通前逐字节一致。
|
|
func PublishRoundAllowSet(ctx context.Context, names []string, source string) {
|
|
h := roundAllowSetHolder(ctx)
|
|
if h == nil {
|
|
return
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.names = append([]string(nil), names...)
|
|
h.source = source
|
|
h.published = true
|
|
}
|
|
|
|
// roundAllowSetHolder 返回 ctx 上的允许集持有者,未播种时为 nil。
|
|
func roundAllowSetHolder(ctx context.Context) *roundAllowSet {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
h, _ := ctx.Value(roundAllowSetKey{}).(*roundAllowSet)
|
|
return h
|
|
}
|
|
|
|
// roundAllowSetFrom 返回 ctx 上已发布的允许集;未播种或未发布时
|
|
// ok=false,语义为「本轮不做软判」。
|
|
func roundAllowSetFrom(ctx context.Context) (names []string, source string, ok bool) {
|
|
h := roundAllowSetHolder(ctx)
|
|
if h == nil {
|
|
return nil, "", false
|
|
}
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
if !h.published {
|
|
return nil, "", false
|
|
}
|
|
return append([]string(nil), h.names...), h.source, true
|
|
}
|
|
|
|
// allowsName 报告 name 是否在允许集内。
|
|
func allowsName(names []string, name string) bool {
|
|
for _, n := range names {
|
|
if n == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SoftNotAllowed 是「软判并回」的调用结果:调用确实执行了(能力≠边界,
|
|
// 不硬阻断),但结果里显式带上 not_allowed 提示,使模型经 tool result
|
|
// 看到这次调用越出了本轮的允许集,trace 与回灌也都可查。
|
|
type SoftNotAllowed struct {
|
|
// Tool 是越界调用的工具名。
|
|
Tool string `json:"tool"`
|
|
// NotAllowed 是回灌给模型的软提示文案(带 not_allowed 前缀)。
|
|
NotAllowed string `json:"not_allowed"`
|
|
// Result 是该工具的真实返回值 —— 调用被执行过。
|
|
Result any `json:"result"`
|
|
}
|
|
|
|
// SoftJudgeEvent 是一次软判的记录,交给接线方(循环)落审计。
|
|
type SoftJudgeEvent struct {
|
|
// Tool 是越界调用的工具名。
|
|
Tool string
|
|
// Source 是本轮允许集的来源标签(如 "capacity:quiet")。
|
|
Source string
|
|
// Reason 是回灌给模型的软提示文案。
|
|
Reason string
|
|
}
|
|
|
|
// SoftJudgeSink 接收软判记录。ctx 是发起该次调用的上下文,接线方可据
|
|
// 会话环境(actor 元数据)补出审计的 who。
|
|
type SoftJudgeSink func(ctx context.Context, ev SoftJudgeEvent)
|
|
|
|
// SetSoftJudgeSink 接线软判 sink:把「越出允许集但被让行」的调用接进
|
|
// 审计通道(design/11 §3 §8 的 AuditEvent / OnAudit)。同一 dispatcher
|
|
// 只保留最后一个接线方 —— 一个 dispatcher 归一个循环所有(子代理循环
|
|
// 是主循环的浅拷贝,OnAudit 相同),多循环共用同一 dispatcher 时以最后
|
|
// 接线者为准。sink 为 nil 即注销。
|
|
func (d *Dispatcher) SetSoftJudgeSink(sink SoftJudgeSink) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
d.sinkMu.Lock()
|
|
defer d.sinkMu.Unlock()
|
|
d.sink = sink
|
|
}
|
|
|
|
// notifySoftJudge 把一次软判记录交给接线方(未接线时为空操作)。
|
|
func (d *Dispatcher) notifySoftJudge(ctx context.Context, ev SoftJudgeEvent) {
|
|
if d == nil {
|
|
return
|
|
}
|
|
d.sinkMu.RLock()
|
|
sink := d.sink
|
|
d.sinkMu.RUnlock()
|
|
if sink != nil {
|
|
sink(ctx, ev)
|
|
}
|
|
}
|
|
|
|
// softJudge 判定一次调用是否越出本轮允许集(design/14 §4,裁决 14.4.2)。
|
|
// 返回空串表示无需软判(本轮未发布允许集,或调用就在允许集内)。越界时
|
|
// 它记一条带来源的日志、把记录交给 sink,并返回回灌给模型的软提示。
|
|
// 它绝不返回错误、绝不拦截调用:状态变化不是能力边界。
|
|
func (d *Dispatcher) softJudge(ctx context.Context, name string) string {
|
|
names, source, ok := roundAllowSetFrom(ctx)
|
|
if !ok || allowsName(names, name) {
|
|
return ""
|
|
}
|
|
reason := softNotAllowedReason(name, source)
|
|
d.log.Warn("tool call outside the capacity allow set (soft judge, not blocked)",
|
|
"tool", name, "allow_set_source", source, "allow_set_size", len(names))
|
|
d.notifySoftJudge(ctx, SoftJudgeEvent{Tool: name, Source: source, Reason: reason})
|
|
return reason
|
|
}
|
|
|
|
// softNotAllowedReason 是越界调用回灌给模型的软提示文案:写明它没有被
|
|
// 拒绝、仍然会被执行,避免模型把软判误读成能力边界。
|
|
func softNotAllowedReason(name, source string) string {
|
|
if source == "" {
|
|
source = "unknown"
|
|
}
|
|
return "not_allowed: tool " + name + " is outside this round's allow set (" + source +
|
|
"); the call was still let through; capacity is not a capability boundary"
|
|
}
|