Files
AskaEth a0b4c941e4 fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)
两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
2026-10-27 22:00:18 +08:00

172 lines
7.3 KiB
Go
Raw Permalink 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 loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// auditToolRejectNotAllowed 是「工具调用越出允许面」的审计 "what":
// 群聊/陌生人环境中被 design/11 §3 安全门拒绝的调用,以及
// capacity 允许集之外的越界调用(design/14 §4,裁决 14.4.2)共用它,
// 因此审计轨迹与前端小字两侧不必各认一套标签。
const auditToolRejectNotAllowed = "tool.reject.not_allowed"
// AuditEvent 向审计 sink 上报被安全门拦截的工具拒绝
// (design/11 §3 §8)。循环在受限(群聊/陌生人)环境中拒绝
// 工具调用时发出一个,使该拒绝可审计,且
// 前端可以渲染「xx 工具在群聊被拒」的小字。它经
// Options.OnAudit 接到 gateway 审计轨迹。
//
// capacity 允许集之外的越界调用也走同一通道(见 softJudgeAudit):
// 区别只在 Result 文案 —— 安全门写的是拒绝,软判写的是被让行。
type AuditEvent struct {
// Who 是实施行为的 actor 环境标签("group_member"/"stranger"/"owner")。
Who string
// What 是操作,如 "tool.reject.not_allowed"。
What string
// Result 是结果详情 —— 被拦调用的 not_allowed 提示,或越界调用
// 被让行时的 not_allowed 软提示。
Result string
}
// restrictSchemas 仅返回 ctx 上会话环境可调用的工具 schema,
// 依据 design/11 §3 安全门:群聊/陌生人会话只能
// 调用其可编辑标记允许该环境的工具(默认
// 关闭),而 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 {
if policy.Allow(s.Name) {
kept = append(kept, s)
}
}
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 提示的跳过,
// 并为该拒绝发出一个 AuditEvent。nil 接收者或 nil dispatcher 为空操作。
func (l *Loop) applyToolSecurityGate(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
env := sessionEnvFrom(ctx)
policy := d.PolicyEngine(env.ActorEnv)
for i := range calls {
if !actions[i].run {
continue
}
if policy.Allow(calls[i].Name) {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// notAllowedReason 是受限(群聊/陌生人)会话调用其可编辑
// 标记拒绝该环境的工具时回灌给模型的软提示
// (design/11 §3)。它是软结果 —— 注入对话,
// 绝不 panic 或硬崩溃 —— 使模型能重新评估。
func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
}
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。
func (l *Loop) emitAudit(ev AuditEvent) {
if l.onAudit == nil {
return
}
l.onAudit(ev)
}