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" }