Files
Cyrene_v2/internal/runtime/loop/stream.go
T
AskaEth 6e7ec56f9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00

135 lines
5.1 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"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// streamStep 驱动一次流式模型调用。它是
// agent.RunStep 的流式对应物:它从 agent 的公开表面构建请求、
// 运行循环的输入护栏、把增量流经 OnStream、运行输出
// 护栏并解码所有工具调用。由于 RunStep 不暴露流式
// 路径,该方法归循环所有;它镜像 agent 的语义,使两者
// 除增量回调外行为一致。
func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) {
ag := l.agentFor(ctx)
adapter := ag.Adapter()
if adapter == nil {
return nil, fmt.Errorf("loop: stream step: %w", ErrNoAdapter)
}
// 每个 step 解析一次该角色的 agent 与推理旋钮:同一对取值
// 既用于请求,也用于逐增量的广播门,因此角色切换
// 绝不会在流中途用旧的广播开关套用新的提示词。
knobs := l.knobsFor(ctx)
req := l.buildRequest(ctx, messages, ag, knobs)
if l.guards != nil {
verdict, err := l.guards.CheckInput(ctx, &req)
if err != nil {
return nil, fmt.Errorf("loop: check input: %w", err)
}
if verdict != nil && !verdict.Allow {
return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailInput}, nil
}
}
// 一次模型流(一次 ChatStream 调用)取一个 streamId:该流的
// 每一帧共享它,而多步工具轮的后续模型调用取新的 id
// (design/04 §1.1)。它与 TurnID 分工:turnId 标记整个回合,
// streamId 标记回合内的一条流式消息。
streamID := newStreamID()
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
// 在循环边界打上 session/turn 身份:适配器运行在
// 循环之下,无法知道该流属于哪个会话,
// 因此循环用它被驱动的 context 填充(design/16 §4.2),
// 前端便能把增量路由到活跃会话。
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
ev.StreamID = streamID
// 为内部自主流程(backthink)抑制 UI 广播,
// 其中间推理不得泄露进聊天流;当该 turn 的角色
// 关闭思考帧时(showToUser: false)也抑制。两处门
// 都只影响广播:模型产出的响应
// 不变,因此展示与持久化保持解耦。
if l.onStream != nil && !isMutedBroadcast(ctx) && broadcasts(ev, knobs.ThinkingShowToUser) {
l.onStream(ev)
}
})
if err != nil {
return nil, fmt.Errorf("loop: chat stream: %w", err)
}
if l.guards != nil {
verdict, err := l.guards.CheckOutput(ctx, &resp)
if err != nil {
return nil, fmt.Errorf("loop: check output: %w", err)
}
if verdict != nil && !verdict.Allow {
return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailOutput}, nil
}
}
if calls, ok := decodeToolCalls(resp); ok {
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls,
modelStopReason: resp.StopReason, model: resp.Model}, nil
}
return &stepOutcome{final: true, text: resp.Content, stopReason: agent.StopReasonStop,
modelStopReason: resp.StopReason, model: resp.Model}, nil
}
// broadcasts 报告某流式事件是否可推给 UI。只有
// 思考帧会被扣下,且仅当该 turn 的角色把
// showToUser 解析为 false 时;文本、工具调用与工具进度帧
// 从不受影响,因此关闭思考不会改变客户端所见
// 的答案本身。
func broadcasts(ev model.StreamEvent, showToUser *bool) bool {
if !isThinkingKind(ev.Kind) {
return true
}
return showToUser == nil || *showToUser
}
// isThinkingKind 报告 kind 是否为推理分段事件之一。
func isThinkingKind(kind model.StreamKind) bool {
switch kind {
case model.StreamKindThinkingStart, model.StreamKindThinkingDelta, model.StreamKindThinkingEnd:
return true
default:
return false
}
}
// newStreamID 返回新的随机十六进制标识,用于把一串流帧关联到
// 同一次模型流(design/04 §1.1 streamId)。它消耗 crypto/rand 源,
// 源不可用时回退到纳秒时间戳,因此总能产出 id(空的 streamId 会
// 静默破坏客户端关联)。
func newStreamID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
return "stream-" + hex.EncodeToString(b[:])
}
return "stream-" + time.Now().UTC().Format("20060102150405.000000000")
}
// emitTurnBoundary 在循环的回合边界发出 turn.start / turn.end 帧
// (design/04 §1.1)。它复用流式路径的广播门:未配置 OnStream 或
// 被静音的内部流程(backthink / 子代理)不发出。两帧不带载荷 ——
// 帧壳上的 session/turn 身份就是边界信号,回合的终态信息仍由
// hub 在 Run 返回后发出的 stream_end 携带。
func (l *Loop) emitTurnBoundary(ctx context.Context, kind model.StreamKind) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
l.onStream(model.StreamEvent{
Kind: kind,
SessionID: loopSession(ctx),
TurnID: loopTurn(ctx),
})
}