两处 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 ./... 全绿。
172 lines
7.3 KiB
Go
172 lines
7.3 KiB
Go
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)
|
||
}
|