Files
Cyrene_v2/internal/runtime/loop/tools.go
T
AskaEth ef3dcb699b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(gateway,loop,tool): 解耦「工具互斥身份」与「checkpoint 归属」(修普通回合不受保护)
缺口:「1 普通回合 + 1 并行路」时普通回合既不登记也不被检查 —— 因为互斥身份取自
loop.ParallelLegFrom,而网关只给 parallel=true 那一路盖它。直接给普通回合盖 WithParallelLeg
会把它的 checkpoint 键从 sess 变成 sess#turn-x(改崩溃恢复语义),故改为两条独立通道:
- 工具互斥身份:loop.WithToolLegID / ToolLegIDFrom(新增),网关在 beginLegLocked 内【无条件】盖,
  取值 = 本路 turnID ⇒ 普通回合 / 队首续跑路 / 并行路 三条路都覆盖
- checkpoint 归属:WithParallelLeg 一行未改,仍只有真并行路 ⇒ 恢复语义零变化
守门断言 TestCheckpointOwnerIgnoresToolLegID:普通回合 ctx 下 AgentID 必须逐字仍是 sess。
变异 3 条:互斥身份回退只认 ParallelLegFrom(判忙失效)/ 顺手把 checkpoint 改读新通道
(守门断言红,证明确实解耦)/ 普通回合不盖身份(判忙失效)。
另修 race:-race -count=2 三包全绿;诚实边界含一个既有竞态(同一路并发登记共享,建议后续小切片)。
2026-10-05 09:53:08 +08:00

499 lines
18 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"
"sync"
"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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 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
}
// 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)
// 钩子可以把每个调用都标记为 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 缺席时安全门跳过(runToolCalls 已保证
// 此时整批以错误回灌)。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil {
l.applyToolSecurityGate(ctx, d, 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
}
}
}
}
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase)
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
l.spanProp(toolSpan, "result", result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧
// 关联。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 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)
}
}