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" ) // stepOutcome 是循环对单个模型步的内部视图,由 // agent 的 *agent.StepResult(非流式)或流式路径构建的等价物 // 归一化而来。 type stepOutcome struct { final bool text string toolCalls []agent.ToolCall stopReason string // modelStopReason 是 provider 对该模型调用的原始 stop/finish // 原因(Anthropic max_tokens / end_turn / tool_use),记录在 llm_call // span 上,使即使该步本身正常结束,截断也可见。 modelStopReason string // model 是服务该调用的路由模型,在流式路径上可知 //(该路径自行构建请求),在 agent 路径上为空。它 // 记录在 llm_call span 的日志事件上。 model string // promptTokens 是本次调用的输入侧 token 数(提供商上报时,否则 0)。它只用于 // 「输入逼近上下文预算」的 WARN(design/04 附录 A.3 第 5 条,不阻断任何流程)。 promptTokens int } // contextPressureRatio 是「单次输入逼近上下文预算」的告警线:达到预算的 90% // 就记一条 WARN,**不阻断**(design/04 附录 A.3 第 5 条)。它只是让「快溢出了」 // 这件事在日志里可见,压缩仍按自己的预算独立判定。 const contextPressureRatio = 0.9 // reportContextPressure 在本次调用的输入 token 达到预算的 contextPressureRatio // 时记一条 WARN。预算关闭(<= 0)或提供商没上报用量时不记 —— 没有依据就不吵。 func (l *Loop) reportContextPressure(promptTokens int) { if l.maxContextTokens <= 0 || promptTokens <= 0 { return } if float64(promptTokens) < float64(l.maxContextTokens)*contextPressureRatio { return } l.log.Warn("model input is near the context budget", "prompt_tokens", promptTokens, "budget", l.maxContextTokens, "ratio", contextPressureRatio, "action", "compaction triggers at the budget") } // step 运行单次模型调用。配置了 OnStream 回调时,它 // 直接驱动适配器的 ChatStream,把增量串给 OnStream 并 // 应用循环的护栏;否则委托给 agent 的 RunStep, // 由后者运行 agent 自身的护栏并记录自己的 CallRecord。整个 // 调用被包在 llm_call span 中,使 M9 面板能看到每次 LLM 调用。 func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { _, llmSpan := l.span(ctx, "llm.call", trace.SpanKindLLM) var ( out *stepOutcome err error ) // 静音(内部自主流程)绕过流式:它既不流向 // UI,也不付流式路径的额外开销。正常聊天则流式。 if l.onStream != nil && !isMutedBroadcast(ctx) { out, err = l.streamStep(ctx, messages) } else { out, err = l.agentStep(ctx, sessionID, messages) } if err != nil { l.spanProp(llmSpan, "error", err.Error()) l.finishSpan(llmSpan, trace.SpanStatusError) return nil, fmt.Errorf("loop: model step: %w", err) } l.spanProp(llmSpan, "stop_reason", out.stopReason) if out.modelStopReason != "" { l.spanProp(llmSpan, "model_stop_reason", out.modelStopReason) } l.spanProp(llmSpan, "tool_calls", len(out.toolCalls)) // 输入逼近预算的 WARN(不阻断):覆盖流式路径(agent 路径不经本函数的上报, // promptTokens 为 0 时静默跳过)。 l.reportContextPressure(out.promptTokens) l.recordLLMDone(llmSpan, out, l.agentFor(ctx)) l.finishSpan(llmSpan, trace.SpanStatusOK) return out, nil } // agentStep 把非流式步委托给该 turn 的 agent,由后者运行输入 // 与输出护栏、调用模型并解码所有工具调用。 // // 工具面与流式路径**同源**(audit P0-12):两条路径都取这一处过滤结果 // (capacity 允许集 + design/11 §3 会话环境安全门),区别只在于流式路径在 // 循环里组装请求时直接把它写进 model.Request,而本路径经 ctx 下传给 agent // 广告。agent 侧因此不需要、也不允许自行过滤,两份实现无从漂移;静音内部 // 流程(backthink、子代理)与前端聊天看到的是同一份工具面。 func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { ctx = agent.WithToolSurface(ctx, l.toolSchemas(ctx, messages)) sr, err := l.agentFor(ctx).RunStep(ctx, sessionID, messages) if err != nil { return nil, fmt.Errorf("loop: agent step: %w", err) } return &stepOutcome{ final: sr.Final, text: sr.Text, toolCalls: sr.ToolCalls, stopReason: sr.StopReason, modelStopReason: sr.ModelStopReason, }, nil }