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, toolEndFrameSummary(result, err)) 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 { recordToolResult(toolSpan, 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.* 帧关联; // resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。 func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) { if l.onStream == nil || isMutedBroadcast(ctx) { return } ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary} 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) } }