- 重复调用安全网(critical-tool-loop):指纹=name+canonical JSON(args);批内重复第二次跳过回喂
「duplicate call」;跨轮关键重复(同指纹重现)→首次 block+criticalSeen,再次连续重复→终止整轮
StopReasonToolLoopTerminated(替代裸 8 轮针对同调用的硬截止)。
- 并行/串行混合:invokeParallel 默认并行,信号量(ToolParallelism 默认4)限并发,结果按调用索引
写回按原序渲染;串行触发=Options.ToolExecution:sequential 或 SetToolExecutionMode(name,sequential)
(对齐 OpenClaw「一个 sequential 标记→整组串行」);goroutine 不泄漏、ctx 取消快速返回。
- 工具钩子:ToolLoopHooks(BatchAdmit/CallAdmit/AfterCall/AfterOutcome) 经 Options.Hooks 注入,
均 nil-tolerant;供后续敏感操作代审复用(design/11)。terminate 用 every 语义。
- status 分类:ToolError.Kind() 区分 argument-validation(JSON-Schema 失败)/execution;
失败消息渲染 {kind,message} 让模型区分重发参数 vs 执行失败。
- tool-call-repair:LenientJSONUnmarshal 修 fence 包裹/尾逗号畸形;agent.extractToolCalls 与
loop.decodeToolCalls 走它;不可修复退化为文本不崩整轮。意图调用未区分回喂提示(后置)。
- tool_execution_update:流式路径 emitToolExecution 发 StreamKindToolExecution(start/end/end:error)。
go build/vet/test 全绿(46包)。TestRun_MaxRounds 改每轮不同参(原同 echo 被安全网拦,预期新行为)。
-race 受宿主 TSan VMA 限制无法运行(与代码无关)。
87 lines
3.1 KiB
Go
87 lines
3.1 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"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/trace"
|
|
)
|
|
|
|
// Run drives the agent loop: inject relevant memory into the first-turn context,
|
|
// then run up to MaxRounds steps. Each step runs the model (streaming through
|
|
// OnStream when configured) and any requested tool calls are executed under a
|
|
// per-invoke timeout and fed back as RoleTool messages so the model can continue.
|
|
// A checkpoint is persisted at every step, every step is traced as a
|
|
// state_transition span (with an llm_call child and tool_call spans for each
|
|
// invocation), and after each step the memory Extractor runs with errors logged
|
|
// rather than fatal.
|
|
//
|
|
// It returns the final answer, or a partial Result with StopReasonMaxRounds when
|
|
// the budget is exhausted without a final answer. Step errors are wrapped with %w
|
|
// so callers can classify them with errors.Is.
|
|
func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Message) (*Result, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if l.agent == nil {
|
|
return nil, fmt.Errorf("loop: run: %w", ErrNilAgent)
|
|
}
|
|
|
|
msgs := l.injectMemory(ctx, messages)
|
|
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
|
res := &Result{}
|
|
for step := 1; step <= l.maxRounds; step++ {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
|
|
}
|
|
|
|
rc, roundSpan := l.span(ctx, "loop.step", trace.SpanKindStateTransition)
|
|
outcome, err := l.step(rc, sessionID, msgs)
|
|
if err != nil {
|
|
l.spanProp(roundSpan, "error", err.Error())
|
|
l.finishSpan(roundSpan, trace.SpanStatusError)
|
|
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
|
|
}
|
|
l.spanProp(roundSpan, "step", step)
|
|
l.spanProp(roundSpan, "stop_reason", outcome.stopReason)
|
|
l.finishSpan(roundSpan, trace.SpanStatusOK)
|
|
|
|
res.Steps = step
|
|
if outcome.final {
|
|
res.Text = outcome.text
|
|
res.StopReason = outcome.stopReason
|
|
l.saveCheckpoint(ctx, sessionID, step, msgs, outcome.stopReason)
|
|
l.extractMemory(ctx, msgs, step)
|
|
return res, nil
|
|
}
|
|
|
|
batch, err := l.runToolCalls(rc, msgs, outcome.toolCalls, toolState)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loop: run: step %d: tools: %w", step, err)
|
|
}
|
|
msgs = batch.messages
|
|
res.ToolCallsExecuted += batch.executed
|
|
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
|
|
l.extractMemory(ctx, msgs, step)
|
|
|
|
// The critical-tool-loop safety net (or a terminating hook) stops the
|
|
// round without waiting for a real final answer.
|
|
if batch.terminate {
|
|
res.Text = criticalToolLoopTerminalReason
|
|
res.StopReason = StopReasonToolLoopTerminated
|
|
l.saveCheckpoint(ctx, sessionID, step, msgs, StopReasonToolLoopTerminated)
|
|
l.extractMemory(ctx, msgs, step)
|
|
return res, nil
|
|
}
|
|
}
|
|
|
|
// Budget exhausted without a final answer.
|
|
res.Steps = l.maxRounds
|
|
res.StopReason = StopReasonMaxRounds
|
|
l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds)
|
|
l.extractMemory(ctx, msgs, l.maxRounds)
|
|
return res, nil
|
|
}
|