Files
Cyrene_v2/internal/runtime/loop/tools.go
T
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

423 lines
14 KiB
Go
Raw 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"
"encoding/json"
"errors"
"fmt"
"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"
)
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
// toolExecParallel。
const (
toolExecParallel = "parallel"
toolExecSequential = "sequential"
)
// criticalToolLoopTerminalReason 在关键工具循环安全网停止
// run 时,作为 Result.Text 与每个被拦截调用的文本浮出。
const criticalToolLoopTerminalReason = "critical tool-loop detected: an identical tool call was repeated across rounds; the run is stopping"
// ToolCallVeto 由 CallAdmit 钩子返回,用于拦截单个调用。
type ToolCallVeto struct {
Block bool
Reason string
}
// ToolOutcome 是一次工具调用执行的归一化结果。它正是
// after-call / after-outcome 钩子所观察、并可能在循环把它
// 回渲给模型之前改写的东西。
type ToolOutcome struct {
// Result 是工具返回的值(失败或跳过时为 nil)。
Result any
// Err 在调用执行失败时非 nil;成功时为 nil。
Err error
// ErrKind 把失败归类为 tool.KindArgumentValidation 或
// tool.KindExecution(任务 errorKind 状态分类)。成功时为
// 空。
ErrKind string
// Skipped 在调用从未执行(重复 / 被拦截 / 被否决)时为 true。
Skipped bool
// Reason 是被跳过调用的人类可读解释。
Reason string
// Terminate 由钩子设置时,表示一旦该批中每个调用都被
// 标记 terminate,整轮就应停止。
Terminate bool
}
// ToolLoopHooks 归组可选的按批次 / 按调用工具循环钩子。每个
// 字段都容忍 nil。这些是循环为「敏感操作代审」(design/11
// 批次准入)以及未来的异步工具路径保留的扩展点,使
// 审计层与循环解耦。
type ToolLoopHooks struct {
// BatchAdmit 在任何调用执行前每批调用一次。返回
// (false, reason) 会拦截整批:每个调用都变成携带 reason 的
// 跳过结果。为 nil = 总是准入。
BatchAdmit func(ctx context.Context, calls []agent.ToolCall) (bool, string)
// CallAdmit 在每个调用执行前对它调用一次。非 nil 且
// Block=true 的否决会拦截该单个调用。为 nil = 总是准入。
CallAdmit func(ctx context.Context, call agent.ToolCall) *ToolCallVeto
// AfterCall 在工具执行后改写调用的结果。返回 nil
// 则保持原样。它在 AfterOutcome 之前运行。
AfterCall func(ctx context.Context, call agent.ToolCall, outcome ToolOutcome) *ToolOutcome
// AfterOutcome 观察/改写最终结果(在 AfterCall 与
// 任何跳过/拦截决策之后),就在结果被回渲给
// 模型之前。为 nil = 保持结果。
AfterOutcome func(ctx context.Context, call agent.ToolCall, outcome ToolOutcome) *ToolOutcome
}
// toolRunResult 是运行一个工具批次的结果。
type toolRunResult struct {
messages []model.Message
executed int
terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
}
// toolLoopState 携带关键工具循环安全网的每次 run 状态。
// 它作用域限于单次 Run 调用,因此并发 run 绝不共享它。
type toolLoopState struct {
criticalSeen bool // 本次 run 已报告过关键循环
prevFP map[string]bool // 紧邻上一批中见过的指纹
}
// toolCallAction 是一个调用经分类后的执行决策。
type toolCallAction struct {
run bool // 执行该调用
reason string // run==false 时,回灌给模型的人类可读原因
}
// runToolCalls 执行一批模型请求的工具调用。它分类重复
// 调用(关键工具循环安全网)、应用可选的
// 批次/按调用钩子、以并行(有界)或串行方式运行存活的调用,
// 并按原始调用顺序把每个结果作为 RoleTool 消息回灌。
// 它返回实际调用的数量(executed)、更新后的消息切片,
// 以及该轮是否应终止。
//
// 调用被委托给被分派的工具,由后者应用每工具
// 截止时间;缺少 dispatcher 时以错误形式回灌,使循环绝不
// 对未处理的调用 panic 或崩溃。
func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls []agent.ToolCall, st *toolLoopState) (toolRunResult, error) {
d := l.dispatcher()
res := toolRunResult{messages: messages}
if len(calls) == 0 {
return res, nil
}
actions, terminate := classifyToolBatch(st, calls)
if terminate {
res.terminate = true
}
// 执行前的三道门(安全门 + 批次/逐调用钩子)见 applyToolAdmissions。
l.applyToolAdmissions(ctx, d, calls, actions)
// 决定串行还是并行:显式串行模式,或任一调用的工具
// 被声明为串行(单个串行标记即让整组
// 串行,与 OpenClaw 的 launchParallelToolCalls 一致)。
sequential := l.toolExec == toolExecSequential
if !sequential && d != nil {
for i := range calls {
if actions[i].run && d.IsSequential(calls[i].Name) {
sequential = true
break
}
}
}
outcomes := make([]ToolOutcome, len(calls))
if sequential {
for i := range calls {
if actions[i].run {
outcomes[i] = l.invokeOne(ctx, d, calls[i])
res.executed++
} else {
outcomes[i] = skippedOutcome(actions[i].reason)
}
}
} else {
idxs := make([]int, 0, len(calls))
for i := range calls {
if actions[i].run {
idxs = append(idxs, i)
} else {
outcomes[i] = skippedOutcome(actions[i].reason)
}
}
l.invokeParallel(ctx, d, calls, idxs, outcomes)
res.executed += len(idxs)
}
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) {
res.terminate = true
}
// 渲染 assistant 工具调用轮(原生 provider 需要它以让
// 后续 tool_result 能被关联),后接每个结果,按调用顺序
// 排列,使模型看到稳定的回灌。
res.messages = appendToolCallHistory(res.messages, calls, outcomes)
return res, nil
}
// classifyToolBatch 把一批调用划分为运行/跳过决策,执行
// 关键工具循环安全网:
//
// - 与同批中更早的一个调用完全相同(同名 + 同参数)的
// 调用被跳过并给出重复提示(绝不执行)。
// - 与上一轮某个调用完全相同的调用是关键的循环重复:
// 第一次出现会拦截该重复调用并置位 criticalSeen,使
// 模型有一轮重新评估;连续第二次出现则终止
// 整个 run。
//
// 它返回 (actions, terminate)。terminate 为 true 时每个调用
// 都以 criticalToolLoopTerminalReason 拦截,且该轮必须结束。
func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAction, bool) {
seen := make(map[string]bool, len(calls))
actions := make([]toolCallAction, len(calls))
repeat := false
for i, call := range calls {
fp := toolFingerprint(call)
if seen[fp] {
// 与同批中已有调用的重复。
if st.prevFP[fp] {
actions[i] = toolCallAction{run: false, reason: repeatedCallReason()}
repeat = true
} else {
actions[i] = toolCallAction{run: false, reason: duplicateCallReason()}
}
continue
}
seen[fp] = true
if st.prevFP[fp] {
// 与上一轮的重复 —— 一个关键循环。
actions[i] = toolCallAction{run: false, reason: repeatedCallReason()}
repeat = true
} else {
actions[i] = toolCallAction{run: true}
}
}
// 记住本轮哪些指纹是活跃的,供下一轮的
// 跨轮重复检测使用。
st.prevFP = seen
terminate := false
if repeat {
if st.criticalSeen {
terminate = true
for i := range actions {
actions[i] = toolCallAction{run: false, reason: criticalToolLoopTerminalReason}
}
} else {
st.criticalSeen = true
}
}
return actions, terminate
}
// applyToolAdmissions 对一批调用应用执行前的三道门,把不放行的
// 调用改写为携带原因的跳过决策:① 安全门(design/11 §3)——
// 群聊/陌生人会话只能调用其标记允许该环境的工具(默认关闭),
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。
//
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
}
if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
if reason == "" {
reason = "tool batch blocked by policy"
}
for i := range calls {
actions[i] = toolCallAction{run: false, reason: reason}
}
}
}
if l.hooks.CallAdmit != nil {
for i := range calls {
if !actions[i].run {
continue
}
if veto := l.hooks.CallAdmit(ctx, calls[i]); veto != nil && veto.Block {
reason := veto.Reason
if reason == "" {
reason = "tool call blocked by policy"
}
actions[i] = toolCallAction{run: false, reason: reason}
}
}
}
}
// applyToolOutcomeHooks 运行结果落定后的两类观察/改写钩子,
// 就地改写 outcomes:AfterCall 仅对实际执行过的调用(在
// AfterOutcome 之前),AfterOutcome 对每个最终结果(无论是否
// 跳过),都在结果回灌给模型之前运行。
func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall, outcomes []ToolOutcome) {
if l.hooks.AfterCall != nil {
for i := range calls {
if outcomes[i].Skipped {
continue
}
if after := l.hooks.AfterCall(ctx, calls[i], outcomes[i]); after != nil {
outcomes[i] = *after
}
}
}
if l.hooks.AfterOutcome != nil {
for i := range calls {
if after := l.hooks.AfterOutcome(ctx, calls[i], outcomes[i]); after != nil {
outcomes[i] = *after
}
}
}
}
// 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch"
}
func repeatedCallReason() string {
return "already used with identical arguments in a previous round; do not repeat it"
}
// skippedOutcome 构建一个携带人类可读原因的 Skipped 结果。
func skippedOutcome(reason string) ToolOutcome {
if reason == "" {
reason = "tool call skipped"
}
return ToolOutcome{Skipped: true, Reason: reason}
}
// shouldTerminateBatch 报告该批中每个调用是否都要求终止
// (OpenClaw 的 shouldTerminateToolBatch),使钩子能停止本轮。
func shouldTerminateBatch(outcomes []ToolOutcome) bool {
if len(outcomes) == 0 {
return false
}
for _, o := range outcomes {
if !o.Terminate {
return false
}
}
return true
}
// toolFingerprint 规范化一个工具调用(name + args),使相同调用
// 可被检测。Args 被 JSON 序列化,因此键序不同的等价对象
// 会塌缩为同一指纹;序列化失败则回退到 %v。
func toolFingerprint(call agent.ToolCall) string {
b, err := json.Marshal(call.Args)
if err != nil {
b = []byte(fmt.Sprintf("%v", call.Args))
}
return call.Name + "\x00" + string(b)
}
// errorKind 把调用错误归类为 tool.Kind* 标签,使模型
// 能区分参数校验失败(重新发出修正后的调用)与
// 执行失败。nil 错误产出 ""(成功)。
func errorKind(err error) string {
if err == nil {
return ""
}
var te *tool.ToolError
if errors.As(err, &te) {
return te.Kind()
}
if errors.Is(err, tool.ErrInvalidArgs) {
return tool.KindArgumentValidation
}
return tool.KindExecution
}
// dispatcher 返回循环的工具 dispatcher;循环未被给予时
// 回退到 agent 自身的。
func (l *Loop) dispatcher() *tool.Dispatcher {
if l.tools != nil {
return l.tools
}
if l.agent != nil {
return l.agent.Tools()
}
return nil
}
// toolResultMessage 把工具调用的结果渲染为可回灌给模型的
// RoleTool 消息。被跳过的调用携带其原因;失败携带
// 结构化的 {kind,message},使 LLM 能恰当应对;成功携带
// 该值。
func toolResultMessage(tc agent.ToolCall, out ToolOutcome) model.Message {
return model.Message{Role: model.RoleTool, ToolCallName: tc.Name,
Content: []model.Content{{Type: model.ContentTypeText, Text: toolResultText(tc, out)}}}
}
// toolResultText 渲染工具结果消息的文本载荷。
func toolResultText(tc agent.ToolCall, out ToolOutcome) string {
switch {
case out.Skipped:
if out.Reason == "" {
return fmt.Sprintf("tool %s skipped", tc.Name)
}
return fmt.Sprintf("tool %s skipped: %s", tc.Name, out.Reason)
case out.Err != nil:
if kind := out.ErrKind; kind != "" {
if payload, err := json.Marshal(map[string]string{"kind": kind, "message": out.Err.Error()}); err == nil {
return fmt.Sprintf("tool %s failed: %s", tc.Name, payload)
}
}
return fmt.Sprintf("tool %s failed: %v", tc.Name, out.Err)
default:
return fmt.Sprintf("tool %s result: %s", tc.Name, formatToolResult(out.Result))
}
}
// formatToolResult 把工具返回值渲染为给模型的文本。字符串
// 直通(空字符串变为 "empty");其他一切都 JSON
// 编码,编码出错时回退到朴素的 %v 渲染。
func formatToolResult(result any) string {
switch v := result.(type) {
case nil:
return "null"
case string:
if v == "" {
return "empty"
}
return v
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}