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/**`,「前端能渲染」待前端批);无真实模型端到端。
This commit is contained in:
1 parent
afef75f640
commit
6e7ec56f9a
16 files changed
+982
-126
No files matched your search
+16
-10
@@ -110,7 +110,7 @@
|
||||
- Web 端镜像实现在 `frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 对未知值返回原值),权威仍以本端点为准。
|
||||
- **反漂移保证(后端)**:后端有测试断言「每个内置适配器的 `Name()` 都在 `platforms` 表内」「`moods` 与 persona 情绪状态机的内置状态一致」「`modes` 与前端 `MODES` 九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
|
||||
|
||||
> **契约版本 `v0.49`**(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)与 `/ws/chat` 的连接鉴权都**照旧**;**新帧类型默认只发 owner/admin**(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。**上一版 `v0.47`**:(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**:派发子代理时若指定了目标角色,该分支根节点的 `props` 就多一个 `persona` 键,标注它**正在扮演的角色**——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;**纯增量**:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。**上一版 `v0.46`**:(**`PATCH /api/v1/sessions/{id}` 的 `personaId` 由「可重新绑定该会话的角色」改为「出现即 `400`」**:**会话角色在创建时确定、之后不可变**,要换角色请新开会话(design/22 §8-1)——**明确拒绝**而非静默忽略/200 空操作;**键出现**(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)一律 `400 bad_request` 且消息点名 `personaId`,该检查**先于**会话存在性检查(未知会话 + `personaId` 也回 `400` 而不是 `404`)。**破坏性**:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式 `""` 解绑」的客户端必须改为**新建会话**选角色;`title`/`pinned` 行为与 `POST /sessions {personaId}`(创建时绑定)**完全不变**,不再有 `501/200 {personaId}` 的 PATCH 路径)。上一版 `v0.45`:`PUT /admin/channels/{platform}/config` 的 `personaId` 由「省略/`""` 都 = 解绑」修订为三态(**省略(或 `null`)= 保留已存绑定**、**显式 `""` = 解绑**、**非空 id = 绑定**,未知角色仍 `400`)。
|
||||
> **契约版本 `v0.50`**(**事件批:流式帧的结构化补齐**,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):① **`tool_execution` 拆三事件**——旧单帧 `tool_execution.update {id,index,delta}`(phase 与工具名拼在 `delta` 字符串里)被 **`tool_execution.start`**(进入执行,`phase:"running"`)/ **`tool_execution.update`**(运行中进度/局部输出,可多帧)/ **`tool_execution.end`**(完成或失败,`phase:"done"|"error"`)替换,载荷改为结构化 `{tool, phase, id?}`(`tool` = 工具名;`phase` = 细粒度执行阶段,与 §3.5 流程图节点 status 同一套 `running/done/error` 枚举;`id` = provider 工具调用 id,可与 `tool_call.*` 帧关联)——**phase 不再塞进 `delta`**;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,**暂无生产者**,见 §3.1)。② **`streamId`(模型流帧 `data` 内新增可选字段)**:同一次模型流(一次 ChatStream 调用)的多帧共享同一 `streamId`,多步工具轮的第二次模型调用取新 id;与帧壳的 `turnId`(整个回合恒定)分工——`turnId` 标记「这轮回答」,`streamId` 标记「这轮里的哪一条流式消息」。③ **`turn.start` / `turn.end`(新帧)**:loop 在回合边界发出、经 ws_bridge 下行;**无 `data` 载荷**(帧壳的 `sessionId`/`turnId`/`seq` 即边界信号;回合终态信息仍由既有 `stream_end` 携带);`turn.end` 覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ **向后兼容**:`turn.start/end` 与 `streamId` 为纯新增,老客户端可安全忽略;**破坏性**在旧 `tool_execution.update` 单帧——loop 不再产生它,按旧形状(解析 `delta` 里的 `phase:工具名` 拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅 `owner` + `admin`」档(fail-closed 默认,无需扩白名单)。⑥ **`graph_update` 会话范围口径修正**(裁决 01.4.5,随实现事实补记):`web` / `web:<uuid>` / `sess-backthink` 三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。**上一版 `v0.49`**:(**WS 握手回放与上行帧的角色门(design/11 §7.2)**:补上 §7.1 下行门之外的**同一类缺口的另两处**,对 `viewer` **继续收紧**,`owner` / `admin` 行为不变,**无端点、无字段形状变更**——① **握手 `history` 回放改为仅 `owner` + `admin`**:此前任意角色一连上 `/ws/chat` 就会收到主会话最近 50 条消息正文(`thinking` 已剥离,但**正文本身**就是主人的对话),`viewer` 从此**不收**;② **上行 JSON 帧按角色授权(fail-closed)**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝,**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**——被拒帧含 `chat`(此前 `viewer` 可**真的驱动模型并落库**)、`interrupt`、`subagent_ask` / `task_output_query`(其应答是**定向**帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给 `viewer`)、`voice_input`、`mode_switch`,且**未登记的新上行帧默认拒**;**协议级帧(ping/pong、close)不受影响**。③ **合口径**:`viewer` = **可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容、也发不出任何指令**。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。**上一版 `v0.48`**:(**WS 下行帧的角色门(design/11 §7.1)**:hub 的下行现在是**按帧类型分档**的——只有 7 个**状态机信号**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)继续发给**所有已认证连接**(含 `viewer`),**其余全部下行帧**(流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`;持久化 `message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`;系统内部 `graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`;私有状态 `mood` / `device_update` / `reminder`)**改为仅发 `owner` + `admin`**)。**对 `viewer` 是收紧**:此前它连上 `/ws/chat` 就能收到角色回复正文与推理内容,现在收不到;**`owner` / `admin` 行为不变**;**无端点、无字段形状变更**——`viewer` 的 12 条只读监控 REST 端点(`viewerAuth`,v0.40)Line truncated
|
||||
|
||||
## 2. REST 端点(管理,`/api/v1/admin/*`)
|
||||
|
||||
@@ -243,20 +243,25 @@
|
||||
## 3. WebSocket `/ws/chat`(实时)
|
||||
|
||||
- **握手**: `ws://<host>:8080/ws/chat?token=<JWT>` 或 `Authorization: Bearer`;未认证在握手前回 `401` JSON。
|
||||
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end` / `tool_execution.update`(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
|
||||
- **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`。
|
||||
- **下行(服务端→客户端)**: 经 bus 广播 `{type:"<event>", data:..., turnId?, seq?, sessionId?}`(`turnId`/`seq` 为流式关联字段,见 §3.1;`sessionId` 为**会话持久化(design/16)新增**,能确定会话的事件带上,供客户端按激活会话过滤;**v0.36 起结构化流帧 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` 也带 `sessionId` + `turnId`**——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——`message.start/delta/end` / `thinking.start/delta/end` / `tool_call.start/delta/end`(以上模型流帧的 `data` 另带 `streamId`,v0.50)/ `tool_execution.start/update/end`(工具执行三事件,v0.50)/ `turn.start` / `turn.end`(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的 `stream_chunk` / `stream_end` / `thinking`(思维链) / `mood`(情绪变化,见 §3.7) / `reminder` / `device_update` / `tool_used`(工具回显) / `subagent_event` / `graph_update` / **`message_persisted`**(会话持久化增量,见 §3.3)/ **`message_edited`**、**`message_deleted`**(消息编辑/删除增量,见 §3.8)/ **`session_read`**(已读游标推进,见 §3.9)。**会话编排状态(design/15,均为广播,`data.sessionId` 区分会话)**:`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(见 §3.2)。语音回复走 `voice_output`(定向回请求连接,非广播)。
|
||||
- **下行帧的角色门(v0.48,design/11 §7.1)**: 下行帧按**类型**分两类,由后端**唯一一处**收口判定(**不在白名单里的帧默认只发 `owner` + `admin`**,fail-closed):① **状态机信号**(白名单,**不含任何内容**)→ **所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full`(§3.2)、`session_read`(§3.9);② **其余全部** → **仅 `owner` + `admin`**:流式内容 `message.*` / `thinking.*` / `tool_call.*` / `tool_execution.start/update/end` / `turn.start` / `turn.end`(**v0.50 新增的这几种同属本档**——工具进度与回合边界对 `viewer` 也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/ `stream_chunk` / `stream_end` / `thinking`(§3.1)、持久化 `message_persisted`(§3.3)/ `channel_persisted`(§3.4)/ `message_edited` / `message_deleted`(§3.8)、系统内部 `graph_update`(§3.5)/ `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、私有状态 `mood`(§3.7)/ `device_update` / `reminder`。
|
||||
- **上行帧与握手回放的角色门(v0.49,design/11 §7.2)**: 与上面那条下行门**成对**,收的是同一个缺口的两处残余——① **客户端→服务端(上行)JSON 帧**:只有 `owner` / `admin` 连接可以发;非 `owner`/`admin`(即 `viewer`)连接发来的**任何**上行 JSON 帧都**默认被拒**,回复是**定向**(只回发送者)的 `{type:"error", data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**。被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`——**未登记的新上行帧同样默认拒**,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② **握手 `history` 回放**:仅 `owner` + `admin` 连接收到;`viewer` 连接建立后**不收**回放。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。
|
||||
|
||||
> **这是「谁被主动推送」的门,不是数据可见性**:`viewer` 的 12 条只读监控端点(`viewerAuth`,v0.40)**照旧**(含 `GET /personas` 的角色级 `mood`);`viewer` **仍可连** `/ws/chat`(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(`voice_output` 等)不受影响。**上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条**——`viewer` 连上后**发不出任何指令**、也**收不到 `history` 回放**。
|
||||
|
||||
> **`history` 回放事件(design/16,新增)**:WS 握手升级成功后、该连接下一帧即收到 `{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,createdAt}]}}`——回放主会话最新 50 条(`thinking` 已剥离;默认主会话,次级会话由客户端按激活会话走 `GET /sessions/{id}/messages` 拉取)。客户端据此在重连/新开连接后补全上下文。消息体含 `senderName?`(第三方昵称,design/16 §4.6,主人私聊为空)与 `personaId?`(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。**角色门(v0.49,design/11 §7.2 A)**:这条回放**只发 `owner` + `admin` 连接**;`viewer` 连接建立后**不会**收到 `history` 帧(它若需要只读数据,走 §1 的只读 REST 端点)。
|
||||
|
||||
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*`)
|
||||
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.update` 过滤到 `sessionId === activeSessionId` 的会话,忽略非激活会话的帧(多会话并发流式不再串话)。
|
||||
- **`message.start`**(`{index}`) / **`message.delta`**(`{delta, index}`) / **`message.end`**(`{index}`):正文段。`delta` 为正文 token 增量;客户端对 `message.delta` 累加到当前消息正文;`message.start/end` 划定正文段边界,**不**结束整轮(结束由 `stream_end` 负责)。
|
||||
- **`thinking.start`**(`{index}`) / **`thinking.delta`**(`{delta, index}`) / **`thinking.end`**(`{index}`):思维链段(前端折叠块)。`thinking.delta` 累加增量。
|
||||
- **`tool_call.start`**(`{id, index}`) / **`tool_call.delta`**(`{id, index, delta}`) / **`tool_call.end`**(`{id, index}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
|
||||
- **`tool_execution.update`**(`{id, index, delta}`):工具执行进度/局部输出(保留,工具循环 §1.2 后置实现)。
|
||||
### 3.1 结构化流事件(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.*` / `turn.start/end`)
|
||||
- **事件壳**:`{type, sessionId?, turnId?, seq?, data}`, `data` 为**类型化结构**(非 `any`)。`seq` 为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。**`sessionId` 为事件所属会话、`turnId` 为该轮助手回合 id(均 v0.36 起带上)**:`sessionId` 由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),`turnId` 由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故**同轮内所有帧的 `turnId` 一致**。客户端据此把 `message.delta` / `thinking.delta` / `tool_call.*` / `tool_execution.*` 过滤到 `sessionId === activeSessionId` 的会话,忽略非激活会话的帧(多会话并发流式不再串话)。
|
||||
- **`streamId`(v0.50,模型流帧 `data` 内可选字段)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`(如 `{"delta":"…","index":0,"streamId":"stream-a1b2…"}`)——**同一次模型流的多帧共享同一 id**,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取**新** id。`turnId` 标记「这轮回答」(整轮恒定),`streamId` 标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个 `streamId`。`tool_execution.*` 与 `turn.*` 帧不属于任何模型流,**不带** `streamId`。
|
||||
- **`message.start`**(`{index, streamId?}`) / **`message.delta`**(`{delta, index, streamId?}`) / **`message.end`**(`{index, streamId?}`):正文段。`delta` 为正文 token 增量;客户端对 `message.delta` 累加到当前消息正文;`message.start/end` 划定正文段边界,**不**结束整轮(结束由 `stream_end` 负责)。
|
||||
- **`thinking.start`**(`{index, streamId?}`) / **`thinking.delta`**(`{delta, index, streamId?}`) / **`thinking.end`**(`{index, streamId?}`):思维链段(前端折叠块)。`thinking.delta` 累加增量。
|
||||
- **`tool_call.start`**(`{id, index, streamId?}`) / **`tool_call.delta`**(`{id, index, delta, streamId?}`) / **`tool_call.end`**(`{id, index, streamId?}`):工具调用中间态;`tool_call.delta` 携带参数增量(如 Anthropic `input_json_delta`)。
|
||||
- **`tool_execution.start`**(`{tool, phase:"running", id?}`):工具**进入执行**(v0.50)。`tool` 为工具名;`phase` 为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套 `running/done/error`);`id` 为 provider 工具调用 id(可关联同轮的 `tool_call.*` 帧,provider 未给 id 时省略)。
|
||||
- **`tool_execution.update`**(`{tool, phase:"running", delta?, id?}`):工具**运行中**的进度/局部输出,一次执行可多帧(v0.50)。**现状**:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。
|
||||
- **`tool_execution.end`**(`{tool, phase:"done"|"error", id?}`):工具执行**完成或失败**(v0.50);`phase:"error"` 即失败(design/19 流程图的 tool 节点据此翻 `error` 态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。
|
||||
- **`turn.start` / `turn.end`**(无 `data` 载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的 `sessionId`/`turnId`/`seq` 即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。`turn.end` 覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);**回合终态信息(`stopReason`/`steps`)仍由既有 `stream_end` 携带**,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。
|
||||
- **向后兼容(v0.50 对 `tool_execution` 的替换说明)**:v0.49 及之前的单帧 `tool_execution.update {id,index,delta}`(`delta` 形如 `"start:工具名"` / `"end:error:工具名"` 的拼串)**不再由 loop 产生**——按旧拼串解析的客户端必须迁移到三事件(`tool_call.*` 帧形状不变)。`turn.start/end` 与各帧 `data` 里的 `streamId` 为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。
|
||||
- **向后兼容**:未映射/无法确定的事件仍走 `stream_chunk` + `stream_end`(非流式回退路径)。
|
||||
- **上行(客户端→服务端)**: `mode_switch` / `chat`(跑 loop 并广播流式) / `voice_input` / `subagent_ask` / `task_output_query` / `interrupt`。
|
||||
- **`interrupt`**(上行,客户端→服务端):`{sessionId}` → 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)、清空其待处理队列、回 `session_interrupted`;用户说「停下/算了/换个话题」时发。命中空闲会话为 no-op。
|
||||
@@ -293,7 +298,7 @@
|
||||
### 3.5 实时全链路流程事件(`graph_update`,design/19)
|
||||
|
||||
> **角色门(v0.48,design/11 §7.1)**:`graph_update` 属「系统内部」帧 → **仅 `owner` + `admin`**(`viewer` 收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。
|
||||
- **触发**:主会话 Web 每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**仅主人 Web 会话**(`sessionId == "web"` 或其 `web:<uuid>` 前缀)发布——IM / 群聊 / 陌生人 / 子代理轮次**不**发布,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。
|
||||
- **触发**:每次会话轮次中,loop 的每个 trace span **开始/结束时**由 `loop.FlowPublisher` 经 bus `ws.graph_update` 广播增量 **FlowGraph**。**发布范围为三类会话(v0.50 口径,裁决 01.4.5)**:`web`(Web 主会话) / `web:<uuid>`(Web 子会话,含 IM 主人私聊镜像落主会话的形态) / `sess-backthink`(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——**其余隔离会话(IM 群聊/陌生人的 `sess-xxx`)与子代理轮次不发布**,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。**tool 节点的 `status` 由细粒度 phase 驱动**(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为 `running`,执行结束按成败翻 `done` / `error`——与 §3.1 `tool_execution.*` 帧的 `phase` 同一套枚举、同一处决策。
|
||||
- **载荷**:`{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}`。
|
||||
- **子代理分支节点的 `props.persona`(v0.47,design/22 §8-2)**:`subagent_spawn` 指定了目标角色派发时,该子代理在父会话图里的**分支根节点** `props` 会多一个 `persona` 键(值 = 目标角色 id),标注它**正在扮演的角色**;**不指定角色派发时该键不出现**(与旧版逐字一致),客户端按未知键忽略即可。
|
||||
- `id` 为 `sessionId-round`(每轮一图);`sessionId` 为会话 id;`startedAt/endedAt` 为 RFC3339。
|
||||
@@ -378,6 +383,7 @@
|
||||
- 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。
|
||||
|
||||
## 5. 变更记录
|
||||
- `v0.50`(**事件批:流式帧的结构化补齐**——`tool_execution` 三事件 + `streamId` + `turn.start/end` + `graph_update` 三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):① **`tool_execution` 拆三事件(破坏性替换)**:旧单帧 `tool_execution.update {id,index,delta}`(`delta` 为 `"start:工具名"` / `"end:error:工具名"` 拼串)**不再由 loop 产生**,替换为 `tool_execution.start`(`{tool, phase:"running", id?}`,进入执行)/ `tool_execution.update`(`{tool, phase:"running", delta?, id?}`,运行中进度,可多帧)/ `tool_execution.end`(`{tool, phase:"done"|"error", id?}`,完成或失败)——**phase 与工具名是结构化字段,不再塞进 `delta`**;`id` 复用 provider 工具调用 id(可关联 `tool_call.*` 帧,无 id 时省略该键)。**迁移**:按旧 `delta` 拼串解析的客户端必须改读 `phase`/`tool` 字段;`tool_call.*` 帧形状不变。**诚实边界**:start/end 由 loop 在每次工具执行的首尾真实发出;`update` 的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,**loop 暂不产出 update 帧**(未来接入进度上报无需再改契约)。② **`streamId`(新增可选字段,位于模型流帧的 `data` 内)**:`message.*` / `thinking.*` / `tool_call.*` 帧的 `data` 带 `streamId`——loop 在**每次模型流**(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳 `turnId`(整轮恒定)分工:`turnId` = 哪轮回答,`streamId` = 这轮里的哪条流式消息。`tool_execution.*` / `turn.*` 帧不带。③ **`turn.start` / `turn.end`(新帧,无 `data` 载荷)**:loop 在 Run 起止发出、经 ws_bridge 下行(`model.StreamEvent` 新 kind → hub `broadcastStream` 映射);帧壳 `sessionId`/`turnId`/`seq` 即边界信号;`turn.end` 覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由 `stream_end` 携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④ **`graph_update` 三范围口径**(01.4.5,随实现事实补记):发布范围为 `web` / `web:<uuid>` / `sess-backthink` 三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧 `cmd/cyrene/wire_runtime.go` 的 allow 谓词本就如此,本批零代码变更);并补记 **tool 节点 `status` 由 phase 驱动**(与 §3.1 `tool_execution.*` 的 `phase` 同枚举同决策:`trace.FlowStatusForPhase` 消费 tool span 上的 phase,缺省回落 span 终态)。⑤ **角色门**:本批新帧(`tool_execution.start/end`、`turn.start/end`)全部落 v0.48 下行门的「仅 `owner` + `admin`」档(fail-closed 默认,白名单未扩);`viewer` 行为不变。⑥ **向后兼容总结**:新增帧与新增键(`streamId`、`data.tool`、`data.phase`)可被老客户端安全忽略;唯一破坏点是旧 `tool_execution.update` 单帧消失(见 ①);**现有 Web 前端未消费 `tool_execution.*` / `turn.*` / `streamId`,本批对它零影响**。⑦ **验证**:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序 `turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end`(+`stream_end`)、`streamId` 每流独立、`tool_execution` 帧无 `delta`/`streamId` 键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。**变异验证**:去掉 loop 的 `ev.StreamID = streamID` 透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。
|
||||
- `v0.49`(**WS 握手回放 + 上行帧的角色门**:把 design/11 §7.1 下行门落地时查出的**同一类缺口的另两处**一并收口——**对 `viewer` 继续收紧**,**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.2,2026-10-03 落实):① **问题**:本质与 §7.1 同一个——`viewer` 是**只读监控档**,却能通过 WS 拿到内容、甚至发起写入。(a) 握手 `history` 回放**对任意角色照发**(回放主会话最近 50 条**消息正文**,`thinking` 已剥离但正文本身即主人的对话),`viewer` 一连上就把对话拿走了;(b) `route` 的**上行分发完全没有角色检查**,`viewer` 发 `chat` 能**真的驱动模型并把消息落库**(只读档拿到写权限),`subagent_ask` / `task_output_query` 的应答是**定向帧**(`enqueueJSON`)因此**绕过 §7.1 的下行门**,把子代理上下文 / 任务输出直接回给 `viewer`。② **规则(fail-closed,默认拒)**:**A** 握手 `history` 回放**仅 `owner` + `admin`**(`ownerAdminClaims`,与主动推送同一个判据);**B** 非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律拒绝——**定向**(只回发送者)回 `{type:"error",data:{code:"forbidden"}}`,**不广播、不驱动模型、不落库、不返回任何内容**;被拒帧:`chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`(**默认拒**,未登记的一律拒)。**协议级帧不受影响**:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ **实现**:A 收口在**唯一**发出回放的 `hub.replayHistory` 顶部(**不放调用点**,没有第二个调用者能绕过);B 收口在 `wsConn.route` 的**第一条语句**,且写成「**默认拒**」而**不是被拒类型黑名单**——新加的上行帧类型自动被拒,漏一处也不会放行。④ **合口径(与 §7.1 一起读)**:`viewer` = **可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48)**;**除此以外 WS 上拿不到任何内容,也发不出任何指令**。⑤ **未来放宽的要求**:将来若真需要 `viewer` 上行(如手机端以 viewer 凭据做只读订阅之外的交互),**必须先在 design/11 §7.2 给它开口并登记白名单**;代码里那把「默认拒」必须**改造成登记表**,**不得直接删掉**。⑥ **测试与变异验证**:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:`viewer` 连接**收不到** `history` 回放,`owner` / `admin` **收得到**(且回放里 `thinking` 仍为空);B:`viewer` 依次发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input` / `mode_switch`,**每次都收到定向 `forbidden` error**,且其后**无任何内容帧**(对 `subagent_ask` / `task_output_query` 尤其断言「不得收到定向的 `subagent_event` / `task_output`」),全程**计数 runner = 0 且落库 = 0**;另一条 `viewer` 连接作为观察者**不收**这些 error(证明是**定向**而非广播);回归:`owner` 发同一个 `chat` 仍然**真的驱动 runner(= 1)**并把 user + assistant 两条**落库**;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)**均正常**。**变异验证**:① 去掉 A 的门 → `viewer` 立刻收到 `history`(A 断言 FAIL,已跑);② 去掉 B 的门 → `viewer` 的 `chat` 驱动 runner **1 次**并**落库 2 条**、`subagent_ask` 直接回 `subagent_event`(B 断言 FAIL,已跑)。⑦ **影响面**:`owner` / `admin` **逐字不变**;`/ws/chat` **连接鉴权不变**(`viewer` 仍可连);无新端点、无字段形状变更;今天**没有 `viewer` 客户端在跑**(`/auth/login` 仍写死签发 `RoleOwner`,除测试外无 viewer 凭据),故线上实际影响为 0。
|
||||
- `v0.48`(**WS 下行帧的角色门**:`/ws/chat` 的下行帧改为**按帧类型分档**——**对 `viewer` 是收紧**(此前它连上就能收到角色回复正文与推理内容,现在收不到),**`owner` / `admin` 行为不变**,**无端点、无字段形状变更**;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① **问题**:hub 的下行是**全连接广播**,而 `/ws/chat` 只验 JWT(任意角色可连),于是 `viewer` 连接会收到 `message.*` / `thinking.*` / `tool_call.*`(**含角色回复正文与推理内容**)、`message_persisted` / `message_edited` / `message_deleted`、`graph_update`、`mood`、`subagent_event` / `task_output` / `device_update` / `reminder` 等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与 `GET /sessions/{id}/messages` 的 owner/admin 档位不一致(**同一批内容从 WS 旁路漏出**)。② **规则(fail-closed,默认拒)**:**只有白名单里的 7 个「状态机信号」**(`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / `session_read`,**均不含任何内容**)发给**所有已认证连接**(含 `viewer`);**其余全部**(`stream_chunk` / `stream_end` / `thinking`、`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`、`graph_update` / `subagent_event` / `subagent.result` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`、`mood` / `device_update` / `reminder`)**只发 `owner` + `admin`**;**未登记的新帧类型默认只发 owner/admin**(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ **实现**:收口为**唯一一处**「按帧类型分类后投递」的入口(`internal/services/gateway/ws_frame_scope.go` + `hub.broadcast`),**不给每个调用点加开关**(那正是「漏一处即泄漏」的写法)——流式 `message.*` / `thinking.*` / `tool_call.*`(`broadcastStream`)、`stream_chunk` / `stream_end`(`runTurn` / `streamChat`)、`message_persisted` / `message_edited` / `message_deleted`、`mood` / `capacity_awaken`、编排状态信号(`StateSink`)、`graph_update` / `subagent_event` / `device_update` / `reminder`(`relay` 归一化)**全部经同一入口**,不存在第二条投递路径。④ **语义澄清(不是数据从此不可见)**:这条门管「**谁该被主动推送**」——`viewer` 的 **12 条只读监控端点**(v0.40 `viewerAuth`,含 `GET /personas` 的角色级 `mood`)**逐条照旧**;`/ws/chat` 的**连接鉴权不变**(`viewer` 仍可连,只是收不到上列帧);**定向回请求连接的帧**(`voice_output` / `error` / `subagent_ask`、`task_output_query` 的应答)不进这道门、行为不变。⑤ **测试与变异验证**:真 WS 连接 + 真角色 token 的**逐帧表驱动**断言(design/11 §7.1 表里**每一个**帧类型 × 三种角色:`owner` / `admin` 两类都收得到,`viewer` 只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把 `graph_update` 改回全广播 → 「viewer 收不到 `graph_update`」必须 FAIL(已跑,见提交说明),把 `session_idle` 改走受限路径 → 「viewer 收得到 `session_idle`」必须 FAIL(已跑)。⑥ **已知残留(本次未动,留待主人裁决)**:`/ws/chat` 的**上行**仍未按角色授权(`viewer` 仍可发 `chat` / `interrupt` / `subagent_ask` / `task_output_query` / `voice_input`,其应答是**定向**帧、不经过本门),且握手时的 `history` 回放仍对任意角色照发(`thinking` 已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。**(本残留已由 `v0.49` 收口:上行帧默认拒、握手回放仅 `owner`/`admin`,见上一条变更记录与 §3 顶部第二把角色门。)**
|
||||
- `v0.47`(**`graph_update` 子代理泳道根节点新增可选 `props.persona`**,对客户端**纯增量**;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):① **`subagent_spawn` 指定目标角色派发时**,该子代理在父会话图里的**分支根节点** `props` 多一个 `persona` 键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② **不指定角色派发时该键不出现**:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ **无新端点、无字段改名、无鉴权变更**;`subagent_event` 帧**未新增字段**(其 `role` 会等于目标角色——既有字段的取值语义变化,非形状变更)。④ **`subagent_spawn` 的 `persona` 参数不在本文档契约内**:那是给**模型**用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ **验证**:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在**目标角色**域」+「未知角色整批拒绝」+「**省略 `persona` 时结果 JSON 键集与旧版完全相同**」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。
|
||||
|
||||
@@ -319,12 +319,12 @@ func TestRun_StreamToolExecution_EmitsProgress(t *testing.T) {
|
||||
defer mu.Unlock()
|
||||
saw := false
|
||||
for _, ev := range evs {
|
||||
if ev.Kind == model.StreamKindToolExecution {
|
||||
if ev.Kind == model.StreamKindToolExecutionStart || ev.Kind == model.StreamKindToolExecutionEnd {
|
||||
saw = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatalf("expected a tool_execution_update event on the streaming path, got %+v", evs)
|
||||
t.Fatalf("expected a tool_execution start/end event on the streaming path, got %+v", evs)
|
||||
}
|
||||
}
|
||||
@@ -283,8 +283,15 @@ func TestRun_StreamDeltas(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var deltas []string
|
||||
l := New(Options{
|
||||
Agent: a,
|
||||
OnStream: func(ev model.StreamEvent) { mu.Lock(); deltas = append(deltas, ev.Delta); mu.Unlock() },
|
||||
Agent: a,
|
||||
OnStream: func(ev model.StreamEvent) {
|
||||
if ev.Kind != model.StreamKindTextDelta {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
deltas = append(deltas, ev.Delta)
|
||||
mu.Unlock()
|
||||
},
|
||||
MaxRounds: 8,
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ import (
|
||||
// goroutine 上,因此提取绝不延迟回复),错误记录日志
|
||||
// 而非致命。
|
||||
//
|
||||
// 回合边界发出 turn.start / turn.end 流式帧(design/04 §1.1):
|
||||
// start 在任何工作之前,end 经 defer 覆盖 Run 的每个出口
|
||||
// (最终答案 / 安全网终止 / 预算耗尽 / 步骤错误)。
|
||||
//
|
||||
// 当 turn 带着 Result 落定时,OnTurnEnd 钩子对该
|
||||
// turn 调用一次(绝不按步调用),其情绪标签记录在 Result.Mood 上,
|
||||
// 使循环外围的分层能推进人格状态并标记持久化的回复。
|
||||
@@ -46,6 +50,9 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
ctx = withCapState(ctx, &capStateAccum{state: capacity.StateQuiet})
|
||||
ctx = withLoopSession(ctx, sessionID)
|
||||
|
||||
l.emitTurnBoundary(ctx, model.StreamKindTurnStart)
|
||||
defer l.emitTurnBoundary(ctx, model.StreamKindTurnEnd)
|
||||
|
||||
// 恢复被中断的 run(design §1 durable execution):该会话有
|
||||
// 可恢复的 checkpoint 时,重建其上下文并从下一步继续,
|
||||
// 而不是从全新对话重启。不同的
|
||||
@@ -53,7 +60,6 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
// 全新开始(LoadLatest 未命中,或 stop reason 不可恢复)。
|
||||
msgs, startStep, _ := l.loadCheckpoint(ctx, sessionID, messages)
|
||||
msgs = l.resolveImages(ctx, msgs)
|
||||
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
||||
res := &Result{}
|
||||
// 分支 Run(子代理)把其 span 并入父会话的实时
|
||||
// 图;只有所属的 Run 开始与结束轮次。没有它,子代理
|
||||
@@ -62,6 +68,14 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
l.flow.StartRound(sessionID, lastUserText(msgs))
|
||||
defer func() { l.flow.EndRound(sessionID, res.Text) }()
|
||||
}
|
||||
return l.driveTurn(ctx, sessionID, msgs, startStep, res)
|
||||
}
|
||||
|
||||
// driveTurn 运行至多 maxRounds 个 step 的主循环:每步组装上下文、
|
||||
// 调用模型、执行被请求的工具并回灌。它在任何一种落定路径上返回
|
||||
// 已 settle 的 Result;step 或工具批次的错误以 %w 包装返回。
|
||||
func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Message, startStep int, res *Result) (*Result, error) {
|
||||
toolState := &toolLoopState{prevFP: make(map[string]bool)}
|
||||
for step := startStep; step <= l.maxRounds; step++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("loop: run: step %d: %w", step, err)
|
||||
@@ -88,10 +102,7 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
if outcome.final {
|
||||
res.Text = outcome.text
|
||||
res.StopReason = outcome.stopReason
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, outcome.stopReason)
|
||||
l.extractMemoryAsync(ctx, msgs, step)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
return l.settleAndReturn(ctx, sessionID, msgs, step, outcome.stopReason, res)
|
||||
}
|
||||
|
||||
batch, err := l.runToolCalls(rc, msgs, outcome.toolCalls, toolState)
|
||||
@@ -107,18 +118,22 @@ func (l *Loop) Run(ctx context.Context, sessionID string, messages []model.Messa
|
||||
if batch.terminate {
|
||||
res.Text = criticalToolLoopTerminalReason
|
||||
res.StopReason = StopReasonToolLoopTerminated
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, StopReasonToolLoopTerminated)
|
||||
l.extractMemoryAsync(ctx, msgs, step)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
return l.settleAndReturn(ctx, sessionID, msgs, step, StopReasonToolLoopTerminated, res)
|
||||
}
|
||||
}
|
||||
|
||||
// 预算耗尽而无最终答案。
|
||||
res.Steps = l.maxRounds
|
||||
res.StopReason = StopReasonMaxRounds
|
||||
l.saveCheckpoint(ctx, sessionID, l.maxRounds, msgs, StopReasonMaxRounds)
|
||||
l.extractMemoryAsync(ctx, msgs, l.maxRounds)
|
||||
return l.settleAndReturn(ctx, sessionID, msgs, l.maxRounds, StopReasonMaxRounds, res)
|
||||
}
|
||||
|
||||
// settleAndReturn 落定一个已得出 Result 的回合:以该路径的
|
||||
// stop reason 持久化终态 checkpoint、触发后台记忆提取、
|
||||
// 上报 turn 钩子(情绪标签写回 Result.Mood),然后把结果交还调用方。
|
||||
func (l *Loop) settleAndReturn(ctx context.Context, sessionID string, msgs []model.Message, step int, stopReason string, res *Result) (*Result, error) {
|
||||
l.saveCheckpoint(ctx, sessionID, step, msgs, stopReason)
|
||||
l.extractMemoryAsync(ctx, msgs, step)
|
||||
l.settleTurn(ctx, sessionID, msgs, res)
|
||||
return res, nil
|
||||
}
|
||||
@@ -2,7 +2,10 @@ 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"
|
||||
@@ -36,6 +39,11 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
}
|
||||
}
|
||||
|
||||
// 一次模型流(一次 ChatStream 调用)取一个 streamId:该流的
|
||||
// 每一帧共享它,而多步工具轮的后续模型调用取新的 id
|
||||
// (design/04 §1.1)。它与 TurnID 分工:turnId 标记整个回合,
|
||||
// streamId 标记回合内的一条流式消息。
|
||||
streamID := newStreamID()
|
||||
resp, err := adapter.ChatStream(ctx, req, func(ev model.StreamEvent) {
|
||||
// 在循环边界打上 session/turn 身份:适配器运行在
|
||||
// 循环之下,无法知道该流属于哪个会话,
|
||||
@@ -43,6 +51,7 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
// 前端便能把增量路由到活跃会话。
|
||||
ev.SessionID = loopSession(ctx)
|
||||
ev.TurnID = loopTurn(ctx)
|
||||
ev.StreamID = streamID
|
||||
// 为内部自主流程(backthink)抑制 UI 广播,
|
||||
// 其中间推理不得泄露进聊天流;当该 turn 的角色
|
||||
// 关闭思考帧时(showToUser: false)也抑制。两处门
|
||||
@@ -95,3 +104,31 @@ func isThinkingKind(kind model.StreamKind) bool {
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// captureFlow 返回一个 FlowPublisher 与读取其已发布快照的 reader,
|
||||
// 使测试能断言工具节点的 status 如何随 phase 演进(design/19 §1)。
|
||||
func captureFlow() (*FlowPublisher, func() []*trace.FlowGraph) {
|
||||
var mu sync.Mutex
|
||||
var seen []*trace.FlowGraph
|
||||
flow := NewFlowPublisher(func(subject string, payload any) {
|
||||
if subject != graphUpdateSubject {
|
||||
return
|
||||
}
|
||||
if g, ok := payload.(*trace.FlowGraph); ok {
|
||||
mu.Lock()
|
||||
seen = append(seen, g)
|
||||
mu.Unlock()
|
||||
}
|
||||
}, func(string) bool { return true })
|
||||
return flow, func() []*trace.FlowGraph {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]*trace.FlowGraph(nil), seen...)
|
||||
}
|
||||
}
|
||||
|
||||
// toolExecEvents 过滤出 tool_execution 三事件,保持发出顺序。
|
||||
func toolExecEvents(evs []model.StreamEvent) []model.StreamEvent {
|
||||
var out []model.StreamEvent
|
||||
for _, ev := range evs {
|
||||
switch ev.Kind {
|
||||
case model.StreamKindToolExecutionStart, model.StreamKindToolExecutionUpdate, model.StreamKindToolExecutionEnd:
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolNodeStatus 返回图中所有 tool 节点的当前 status。
|
||||
func toolNodeStatus(g *trace.FlowGraph) []string {
|
||||
var out []string
|
||||
for _, n := range g.Nodes {
|
||||
if n.Kind == "tool" {
|
||||
out = append(out, n.Status)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runToolTurn 用「模型请求 datetime → 工具执行 → 模型收尾」的两步流
|
||||
// 驱动一个带流式回调与 flow 图的 Run,返回收集到的事件与图快照。
|
||||
func runToolTurn(t *testing.T, toolResult any, toolErr error) ([]model.StreamEvent, []*trace.FlowGraph) {
|
||||
t.Helper()
|
||||
ad := &mockAdapter{stream: []mgResp{
|
||||
{resp: nativeDatetimeCall()},
|
||||
{resp: model.Response{Model: "m", Content: "done"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "datetime", result: toolResult, err: toolErr})
|
||||
a := newTestAgent(t, ad, td)
|
||||
onStream, streams := collectStream()
|
||||
flow, graphs := captureFlow()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8, Flow: flow, Tracer: trace.New(nil)})
|
||||
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "time?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
return streams(), graphs()
|
||||
}
|
||||
|
||||
// TestRun_ToolExecutionThreeEvents 锁住裁决 01.4.1:一次工具执行发出
|
||||
// start 与 end 两帧(顺序固定),phase 是 running/done 的结构化字段,
|
||||
// 绝不再塞进 Delta;工具名走 Tool 字段,ID 复用 provider 的
|
||||
// tool call id,使执行帧可与 tool_call.* 帧关联。
|
||||
func TestRun_ToolExecutionThreeEvents(t *testing.T) {
|
||||
t.Parallel()
|
||||
evs, _ := runToolTurn(t, map[string]any{"now": "2026-09-08 22:34:00"}, nil)
|
||||
ex := toolExecEvents(evs)
|
||||
if len(ex) != 2 {
|
||||
t.Fatalf("tool_execution events = %d, want 2 (start+end): %+v", len(ex), ex)
|
||||
}
|
||||
start, end := ex[0], ex[1]
|
||||
if start.Kind != model.StreamKindToolExecutionStart || start.Phase != model.ToolPhaseRunning {
|
||||
t.Fatalf("start event = %+v, want kind start phase running", start)
|
||||
}
|
||||
if end.Kind != model.StreamKindToolExecutionEnd || end.Phase != model.ToolPhaseDone {
|
||||
t.Fatalf("end event = %+v, want kind end phase done", end)
|
||||
}
|
||||
for _, ev := range ex {
|
||||
if ev.Tool != "datetime" {
|
||||
t.Fatalf("event tool = %q, want datetime (%+v)", ev.Tool, ev)
|
||||
}
|
||||
if ev.ID != "call_native_1" {
|
||||
t.Fatalf("event id = %q, want the provider tool call id (%+v)", ev.ID, ev)
|
||||
}
|
||||
if ev.Delta != "" {
|
||||
t.Fatalf("event delta = %q, want empty (phase must not ride in Delta)", ev.Delta)
|
||||
}
|
||||
if ev.StreamID != "" {
|
||||
t.Fatalf("event streamId = %q, want empty (tool execution is not a model stream)", ev.StreamID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ToolExecutionErrorPhase 覆盖失败路径:end 帧的 phase 为
|
||||
// error,且 flow 图中该 tool 节点最终也是 error —— 同一 phase 驱动
|
||||
// 两处(裁决 03.4.4)。
|
||||
func TestRun_ToolExecutionErrorPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
evs, graphs := runToolTurn(t, nil, errors.New("boom"))
|
||||
ex := toolExecEvents(evs)
|
||||
if len(ex) != 2 {
|
||||
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
|
||||
}
|
||||
if ex[1].Kind != model.StreamKindToolExecutionEnd || ex[1].Phase != model.ToolPhaseError {
|
||||
t.Fatalf("end event = %+v, want kind end phase error", ex[1])
|
||||
}
|
||||
last := graphs[len(graphs)-1]
|
||||
statuses := toolNodeStatus(last)
|
||||
if len(statuses) == 0 || statuses[0] != trace.FlowStatusError {
|
||||
t.Fatalf("final tool node status = %v, want [error] (phase-driven)", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ToolPhaseDrivesFlowNodeStatus 锁住裁决 03.4.4:工具执行期间
|
||||
// flow 图出现 running 的 tool 节点,执行结束后翻转为 done —— 节点状态
|
||||
// 由细粒度 phase 驱动,取值与事件 phase 完全一致。
|
||||
func TestRun_ToolPhaseDrivesFlowNodeStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
evs, graphs := runToolTurn(t, map[string]any{"now": "x"}, nil)
|
||||
sawRunning, sawDone := false, false
|
||||
for _, g := range graphs {
|
||||
for _, s := range toolNodeStatus(g) {
|
||||
switch s {
|
||||
case trace.FlowStatusRunning:
|
||||
sawRunning = true
|
||||
case trace.FlowStatusDone:
|
||||
sawDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawRunning || !sawDone {
|
||||
t.Fatalf("tool node statuses over the round: running=%v done=%v, want both (phase-driven)", sawRunning, sawDone)
|
||||
}
|
||||
ex := toolExecEvents(evs)
|
||||
if len(ex) != 2 || ex[0].Phase != model.ToolPhaseRunning || ex[1].Phase != model.ToolPhaseDone {
|
||||
t.Fatalf("phases = %+v, want [running done] matching the node statuses", ex)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_StreamIDPerModelStream 锁住裁决 01.4.2:同一次模型流的多帧
|
||||
// 共享同一 streamId;多步工具轮的第二次模型调用取新的 streamId;
|
||||
// turn 与 tool_execution 帧不属于任何模型流,不带 streamId。
|
||||
func TestRun_StreamIDPerModelStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{
|
||||
{resp: nativeDatetimeCall()},
|
||||
{resp: model.Response{Model: "m", Content: "done"}},
|
||||
}, chunks: [][]string{{"Hel"}, {"lo"}}}
|
||||
td := dispatcherWith(t, &mockTool{name: "datetime", result: "now"})
|
||||
a := newTestAgent(t, ad, td)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "time?")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
var firstStream, secondStream string
|
||||
for _, ev := range streams() {
|
||||
if ev.Kind != model.StreamKindTextDelta {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(ev.StreamID, "stream-") {
|
||||
t.Fatalf("delta streamId = %q, want a stream-* id", ev.StreamID)
|
||||
}
|
||||
switch ev.Delta {
|
||||
case "Hel":
|
||||
firstStream = ev.StreamID
|
||||
case "lo":
|
||||
secondStream = ev.StreamID
|
||||
}
|
||||
}
|
||||
if firstStream == "" || secondStream == "" {
|
||||
t.Fatalf("missing deltas for the two model streams (first=%q second=%q)", firstStream, secondStream)
|
||||
}
|
||||
if firstStream == secondStream {
|
||||
t.Fatalf("the two model streams share streamId %q; each ChatStream call must mint a fresh one", firstStream)
|
||||
}
|
||||
for _, ev := range streams() {
|
||||
switch ev.Kind {
|
||||
case model.StreamKindTurnStart, model.StreamKindTurnEnd,
|
||||
model.StreamKindToolExecutionStart, model.StreamKindToolExecutionEnd:
|
||||
if ev.StreamID != "" {
|
||||
t.Fatalf("%s carries streamId %q; only model-stream frames do", ev.Kind, ev.StreamID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_TurnBoundaryFrames 锁住裁决 01.4.3:回合以 turn.start 开始、
|
||||
// turn.end 结束(无论正常落定还是预算耗尽),两帧携带会话与 turn
|
||||
// 作用域、无载荷。
|
||||
func TestRun_TurnBoundaryFrames(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "ok"}}}, chunks: [][]string{{"ok"}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
ctx := WithTurnID(context.Background(), "turn-b")
|
||||
if _, err := l.Run(ctx, "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
evs := streams()
|
||||
if len(evs) < 3 {
|
||||
t.Fatalf("events = %+v, want at least turn.start + one delta + turn.end", evs)
|
||||
}
|
||||
if evs[0].Kind != model.StreamKindTurnStart || evs[len(evs)-1].Kind != model.StreamKindTurnEnd {
|
||||
t.Fatalf("boundary kinds = %q…%q, want turn_start…turn_end", evs[0].Kind, evs[len(evs)-1].Kind)
|
||||
}
|
||||
for _, boundary := range []model.StreamEvent{evs[0], evs[len(evs)-1]} {
|
||||
if boundary.SessionID != "web" || boundary.TurnID != "turn-b" {
|
||||
t.Fatalf("boundary scope = session %q turn %q, want web turn-b", boundary.SessionID, boundary.TurnID)
|
||||
}
|
||||
if boundary.Delta != "" || boundary.Phase != "" || boundary.Tool != "" {
|
||||
t.Fatalf("boundary carries payload: %+v", boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_TurnEndEmittedOnStepError 覆盖错误出口:模型步失败时 Run
|
||||
// 返回错误,但回合已经以 turn.start 开过,因此 turn.end 必须照样
|
||||
// 发出,客户端不会留下一个永远开着的回合。
|
||||
func TestRun_TurnEndEmittedOnStepError(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{{err: errors.New("model down")}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
if _, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err == nil {
|
||||
t.Fatal("Run: expected the step error to surface")
|
||||
}
|
||||
evs := streams()
|
||||
if len(evs) != 2 || evs[0].Kind != model.StreamKindTurnStart || evs[1].Kind != model.StreamKindTurnEnd {
|
||||
t.Fatalf("events = %+v, want exactly turn.start + turn.end around the failed run", evs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_TurnBoundaryMutedForInternalRuns 内部自主流程(backthink /
|
||||
// 子代理)经 WithMutedBroadcast 驱动同一个循环:回合边界帧与流帧
|
||||
// 一样被静音,中间过程绝不泄露进面向用户的聊天流。
|
||||
func TestRun_TurnBoundaryMutedForInternalRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Content: "ok"}}}, chunks: [][]string{{"o"}}}
|
||||
a := newTestAgent(t, ad, nil)
|
||||
onStream, streams := collectStream()
|
||||
l := New(Options{Agent: a, OnStream: onStream, MaxRounds: 8})
|
||||
|
||||
ctx := WithMutedBroadcast(context.Background())
|
||||
if _, err := l.Run(ctx, "sess-backthink", []model.Message{model.TextMessage(model.RoleUser, "reflect")}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if evs := streams(); len(evs) != 0 {
|
||||
t.Fatalf("muted run emitted %d events, want none: %+v", len(evs), evs)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ func collectStream() (func(model.StreamEvent), func() []model.StreamEvent) {
|
||||
// TestRun_StreamCarriesSessionAndTurn 钉住 A1 契约:循环给每个
|
||||
// 流式事件打上它被驱动的会话,以及经 WithTurnID 挂载的 turn id,
|
||||
// 使 WS hub 能把 message.delta/thinking.delta/tool_call.*
|
||||
// 路由到正确的会话与 turn(design/16 §4.2)。
|
||||
// 路由到正确的会话与 turn(design/16 §4.2)。回合边界帧
|
||||
// (turn.start/end,v0.50)同样带作用域,因此计入总数。
|
||||
func TestRun_StreamCarriesSessionAndTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{
|
||||
@@ -45,16 +46,19 @@ func TestRun_StreamCarriesSessionAndTurn(t *testing.T) {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
events := streams()
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("stream events = %d, want 2", len(events))
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("stream events = %d, want 4 (turn.start + 2 deltas + turn.end)", len(events))
|
||||
}
|
||||
for i, ev := range events {
|
||||
if ev.SessionID != "web" || ev.TurnID != "turn-xyz" {
|
||||
t.Fatalf("event[%d] scope = session %q turn %q, want session web turn turn-xyz", i, ev.SessionID, ev.TurnID)
|
||||
}
|
||||
}
|
||||
if events[0].Delta != "Hel" || events[1].Delta != "lo" {
|
||||
t.Fatalf("deltas = [%q %q], want [Hel lo]", events[0].Delta, events[1].Delta)
|
||||
if events[0].Kind != model.StreamKindTurnStart || events[3].Kind != model.StreamKindTurnEnd {
|
||||
t.Fatalf("boundary kinds = %q…%q, want turn_start…turn_end", events[0].Kind, events[3].Kind)
|
||||
}
|
||||
if events[1].Delta != "Hel" || events[2].Delta != "lo" {
|
||||
t.Fatalf("deltas = [%q %q], want [Hel lo]", events[1].Delta, events[2].Delta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ func TestRun_StreamWithoutTurnIDLeavesItEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRun_ToolExecutionCarriesScope 覆盖循环的另一个流式发出点:
|
||||
// tool_execution_update 进度帧也必须带作用域,否则它就会成为
|
||||
// tool_execution.start/end 进度帧也必须带作用域,否则它就会成为
|
||||
// 多会话客户端唯一无法路由的帧。
|
||||
func TestRun_ToolExecutionCarriesScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -105,7 +109,7 @@ func TestRun_ToolExecutionCarriesScope(t *testing.T) {
|
||||
}
|
||||
found := 0
|
||||
for _, ev := range streams() {
|
||||
if ev.Kind != model.StreamKindToolExecution {
|
||||
if ev.Kind != model.StreamKindToolExecutionStart && ev.Kind != model.StreamKindToolExecutionEnd {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
@@ -114,6 +118,6 @@ func TestRun_ToolExecutionCarriesScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
t.Fatal("no tool_execution_update event was emitted")
|
||||
t.Fatal("no tool_execution event was emitted")
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,8 @@ func TestRun_ThinkingShowToUserFalseWithholdsThinkingFrames(t *testing.T) {
|
||||
t.Fatalf("event kind %q emitted %d time(s), want 1 (body must be unaffected)", k, kinds[k])
|
||||
}
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %d, want only the 3 text frames", len(events))
|
||||
if len(events) != 5 {
|
||||
t.Fatalf("events = %d, want the 3 text frames plus the 2 turn boundaries", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestRun_ThinkingShowToUserDefaultBroadcastsThinking(t *testing.T) {
|
||||
if kinds[model.StreamKindThinkingDelta] != 1 || kinds[model.StreamKindThinkingStart] != 1 || kinds[model.StreamKindThinkingEnd] != 1 {
|
||||
t.Fatalf("thinking frames = %v, want start/delta/end each once with the option unset", kinds)
|
||||
}
|
||||
if len(events) != 6 {
|
||||
t.Fatalf("events = %d, want all 6 frames", len(events))
|
||||
if len(events) != 8 {
|
||||
t.Fatalf("events = %d, want all 8 frames (6 segment frames + 2 turn boundaries)", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,42 +114,8 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
|
||||
res.terminate = true
|
||||
}
|
||||
|
||||
// 安全门(design/11 §3):群聊/陌生人会话只能调用
|
||||
// 其可编辑标记允许该环境的工具(默认关闭)。不被允许的
|
||||
// 调用绝不执行;它以 not_allowed 软结果回灌
|
||||
// 并记录到审计 sink。它是该批的最终权威 ——
|
||||
// 后续的审计钩子可以收紧,但绝不重新放行被拒的工具。
|
||||
if d != nil {
|
||||
l.applyToolSecurityGate(ctx, d, calls, actions)
|
||||
}
|
||||
|
||||
// BatchAdmit 钩子(审计 / 政治审查):拦截整批。
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CallAdmit 钩子:对我们打算运行的调用逐个否决。
|
||||
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}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 执行前的三道门(安全门 + 批次/逐调用钩子)见 applyToolAdmissions。
|
||||
l.applyToolAdmissions(ctx, d, calls, actions)
|
||||
|
||||
// 决定串行还是并行:显式串行模式,或任一调用的工具
|
||||
// 被声明为串行(单个串行标记即让整组
|
||||
@@ -187,26 +153,8 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
|
||||
res.executed += len(idxs)
|
||||
}
|
||||
|
||||
// AfterCall 钩子(仅对实际执行过的调用)。
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AfterOutcome 钩子(对每个最终结果运行,无论是否跳过)。
|
||||
if l.hooks.AfterOutcome != nil {
|
||||
for i := range calls {
|
||||
if after := l.hooks.AfterOutcome(ctx, calls[i], outcomes[i]); after != nil {
|
||||
outcomes[i] = *after
|
||||
}
|
||||
}
|
||||
}
|
||||
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
|
||||
l.applyToolOutcomeHooks(ctx, calls, outcomes)
|
||||
|
||||
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮
|
||||
// (OpenClaw 的 shouldTerminateToolBatch)。
|
||||
@@ -277,48 +225,125 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
|
||||
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_update 进度并归一化
|
||||
// 结果。
|
||||
// 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}
|
||||
}
|
||||
l.emitToolExecution(ctx, tc.Name, "start")
|
||||
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
|
||||
result, err := d.Invoke(ctx, tc.Name, tc.Args)
|
||||
phase := "end"
|
||||
phase := model.ToolPhaseDone
|
||||
if err != nil {
|
||||
phase = "end:error"
|
||||
phase = model.ToolPhaseError
|
||||
}
|
||||
l.emitToolExecution(ctx, tc.Name, phase)
|
||||
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase)
|
||||
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)
|
||||
l.finishSpan(toolSpan, trace.SpanStatusError)
|
||||
} else {
|
||||
l.spanProp(toolSpan, "result", result)
|
||||
l.finishSpan(toolSpan, trace.SpanStatusOK)
|
||||
}
|
||||
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
|
||||
return out
|
||||
}
|
||||
|
||||
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
|
||||
// tool_execution_update 进度事件转发给前端;否则为空操作
|
||||
// (被静音的内部流程也会跳过它)。phase 是一个短标签,
|
||||
// 如 "start" 或 "end:error"。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, name, phase string) {
|
||||
// tool_execution 三事件(start / update / end)转发给前端;否则为
|
||||
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
|
||||
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
|
||||
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧
|
||||
// 关联。
|
||||
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase string) {
|
||||
if l.onStream == nil || isMutedBroadcast(ctx) {
|
||||
return
|
||||
}
|
||||
ev := model.StreamEvent{Kind: model.StreamKindToolExecution, Delta: phase + ":" + name}
|
||||
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID}
|
||||
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 的对应调用索引处,使调用方
|
||||
// 能按原始顺序渲染结果。信号量限制并发,在途
|
||||
|
||||
@@ -33,11 +33,40 @@ const (
|
||||
// StreamKindToolCallEnd closes a tool invocation block.
|
||||
StreamKindToolCallEnd StreamKind = "tool_call_end"
|
||||
|
||||
// StreamKindToolExecution reports intermediate progress while the loop runs
|
||||
// a tool that the model requested (design/04 §1.1 tool_execution_update). It
|
||||
// is emitted by the tool loop, not the model adapter; reserved for a later
|
||||
// milestone, but the wire mapping is defined so the frontend can react.
|
||||
StreamKindToolExecution StreamKind = "tool_execution_update"
|
||||
// StreamKindToolExecutionStart opens the execution of a tool the model
|
||||
// requested (design/04 §1.1: entering execution). It is emitted by the tool
|
||||
// loop, not the model adapter; Phase carries the running label.
|
||||
StreamKindToolExecutionStart StreamKind = "tool_execution_start"
|
||||
// StreamKindToolExecutionUpdate reports intermediate progress or partial
|
||||
// output while a requested tool runs (design/04 §1.1). Multiple frames per
|
||||
// execution are allowed; the wire mapping is defined, and a producer appears
|
||||
// once tools can report mid-execution progress.
|
||||
StreamKindToolExecutionUpdate StreamKind = "tool_execution_update"
|
||||
// StreamKindToolExecutionEnd closes a tool execution with its terminal
|
||||
// phase (done or error). It is emitted by the tool loop after Invoke
|
||||
// returns.
|
||||
StreamKindToolExecutionEnd StreamKind = "tool_execution_end"
|
||||
|
||||
// StreamKindTurnStart opens an assistant turn at the loop's run boundary
|
||||
// (design/04 §1.1 turn.start). It carries no payload: the frame shell's
|
||||
// session/turn identity brackets the turn on the client.
|
||||
StreamKindTurnStart StreamKind = "turn_start"
|
||||
// StreamKindTurnEnd closes an assistant turn at the loop's run boundary,
|
||||
// however the run settled (final answer, guardrail stop, terminated tool
|
||||
// loop, exhausted rounds or an error).
|
||||
StreamKindTurnEnd StreamKind = "turn_end"
|
||||
)
|
||||
|
||||
// 工具执行 phase 标签(design/04 §1.1 细粒度 phase)。取值与 design/19 §1
|
||||
// 的节点 status 枚举(running/done/error)一致:start/update 帧带 running,
|
||||
// end 帧带 done 或 error,前端与实时流程图据此驱动工具节点的状态。
|
||||
const (
|
||||
// ToolPhaseRunning 标记工具进入执行或仍在执行中。
|
||||
ToolPhaseRunning = "running"
|
||||
// ToolPhaseDone 标记工具执行成功结束。
|
||||
ToolPhaseDone = "done"
|
||||
// ToolPhaseError 标记工具执行失败结束。
|
||||
ToolPhaseError = "error"
|
||||
)
|
||||
|
||||
// StreamEvent is a single structured event in a streamed completion. It replaces
|
||||
@@ -49,14 +78,30 @@ type StreamEvent struct {
|
||||
Kind StreamKind
|
||||
// Delta carries the incremental payload: body text for TextDelta, reasoning
|
||||
// text for ThinkingDelta, or a partial_argument fragment for ToolCallDelta.
|
||||
// Tool-execution events never pack their phase or tool name in here —
|
||||
// those live in the structured Phase/Tool fields.
|
||||
Delta string
|
||||
// Index is the zero-based content-block index the event belongs to, when the
|
||||
// provider reports one. The frontend uses it to correlate a segment's
|
||||
// start/delta/end events.
|
||||
Index int
|
||||
// ID is the provider block/index identifier for a tool call (e.g. Anthropic's
|
||||
// tool_use block id). Empty for text segments.
|
||||
// tool_use block id). Empty for text segments. Tool-execution events reuse it
|
||||
// so the client can correlate an execution with its tool_call.* frames.
|
||||
ID string
|
||||
// Tool is the invoked tool's name on a tool_execution.* event. Empty on
|
||||
// every other kind.
|
||||
Tool string
|
||||
// Phase is the fine-grained execution label (ToolPhase*) on a
|
||||
// tool_execution.* event: running on start/update, done or error on end.
|
||||
// Empty on every other kind.
|
||||
Phase string
|
||||
// StreamID identifies the single model stream (one ChatStream call) a frame
|
||||
// belongs to. The loop stamps it at the stream boundary so every frame of
|
||||
// the same streamed message shares it, while a multi-step turn's later
|
||||
// model calls get fresh ids; consumers distinguish it from TurnID, which
|
||||
// stays constant across the whole turn.
|
||||
StreamID string
|
||||
// SessionID is the conversation the streamed event belongs to. It is stamped
|
||||
// by the loop at the turn boundary (loop.streamStep), which reads the session
|
||||
// the Run was started for; a model adapter, running below the loop and with no
|
||||
|
||||
@@ -23,6 +23,24 @@ const (
|
||||
FlowStatusError = "error"
|
||||
)
|
||||
|
||||
// FlowStatusForPhase 把工具执行的细粒度 phase(design/04 §1.1,
|
||||
// loop 随 tool_execution.end 帧一同记在 tool span 上)映射为流程图
|
||||
// 节点状态:phase 取值与本包状态枚举(design/19 §1 统一枚举)
|
||||
// 同构(running/done/error)。未知 phase 返回空串,调用方回落到
|
||||
// span 终态推导,因此缺 phase 的旧 span 不受影响。
|
||||
func FlowStatusForPhase(phase string) string {
|
||||
switch phase {
|
||||
case FlowStatusRunning:
|
||||
return FlowStatusRunning
|
||||
case FlowStatusDone:
|
||||
return FlowStatusDone
|
||||
case FlowStatusError:
|
||||
return FlowStatusError
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier.
|
||||
type FlowNode struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -143,17 +143,26 @@ func (g *RunGraph) addEdge(from, to string) {
|
||||
// AddSpan folds a finished span record into the graph as a node with its final
|
||||
// status. State-transition spans are the step container and are omitted — but
|
||||
// their parent link is remembered so lane resolution can walk through them.
|
||||
// tool 节点的终态优先由其携带的细粒度 phase 驱动(design/04 §1.1 /
|
||||
// design/19 §1:running/done/error),缺失时回落到 span 终态推导,
|
||||
// 因此两套来源对同一节点绝不可能得出不同状态。
|
||||
func (g *RunGraph) AddSpan(rec SpanRecord) {
|
||||
kind := kindForSpan(rec.Kind)
|
||||
if kind == "" {
|
||||
g.rememberParent(rec.ID, rec.ParentID)
|
||||
return
|
||||
}
|
||||
status := statusForFinished(rec.Status)
|
||||
if kind == nodeKindTool {
|
||||
if ph := FlowStatusForPhase(spanPhase(rec)); ph != "" {
|
||||
status = ph
|
||||
}
|
||||
}
|
||||
g.Upsert(NodeSpec{
|
||||
ID: rec.ID,
|
||||
Label: labelForSpan(rec),
|
||||
Kind: kind,
|
||||
Status: statusForFinished(rec.Status),
|
||||
Status: status,
|
||||
StartedAt: rec.StartedAt,
|
||||
EndedAt: rec.EndedAt,
|
||||
Props: safeSpanProps(rec),
|
||||
@@ -162,6 +171,13 @@ func (g *RunGraph) AddSpan(rec SpanRecord) {
|
||||
})
|
||||
}
|
||||
|
||||
// spanPhase 读取 tool span 上记录的执行 phase;缺失或类型不符时返回
|
||||
// 空串(调用方据此回落),不区分「没有」与「读不到」。
|
||||
func spanPhase(rec SpanRecord) string {
|
||||
ph, _ := rec.Props["phase"].(string)
|
||||
return ph
|
||||
}
|
||||
|
||||
// AddSpanStart registers a running placeholder for a span that just began, so a
|
||||
// node appears as "running" before it finishes. State-transition spans are
|
||||
// omitted (their parent link is still remembered for lane resolution), matching
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestFlowStatusForPhase 锁住 phase → 节点状态的映射(design/04 §1.1
|
||||
// 细粒度 phase、design/19 §1 统一枚举):三个已知 phase 原样通过,
|
||||
// 未知 phase 返回空串让调用方回落到 span 终态推导。
|
||||
func TestFlowStatusForPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
FlowStatusRunning: FlowStatusRunning,
|
||||
FlowStatusDone: FlowStatusDone,
|
||||
FlowStatusError: FlowStatusError,
|
||||
"started": "",
|
||||
"": "",
|
||||
}
|
||||
for phase, want := range cases {
|
||||
if got := FlowStatusForPhase(phase); got != want {
|
||||
t.Fatalf("FlowStatusForPhase(%q) = %q, want %q", phase, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSpanToolPhaseDrivesStatus 锁住裁决 03.4.4:tool 节点的终态由
|
||||
// span 携带的细粒度 phase 驱动(与 WS 的 tool_execution.end 帧同一
|
||||
// 取值),缺 phase 的 tool span 回落到 span 终态推导,两路绝不矛盾。
|
||||
func TestAddSpanToolPhaseDrivesStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now()
|
||||
tool := func(id, phase string, status SpanStatus) SpanRecord {
|
||||
rec := SpanRecord{ID: id, Name: "tool.echo", Kind: SpanKindTool,
|
||||
Status: status, StartedAt: now, EndedAt: now}
|
||||
if phase != "" {
|
||||
rec.Props = map[string]any{"phase": phase}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
rec SpanRecord
|
||||
want string
|
||||
}{
|
||||
{"phase done", tool("t1", FlowStatusDone, SpanStatusOK), FlowStatusDone},
|
||||
{"phase error overrides ok status", tool("t2", FlowStatusError, SpanStatusOK), FlowStatusError},
|
||||
{"no phase falls back to span status", tool("t3", "", SpanStatusOK), FlowStatusDone},
|
||||
{"no phase falls back to span error", tool("t4", "", SpanStatusError), FlowStatusError},
|
||||
{"unknown phase falls back to span status", tool("t5", "started", SpanStatusError), FlowStatusError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, now)
|
||||
g.AddSpan(tc.rec)
|
||||
if len(g.nodes) != 1 {
|
||||
t.Fatalf("nodes = %d, want 1", len(g.nodes))
|
||||
}
|
||||
if got := g.nodes[tc.rec.ID].Status; got != tc.want {
|
||||
t.Fatalf("tool node status = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSpanNonToolIgnoresPhase 确保 phase 只驱动 tool 节点:其他
|
||||
// kind 的 span 即使带 phase prop(未来不该有),也不改变其
|
||||
// 终态推导路径。
|
||||
func TestAddSpanNonToolIgnoresPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now()
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, now)
|
||||
g.AddSpan(SpanRecord{ID: "l1", Name: "llm.call", Kind: SpanKindLLM,
|
||||
Status: SpanStatusOK, StartedAt: now, EndedAt: now, Props: map[string]any{"phase": FlowStatusError}})
|
||||
if got := g.nodes["l1"].Status; got != FlowStatusDone {
|
||||
t.Fatalf("llm node status = %q, want %q (phase must not drive non-tool nodes)", got, FlowStatusDone)
|
||||
}
|
||||
}
|
||||
@@ -83,35 +83,71 @@ func subagentProgressEvent(e bus.Event) (wsEvent, bool) {
|
||||
// streamEventData 把流式的 model.StreamKind 映射为其下游 WS 事件
|
||||
// 类型并构建带类型的 data 载荷。它正是把 loop 的结构化
|
||||
// 事件变成前端所渲染的 message.*/thinking.*/tool_call.*/tool_execution.*
|
||||
// 帧的东西。未知类型回退到旧的 stream_chunk 形状,使
|
||||
// 与 turn.start/end 帧的东西。模型流帧(message.*/thinking.*/tool_call.*)
|
||||
// 额外携带 streamId(同一模型流的多帧关联键,design/04 §1.1);
|
||||
// tool_execution.* 与 turn.* 不属于某一次模型流,不带 streamId。
|
||||
// 未知类型回退到旧的 stream_chunk 形状,使
|
||||
// 未识别的事件仍能抵达客户端而不是被丢弃。
|
||||
func streamEventData(ev model.StreamEvent) (string, map[string]any) {
|
||||
switch ev.Kind {
|
||||
case model.StreamKindTextStart:
|
||||
return "message.start", map[string]any{"index": ev.Index}
|
||||
return "message.start", withStreamID(map[string]any{"index": ev.Index}, ev)
|
||||
case model.StreamKindTextDelta:
|
||||
return "message.delta", map[string]any{"delta": ev.Delta, "index": ev.Index}
|
||||
return "message.delta", withStreamID(map[string]any{"delta": ev.Delta, "index": ev.Index}, ev)
|
||||
case model.StreamKindTextEnd:
|
||||
return "message.end", map[string]any{"index": ev.Index}
|
||||
return "message.end", withStreamID(map[string]any{"index": ev.Index}, ev)
|
||||
case model.StreamKindThinkingStart:
|
||||
return "thinking.start", map[string]any{"index": ev.Index}
|
||||
return "thinking.start", withStreamID(map[string]any{"index": ev.Index}, ev)
|
||||
case model.StreamKindThinkingDelta:
|
||||
return "thinking.delta", map[string]any{"delta": ev.Delta, "index": ev.Index}
|
||||
return "thinking.delta", withStreamID(map[string]any{"delta": ev.Delta, "index": ev.Index}, ev)
|
||||
case model.StreamKindThinkingEnd:
|
||||
return "thinking.end", map[string]any{"index": ev.Index}
|
||||
return "thinking.end", withStreamID(map[string]any{"index": ev.Index}, ev)
|
||||
case model.StreamKindToolCallStart:
|
||||
return "tool_call.start", map[string]any{"id": ev.ID, "index": ev.Index}
|
||||
return "tool_call.start", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index}, ev)
|
||||
case model.StreamKindToolCallDelta:
|
||||
return "tool_call.delta", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}
|
||||
return "tool_call.delta", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}, ev)
|
||||
case model.StreamKindToolCallEnd:
|
||||
return "tool_call.end", map[string]any{"id": ev.ID, "index": ev.Index}
|
||||
case model.StreamKindToolExecution:
|
||||
return "tool_execution.update", map[string]any{"id": ev.ID, "index": ev.Index, "delta": ev.Delta}
|
||||
return "tool_call.end", withStreamID(map[string]any{"id": ev.ID, "index": ev.Index}, ev)
|
||||
case model.StreamKindToolExecutionStart:
|
||||
return "tool_execution.start", toolExecutionData(ev)
|
||||
case model.StreamKindToolExecutionUpdate:
|
||||
return "tool_execution.update", toolExecutionData(ev)
|
||||
case model.StreamKindToolExecutionEnd:
|
||||
return "tool_execution.end", toolExecutionData(ev)
|
||||
case model.StreamKindTurnStart:
|
||||
return "turn.start", nil
|
||||
case model.StreamKindTurnEnd:
|
||||
return "turn.end", nil
|
||||
default:
|
||||
return "stream_chunk", map[string]any{"delta": ev.Delta}
|
||||
}
|
||||
}
|
||||
|
||||
// withStreamID 把事件携带的 streamId 并入模型流帧的 data(空值省略,
|
||||
// 使未带流身份的事件保持旧形状)。它原地修改并返回 data,
|
||||
// 仅供 streamEventData 的字面量使用。
|
||||
func withStreamID(data map[string]any, ev model.StreamEvent) map[string]any {
|
||||
if ev.StreamID != "" {
|
||||
data["streamId"] = ev.StreamID
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toolExecutionData 构建 tool_execution.* 帧的载荷:工具名与细粒度
|
||||
// phase 为结构化字段(phase 绝不塞进 delta,design/04 §1.1 裁决
|
||||
// 01.4.1);id 复用 provider 的 tool call id(可关联 tool_call.* 帧),
|
||||
// update 帧可再携带 delta 局部输出。键在空值时省略。
|
||||
func toolExecutionData(ev model.StreamEvent) map[string]any {
|
||||
data := map[string]any{"tool": ev.Tool, "phase": ev.Phase}
|
||||
if ev.ID != "" {
|
||||
data["id"] = ev.ID
|
||||
}
|
||||
if ev.Kind == model.StreamKindToolExecutionUpdate && ev.Delta != "" {
|
||||
data["delta"] = ev.Delta
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态
|
||||
// cyrene.agent.<from>.event,调度器正是这样发布进度的。
|
||||
// 它刻意不匹配寻址事件(cyrene.agent.<from>.<to>.event)
|
||||
|
||||
@@ -81,7 +81,11 @@ func frameScopeCases() []frameScopeCase {
|
||||
streamed(model.StreamKindToolCallStart, "tool_call.start"),
|
||||
streamed(model.StreamKindToolCallDelta, "tool_call.delta"),
|
||||
streamed(model.StreamKindToolCallEnd, "tool_call.end"),
|
||||
streamed(model.StreamKindToolExecution, "tool_execution.update"),
|
||||
streamed(model.StreamKindToolExecutionStart, "tool_execution.start"),
|
||||
streamed(model.StreamKindToolExecutionUpdate, "tool_execution.update"),
|
||||
streamed(model.StreamKindToolExecutionEnd, "tool_execution.end"),
|
||||
streamed(model.StreamKindTurnStart, "turn.start"),
|
||||
streamed(model.StreamKindTurnEnd, "turn.end"),
|
||||
relayed("stream_chunk", false),
|
||||
relayed("stream_end", false),
|
||||
relayed("thinking", false),
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
)
|
||||
|
||||
// wsEventsAdapter 是一个两步脚本适配器:第一步发出一个文本增量后
|
||||
// 返回原生工具调用,第二步再发一个增量并收尾 —— 恰好覆盖
|
||||
// v0.50 事件批的全部帧形态(turn 边界、两次模型流的 streamId、
|
||||
// tool_execution 三事件中的 start/end)。
|
||||
type wsEventsAdapter struct{ calls int }
|
||||
|
||||
func (a *wsEventsAdapter) Name() string { return "ws-events-test" }
|
||||
|
||||
func (a *wsEventsAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
|
||||
return model.Response{Content: "done"}, nil
|
||||
}
|
||||
|
||||
func (a *wsEventsAdapter) ChatStream(_ context.Context, _ model.Request, on func(model.StreamEvent)) (model.Response, error) {
|
||||
a.calls++
|
||||
if a.calls == 1 {
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "Hel", Index: 0})
|
||||
}
|
||||
return model.Response{Model: "m", Content: "", ToolCalls: []model.ToolCall{{
|
||||
ID: "call_ws_1", Name: "datetime", Args: map[string]any{},
|
||||
}}}, nil
|
||||
}
|
||||
if on != nil {
|
||||
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "lo", Index: 0})
|
||||
}
|
||||
return model.Response{Model: "m", Content: "Hello"}, nil
|
||||
}
|
||||
|
||||
// wsEventsTool 是注册进真实 dispatcher 的最小工具,使 runTurn 走过
|
||||
// 完整的工具执行路径(含 tool_execution.start/end 与 phase)。
|
||||
type wsEventsTool struct{}
|
||||
|
||||
func (wsEventsTool) Name() string { return "datetime" }
|
||||
func (wsEventsTool) Description() string { return "test datetime tool" }
|
||||
func (wsEventsTool) Parameters() (map[string]any, error) { return map[string]any{}, nil }
|
||||
func (wsEventsTool) Execute(context.Context, any) (any, error) {
|
||||
return map[string]any{"now": "2026-10-04 12:00:00"}, nil
|
||||
}
|
||||
|
||||
// newEventsStreamHub 把 hub 接到带工具的真实 loop,OnStream 闭包与
|
||||
// cmd/cyrene 的接线一致(整个 StreamEvent 发布到 ws.stream),因此
|
||||
// 测试驱动的是生产路径 OnStream → bus → relay → broadcastStream。
|
||||
func newEventsStreamHub(t *testing.T) (*hub, *wsConn) {
|
||||
t.Helper()
|
||||
b := bus.New()
|
||||
reg := registry.New()
|
||||
td, err := tool.New(reg)
|
||||
if err != nil {
|
||||
t.Fatalf("tool.New: %v", err)
|
||||
}
|
||||
if err := td.Register(wsEventsTool{}); err != nil {
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsEventsAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
lp := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
MaxRounds: 8,
|
||||
OnStream: func(ev model.StreamEvent) {
|
||||
_ = b.Publish(context.Background(), wsStreamSubject, ev)
|
||||
},
|
||||
})
|
||||
hb := newTestHub(lp, b)
|
||||
hb.streaming = true
|
||||
hb.start()
|
||||
t.Cleanup(hb.close)
|
||||
c := newOwnerConn(hb, 64)
|
||||
hb.register(c)
|
||||
return hb, c
|
||||
}
|
||||
|
||||
// collectFramesUntilIdle 收集帧直到凑满 want 帧(含 stream_end)后再
|
||||
// 静默 200ms 确认没有迟到的 bus 帧。loop 帧经 bus 异步抵达而
|
||||
// stream_end 由 runTurn 直接广播,因此不能假定两者相对顺序。
|
||||
func collectFramesUntilIdle(t *testing.T, c *wsConn, want int) []wsEvent {
|
||||
t.Helper()
|
||||
var frames []wsEvent
|
||||
decode := func(b []byte) wsEvent {
|
||||
var ev wsEvent
|
||||
if err := json.Unmarshal(b, &ev); err != nil {
|
||||
t.Fatalf("decode frame %s: %v", b, err)
|
||||
}
|
||||
return ev
|
||||
}
|
||||
deadline := time.After(5 * time.Second)
|
||||
for {
|
||||
if len(frames) >= want {
|
||||
select {
|
||||
case b := <-c.send:
|
||||
frames = append(frames, decode(b))
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return frames
|
||||
case <-deadline:
|
||||
return frames
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case b := <-c.send:
|
||||
frames = append(frames, decode(b))
|
||||
case <-deadline:
|
||||
t.Fatalf("timed out with %d frames, want at least %d: %+v", len(frames), want, frames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runEventsTurn 驱动一个完整的工具回合并返回按 seq 排序的帧,
|
||||
// 以及其中的 stream_end(loop 帧经 bus 异步抵达而 stream_end 由
|
||||
// runTurn 直接广播,因此不能假定两者相对顺序,排序后统一断言)。
|
||||
func runEventsTurn(t *testing.T) ([]wsEvent, *wsEvent) {
|
||||
t.Helper()
|
||||
hb, c := newEventsStreamHub(t)
|
||||
if _, err := hb.runTurn(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "time?")}, loop.OwnerEnv()); err != nil {
|
||||
t.Fatalf("runTurn: %v", err)
|
||||
}
|
||||
frames := collectFramesUntilIdle(t, c, 7)
|
||||
sort.SliceStable(frames, func(i, j int) bool { return frames[i].Seq < frames[j].Seq })
|
||||
var streamEnd *wsEvent
|
||||
for i := range frames {
|
||||
if frames[i].Type == "stream_end" && streamEnd == nil {
|
||||
streamEnd = &frames[i]
|
||||
}
|
||||
}
|
||||
return frames, streamEnd
|
||||
}
|
||||
|
||||
// eventsByType 把 loop 帧(排除 stream_end)按类型归组,同时校验
|
||||
// 每帧都带会话与 turn-* 作用域。
|
||||
func eventsByType(t *testing.T, frames []wsEvent) map[string][]wsEvent {
|
||||
t.Helper()
|
||||
byType := map[string][]wsEvent{}
|
||||
for _, f := range frames {
|
||||
if f.Type == "stream_end" {
|
||||
continue
|
||||
}
|
||||
byType[f.Type] = append(byType[f.Type], f)
|
||||
if f.SessionID != "web" || !strings.HasPrefix(f.TurnID, "turn-") {
|
||||
t.Fatalf("frame %s scope = session %q turn %q, want web + turn-*", f.Type, f.SessionID, f.TurnID)
|
||||
}
|
||||
}
|
||||
return byType
|
||||
}
|
||||
|
||||
// TestStreamFramesTurnSequenceAndBoundaries 是 v0.50 回合边界与帧序的
|
||||
// 端到端锁定(裁决 01.4.3):经真实 loop + bus + hub 驱动的一个工具
|
||||
// 回合,loop 帧必须按序发出 turn.start → message.delta →
|
||||
// tool_execution.start → tool_execution.end → message.delta →
|
||||
// turn.end;turn 边界帧无载荷、整轮 turnId 一致、stream_end 照常收尾。
|
||||
func TestStreamFramesTurnSequenceAndBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
frames, streamEnd := runEventsTurn(t)
|
||||
|
||||
var seq []string
|
||||
for _, f := range frames {
|
||||
if f.Type != "stream_end" {
|
||||
seq = append(seq, f.Type)
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
"turn.start", "message.delta",
|
||||
"tool_execution.start", "tool_execution.end",
|
||||
"message.delta", "turn.end",
|
||||
}
|
||||
if len(seq) != len(want) {
|
||||
t.Fatalf("loop frame types = %v, want %v (frames=%+v)", seq, want, frames)
|
||||
}
|
||||
for i := range want {
|
||||
if seq[i] != want[i] {
|
||||
t.Fatalf("loop frame[%d] = %q, want %q (all=%v)", i, seq[i], want[i], seq)
|
||||
}
|
||||
}
|
||||
if streamEnd == nil {
|
||||
t.Fatalf("no stream_end frame arrived (frames=%+v)", frames)
|
||||
}
|
||||
|
||||
byType := eventsByType(t, frames)
|
||||
turnID := byType["turn.start"][0].TurnID
|
||||
if byType["turn.end"][0].TurnID != turnID || byType["message.delta"][0].TurnID != turnID {
|
||||
t.Fatalf("turnId differs across the turn: %q / %q / %q", turnID, byType["turn.end"][0].TurnID, byType["message.delta"][0].TurnID)
|
||||
}
|
||||
for _, boundary := range []wsEvent{byType["turn.start"][0], byType["turn.end"][0]} {
|
||||
if boundary.Data != nil {
|
||||
t.Fatalf("%s carries data %+v; the boundary frames must be payload-free", boundary.Type, boundary.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamFramesStreamIdAndToolExecutionPayloads 是 v0.50 streamId
|
||||
// 与工具执行帧载荷的端到端锁定(裁决 01.4.1 / 01.4.2):模型流帧带
|
||||
// 每流独立的 streamId(同流共享、跨流相异);tool_execution 帧带
|
||||
// 结构化 tool/phase/id,且不带 delta 拼串、不带 streamId。
|
||||
func TestStreamFramesStreamIdAndToolExecutionPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
frames, _ := runEventsTurn(t)
|
||||
byType := eventsByType(t, frames)
|
||||
|
||||
d1 := dataMap(t, byType["message.delta"][0])
|
||||
d2 := dataMap(t, byType["message.delta"][1])
|
||||
if d1["delta"] != "Hel" || d2["delta"] != "lo" {
|
||||
t.Fatalf("deltas = %v / %v, want Hel / lo", d1["delta"], d2["delta"])
|
||||
}
|
||||
s1, _ := d1["streamId"].(string)
|
||||
s2, _ := d2["streamId"].(string)
|
||||
if !strings.HasPrefix(s1, "stream-") || !strings.HasPrefix(s2, "stream-") {
|
||||
t.Fatalf("streamIds = %q / %q, want two stream-* ids (one per model stream)", s1, s2)
|
||||
}
|
||||
if s1 == s2 {
|
||||
t.Fatalf("the tool turn's two model streams share streamId %q; each ChatStream call must mint a fresh one", s1)
|
||||
}
|
||||
|
||||
startData := dataMap(t, byType["tool_execution.start"][0])
|
||||
endData := dataMap(t, byType["tool_execution.end"][0])
|
||||
if startData["tool"] != "datetime" || endData["tool"] != "datetime" {
|
||||
t.Fatalf("tool_execution tool = %v / %v, want datetime", startData["tool"], endData["tool"])
|
||||
}
|
||||
if startData["phase"] != model.ToolPhaseRunning || endData["phase"] != model.ToolPhaseDone {
|
||||
t.Fatalf("tool_execution phases = %v / %v, want running / done", startData["phase"], endData["phase"])
|
||||
}
|
||||
if startData["id"] != "call_ws_1" || endData["id"] != "call_ws_1" {
|
||||
t.Fatalf("tool_execution ids = %v / %v, want the provider call id", startData["id"], endData["id"])
|
||||
}
|
||||
for typ, data := range map[string]map[string]any{"start": startData, "end": endData} {
|
||||
if _, ok := data["delta"]; ok {
|
||||
t.Fatalf("tool_execution.%s carries a delta key; phase must not ride in delta: %v", typ, data)
|
||||
}
|
||||
if _, ok := data["streamId"]; ok {
|
||||
t.Fatalf("tool_execution.%s carries a streamId; tool execution is not a model stream: %v", typ, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dataMap 取帧 data 的映射形态,形状不符时直接失败。
|
||||
func dataMap(t *testing.T, ev wsEvent) map[string]any {
|
||||
t.Helper()
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("frame %s data = %+v, want a map", ev.Type, ev.Data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestStreamFramesToolExecutionErrorPhase 是失败路径的帧形状锁定:
|
||||
// 直接经生产映射路径(broadcastStream,即 relay 消费 ws.stream 的
|
||||
// 同一入口)广播一个 error phase 的 end 事件,验证帧携带
|
||||
// phase=error(裁决 03.4.4:前端/流程图据此把 tool 节点翻成
|
||||
// error 态)。真实的失败产生路径由 loop 包的
|
||||
// TestRun_ToolExecutionErrorPhase 端到端覆盖。
|
||||
func TestStreamFramesToolExecutionErrorPhase(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb, c := newEventsStreamHub(t)
|
||||
|
||||
hb.broadcastStream(model.StreamEvent{
|
||||
Kind: model.StreamKindToolExecutionEnd,
|
||||
Tool: "datetime",
|
||||
Phase: model.ToolPhaseError,
|
||||
ID: "call_ws_1",
|
||||
SessionID: "web",
|
||||
TurnID: "turn-err",
|
||||
})
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "tool_execution.end" {
|
||||
t.Fatalf("frame type = %q, want tool_execution.end (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
data := dataMap(t, ev)
|
||||
if data["phase"] != model.ToolPhaseError || data["tool"] != "datetime" {
|
||||
t.Fatalf("frame data = %+v, want phase=error tool=datetime", data)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user