- 原缺陷:流式路径经 loop.toolSchemas(capacity 允许集 + restrict)过滤,而 agent 静音/内部路径
(RunStep,被 services/automations 等使用)广告 dispatcher **全量** ⇒ 两条路径不一致
- 修法(方案 1):**过滤实现只留一份**(loop.toolSchemas);l.agentStep 调用同一个函数,结果经
agent.WithToolSurface(ctx, …) 下传,agent 的 buildRequest 原样广告(取不到才回退全量,保持既有回退语义)
· 按构造相同:同一次函数调用、同一份输入 ⇒ 不存在第二份可漂移的实现
· 未选「抽共享函数两侧各调一次」:loop 的过滤输入含 loop 私有会话环境 ctx key 与 capacity 判定器,
agent 侧凑齐等于再造一套 ctx 管线,漂移面更大
- 复核:agent/loop 包测试绿;新增 parity 用例(owner 与 group 两组,静音 == 流式);
变异(撤掉下传行)→ parity 必红且精确复现「静音=全量 4 个」,已还原
- 附注:全仓仅剩 `services/automations` 的「无 Loop 回退」仍广告全量(没有循环就没有闸可言);
子代理 ToolWhitelist 是执行期否决、本不参与 schema 过滤(未改)
87 lines
3.4 KiB
Go
87 lines
3.4 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"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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))
|
||
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
|
||
}
|