feat(orchestration,gateway): 子代理结果登 WS 帧 subagent_result(裁决 02.4.3)
- 编排层改为在 subagent.result 上发布带父会话归属的 SubagentResultEvent,后台批次结果不再消失在进程内总线 - gateway 中继:新帧常量 wsSubagentResult="subagent_result",eventBridge/bridgePatterns 登记该 topic,normalizeEvent 专用成形(帧壳 sessionId = 派生该批次的父会话) - 角色门 fail-closed:未登记进 frameReachesAll,owner/admin 可达、viewer 不可达,真 JWT 三角色 WS 断言钉住 - 前景(2s 内跑完)批次仍只回工具结果,不发此帧
This commit is contained in:
1 parent
0ce498754a
commit
8c6466cb00
4 files changed
+295
-22
No files matched your search
@@ -42,11 +42,11 @@ const (
|
||||
minSpawnTimeoutSeconds = 1
|
||||
maxSpawnTimeoutSeconds = 24 * 60 * 60
|
||||
|
||||
// SubagentResultSubject is the bus subject a finished background batch is
|
||||
// announced on, carrying the *MergedResult. It is the design/05 §9 结果回填 hook
|
||||
// for whichever component owns the session message queue (design/15); the
|
||||
// WS-visible per-agent status already travels on the scheduler's own progress
|
||||
// events, so this subject has no consumer until that queue lands.
|
||||
// SubagentResultSubject 是后台批次结算后广播其结果的主题,载荷是
|
||||
// *SubagentResultEvent(父会话 + 批次结果)。它是 design/05 §9「结果回填主
|
||||
// 会话并提示用户」的载体,消费者是 gateway 的中继:该主题被登记为 WS 下行帧
|
||||
// subagent_result(Docs/api-client.md §3.10),帧按载荷里的父会话归属,因此结果
|
||||
// 落回父会话的实时流而非悬空 hook;逐代理进行中状态仍走调度器进度事件。
|
||||
SubagentResultSubject = "subagent.result"
|
||||
)
|
||||
|
||||
@@ -191,7 +191,7 @@ func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
"persona", specs[0].PersonaID, "mode", specs[0].Mode, "session", specs[0].ParentSession)
|
||||
|
||||
if boolArg(m, "background") {
|
||||
go t.await(ids)
|
||||
go t.await(ids, specs[0].ParentSession)
|
||||
return runningSpawnResult(ids, specs), nil
|
||||
}
|
||||
return t.foreground(ids, specs), nil
|
||||
@@ -206,7 +206,7 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
|
||||
res, err := t.sched.Wait(waitCtx, ids)
|
||||
if err != nil {
|
||||
t.log.Info("sub-agent batch moved to background", "count", len(ids), "waited", t.wait.String())
|
||||
go t.await(ids)
|
||||
go t.await(ids, specs[0].ParentSession)
|
||||
return runningSpawnResult(ids, specs)
|
||||
}
|
||||
return doneSpawnResult(res)
|
||||
@@ -215,7 +215,9 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
|
||||
// await blocks until the batch settles and broadcasts the merged outcome as the
|
||||
// design/05 §2 "结束后写执行总结 → 返回主会话" hand-off. It runs on a detached
|
||||
// goroutine; a nil bus or an unknown id ends it quietly.
|
||||
func (t *SpawnTool) await(ids []string) {
|
||||
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
|
||||
// 归属到父会话的实时流,而不是发一张无归属的帧。
|
||||
func (t *SpawnTool) await(ids []string, parentSession string) {
|
||||
res, err := t.sched.Wait(context.Background(), ids)
|
||||
if err != nil {
|
||||
t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err)
|
||||
@@ -227,7 +229,8 @@ func (t *SpawnTool) await(ids []string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
if perr := b.Publish(context.Background(), SubagentResultSubject, res); perr != nil {
|
||||
payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)}
|
||||
if perr := b.Publish(context.Background(), SubagentResultSubject, payload); perr != nil {
|
||||
t.log.Warn("publish sub-agent results", "error", perr)
|
||||
}
|
||||
}
|
||||
@@ -265,6 +268,13 @@ type SpawnAgentResult struct {
|
||||
Steps int `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// SubagentResultEvent 是后台批次结算后发布的回填载荷(design/05 §9;契约见
|
||||
// Docs/api-client.md §3.10):SessionID 为父会话,其余字段即派发工具返回给模型的结果。
|
||||
type SubagentResultEvent struct {
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
*SpawnResult
|
||||
}
|
||||
|
||||
// doneSpawnResult maps a settled batch onto the tool result.
|
||||
func doneSpawnResult(res *MergedResult) *SpawnResult {
|
||||
out := &SpawnResult{Status: "done", Agents: make([]SpawnAgentResult, 0)}
|
||||
|
||||
@@ -176,10 +176,10 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
|
||||
s.runner = &mockRunner{gate: gate, result: &loop.Result{Text: "late", Steps: 2}}
|
||||
sp := NewSpawnTool(s, WithSpawnWait(40*time.Millisecond))
|
||||
|
||||
results := make(chan *MergedResult, 1)
|
||||
results := make(chan *SubagentResultEvent, 1)
|
||||
unsub, err := s.Bus().Subscribe(context.Background(), SubagentResultSubject,
|
||||
func(_ context.Context, e bus.Event) error {
|
||||
if res, ok := e.Payload.(*MergedResult); ok {
|
||||
if res, ok := e.Payload.(*SubagentResultEvent); ok {
|
||||
select {
|
||||
case results <- res:
|
||||
default:
|
||||
@@ -192,8 +192,11 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// 派发回合的会话(loop 在 Run 顶部把它播进 ctx):回填载荷必须带上它,
|
||||
// gateway 的中继才能把结果归属到父会话的实时流(design/05 §9)。
|
||||
ctx := loop.WithFlowBranch(context.Background(), parentSession)
|
||||
start := time.Now()
|
||||
raw, err := sp.Execute(context.Background(), map[string]any{"goal": "slow"})
|
||||
raw, err := sp.Execute(ctx, map[string]any{"goal": "slow"})
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
@@ -212,14 +215,32 @@ func TestSpawnToolMovesSlowBatchToBackground(t *testing.T) {
|
||||
close(gate)
|
||||
select {
|
||||
case res := <-results:
|
||||
if res.Total != 1 || res.Succeeded != 1 {
|
||||
t.Fatalf("background merge = %d total / %d ok, want 1/1", res.Total, res.Succeeded)
|
||||
if res.SessionID != parentSession {
|
||||
t.Fatalf("result sessionId = %q, want the dispatching session %q", res.SessionID, parentSession)
|
||||
}
|
||||
if res.Agents[0].Text != "late" {
|
||||
t.Fatalf("background result text = %q, want late", res.Agents[0].Text)
|
||||
if res.Status != "done" || len(res.Agents) != 1 {
|
||||
t.Fatalf("result = %+v, want one settled agent", res)
|
||||
}
|
||||
if a := res.Agents[0]; a.Text != "late" || a.Steps != 2 || a.Goal != "slow" || a.Status != string(StatusDone) {
|
||||
t.Fatalf("result agent = %+v, want the late/2-steps/done projection", a)
|
||||
}
|
||||
// 载荷形状是 api-client §3.10 登记的 WS 帧 data:键集锁定,避免
|
||||
// 生产者悄悄改键而文档与实现分叉。
|
||||
wire, merr := json.Marshal(res)
|
||||
if merr != nil {
|
||||
t.Fatalf("marshal result payload: %v", merr)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if uerr := json.Unmarshal(wire, &keys); uerr != nil {
|
||||
t.Fatalf("unmarshal result payload: %v", uerr)
|
||||
}
|
||||
for _, want := range []string{"sessionId", "status", "summary", "agents"} {
|
||||
if _, ok := keys[want]; !ok {
|
||||
t.Fatalf("result payload %s lacks key %q", wire, want)
|
||||
}
|
||||
}
|
||||
case <-time.After(waitTimeout):
|
||||
t.Fatal("a background batch never published its merged result")
|
||||
t.Fatal("a background batch never published its result payload")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,23 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/comm"
|
||||
)
|
||||
|
||||
// wsSubagentResult 是后台子代理批次结算后的下行帧类型(design/05 §9「结果回填
|
||||
// 主会话并提示用户」)。编排层在 orchestration.SubagentResultSubject 上发布批次
|
||||
// 结果,本文件的中继把它成形为这一帧并按载荷里的父会话归属。它属**内容帧**,
|
||||
// 因此走 design/11 §7.1 的 fail-closed 默认档:只发 owner + admin(不登记进那 7 个
|
||||
// 状态机信号白名单)。
|
||||
const wsSubagentResult = "subagent_result"
|
||||
|
||||
// eventBridge 把生产者 bus 主题映射为其下行 WS 事件类型。hub
|
||||
// 订阅这些(并重新归一化为 ws.* 类型),使前端既有的
|
||||
// 消费方亮起来,而生产者无需改变其主题名。
|
||||
var eventBridge = map[string]string{
|
||||
"iot.device_update": "device_update",
|
||||
"task.output": "task_output",
|
||||
"task.done": "task_done",
|
||||
"automation.result": "automation_result",
|
||||
"reminder.trigger": "reminder",
|
||||
"iot.device_update": "device_update",
|
||||
"task.output": "task_output",
|
||||
"task.done": "task_done",
|
||||
"automation.result": "automation_result",
|
||||
"reminder.trigger": "reminder",
|
||||
orchestration.SubagentResultSubject: wsSubagentResult,
|
||||
}
|
||||
|
||||
// bridgePatterns 是 hub 在 ws.> 之外额外订阅的 bus 模式,用于
|
||||
@@ -28,6 +36,9 @@ var bridgePatterns = []string{
|
||||
"automation.>",
|
||||
"reminder.>",
|
||||
"cyrene.agent.*.event",
|
||||
// 后台子代理批次结果:它既不匹配 ws.>,也不是 cyrene.agent 的 4 段广播形态,
|
||||
// 所以必须在这里登记订阅,否则后台结果永远上不了前台(只能看到进度帧)。
|
||||
orchestration.SubagentResultSubject,
|
||||
}
|
||||
|
||||
// normalizeEvent 把 bus 事件映射为下行 wsEvent。ws.* 主题保持
|
||||
@@ -36,6 +47,14 @@ var bridgePatterns = []string{
|
||||
// ws.subagent_event。对没有 WS 可见表示的事件返回 ok=false
|
||||
// (例如内部的 cyrene.agent ask/reply 流量)。
|
||||
func normalizeEvent(e bus.Event) (wsEvent, bool) {
|
||||
// 后台批次结果先走专用成形:它要按载荷里的父会话把结果归属到该会话的实时流
|
||||
// (帧壳 sessionId),这是 eventBridge 的原样透传做不到的。载荷形状不符预期时
|
||||
// 退回下面的通用映射(两路都只发 owner + admin)。
|
||||
if e.Subject == orchestration.SubagentResultSubject {
|
||||
if ev, ok := subagentResultEvent(e.Payload); ok {
|
||||
return ev, true
|
||||
}
|
||||
}
|
||||
if typ, ok := eventBridge[e.Subject]; ok {
|
||||
return wsEvent{Type: typ, Data: e.Payload}, true
|
||||
}
|
||||
@@ -48,6 +67,18 @@ func normalizeEvent(e bus.Event) (wsEvent, bool) {
|
||||
return wsEvent{}, false
|
||||
}
|
||||
|
||||
// subagentResultEvent 把后台批次的结果成形为下行帧:帧壳 sessionId 取自载荷的
|
||||
// 父会话,使结果落回**父会话**的实时消息流(design/05 §9);data 即载荷本身
|
||||
// (批次状态/汇总 + 逐代理结果,与派发工具返回给模型的结果同形)。载荷不是
|
||||
// *orchestration.SubagentResultEvent 时返回 ok=false,由调用方退回通用映射。
|
||||
func subagentResultEvent(payload any) (wsEvent, bool) {
|
||||
ev, ok := payload.(*orchestration.SubagentResultEvent)
|
||||
if !ok || ev == nil {
|
||||
return wsEvent{}, false
|
||||
}
|
||||
return wsEvent{Type: wsSubagentResult, SessionID: ev.SessionID, Data: ev}, true
|
||||
}
|
||||
|
||||
// subagentProgressEvent 把编排进度事件 —— 经 comm.PublishEvent 广播在
|
||||
// cyrene.agent.<from>.event 上 —— 映射为 ws.subagent_event
|
||||
// 帧。只有五个子代理进度名会被浮现;同一主题上广播的
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
|
||||
)
|
||||
|
||||
// 本文件锁住 02.4.3 的结果回填链路:编排层在
|
||||
// orchestration.SubagentResultSubject 上发布的后台批次结果,必须经
|
||||
// ws_bridge 的中继变成 `subagent_result` 下行帧、并**按父会话归属**
|
||||
// (帧壳 sessionId),从而出现在父会话的实时流里(design/05 §9);
|
||||
// 它是内容帧,因此只发 owner + admin(design/11 §7.1,fail-closed)。
|
||||
//
|
||||
// 断言走真路径:真 bus Publish → hub 的真订阅与中继 → 真 /ws/chat 连接
|
||||
// (真 JWT)。viewer 的负数用例(收不到)与 owner/admin 的正数用例
|
||||
// (收得到 + 载荷形状)在同一次发布上一起判定。
|
||||
|
||||
// subagentResultReadWindow 是排空一条连接时的单帧等待窗口。广播是异步的
|
||||
// (bus worker → relay → hub → 连接写协程),窗口内应收到全部相关帧;
|
||||
// 读超时即视为排空完毕。
|
||||
const subagentResultReadWindow = 1500 * time.Millisecond
|
||||
|
||||
// subagentFrame 是测试侧的线上帧视图(等价于客户端解码):帧壳 type/sessionId 直接
|
||||
// 解出,data 保留原始 JSON,使断言既能按形状解码、又能锁住键集。
|
||||
type subagentFrame struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// subagentFrameData 是 api-client §3.10 登记的 data 形状。
|
||||
type subagentFrameData struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Agents []struct {
|
||||
ID string `json:"id"`
|
||||
Goal string `json:"goal"`
|
||||
Role string `json:"role"`
|
||||
Mode string `json:"mode"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Text string `json:"text"`
|
||||
Steps int `json:"steps"`
|
||||
} `json:"agents"`
|
||||
}
|
||||
|
||||
// TestSubagentResultReachesOwnerAndAdminOnly 是后台结果回填的真 WS 帧断言:
|
||||
// 真 bus 上发布编排层的真实载荷类型 → owner/admin 收到 `subagent_result`
|
||||
// 帧(带父会话与逐代理结果),viewer **收不到**(内容帧默认拒)。
|
||||
func TestSubagentResultReachesOwnerAndAdminOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 新帧属内容帧:不在那 7 个状态机信号白名单里,必须落 fail-closed 的
|
||||
// owner+admin 档(design/11 §7.1「要放进白名单必须有明确理由并在本表登记」)。
|
||||
if frameReachesAll(wsSubagentResult) {
|
||||
t.Fatalf("%s reached every connection; a result frame carries content and must stay owner/admin only", wsSubagentResult)
|
||||
}
|
||||
|
||||
const parentSessionID = "web:subagent-dispatch"
|
||||
env := newEnv(t, nil, false)
|
||||
|
||||
conns := map[auth.Role]*websocket.Conn{}
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
conn := dialWS(t, env.ts.URL, env.token(t, role))
|
||||
defer conn.Close()
|
||||
conns[role] = conn
|
||||
}
|
||||
waitConnCount(t, env.srv, len(conns))
|
||||
|
||||
// 编排层发布的真实载荷(与 SpawnTool.await 同类型同键集)。
|
||||
payload := &orchestration.SubagentResultEvent{
|
||||
SessionID: parentSessionID,
|
||||
SpawnResult: &orchestration.SpawnResult{
|
||||
Status: "done",
|
||||
Summary: "子代理汇总: 共 1 个, 1 成功, 0 失败, 0 中止",
|
||||
Agents: []orchestration.SpawnAgentResult{{
|
||||
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Mode: "engineer",
|
||||
Status: "done", Summary: "完成 (步骤=2): 结论",
|
||||
Text: "结论", Steps: 2,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := env.bus.Publish(context.Background(), orchestration.SubagentResultSubject, payload); err != nil {
|
||||
t.Fatalf("publish %s: %v", orchestration.SubagentResultSubject, err)
|
||||
}
|
||||
// 哨兵:一个白名单状态信号收尾。它让每条连接的「本批帧已到齐」从猜测的
|
||||
// 空闲窗口变成可判定的边界;viewer 也必定收到它,故「viewer 什么都没收到」
|
||||
// 不会与「连接根本没工作」混淆。
|
||||
if err := env.bus.Publish(context.Background(), wsPrefix+scopeSentinel, map[string]any{"sessionId": parentSessionID}); err != nil {
|
||||
t.Fatalf("publish sentinel: %v", err)
|
||||
}
|
||||
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
|
||||
frames := drainFrames(t, conns[role])
|
||||
assertFrameCount(t, string(role), frames, wsSubagentResult, 1)
|
||||
assertFrameCount(t, string(role), frames, scopeSentinel, 1)
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("%s received %d frames, want exactly the result + the sentinel: %v", role, len(frames), frameTypesOf(frames))
|
||||
}
|
||||
assertSubagentResultFrame(t, string(role), findFrame(t, string(role), frames, wsSubagentResult), parentSessionID, payload)
|
||||
}
|
||||
|
||||
viewerFrames := drainFrames(t, conns[auth.RoleViewer])
|
||||
if len(viewerFrames) != 1 || viewerFrames[0].Type != scopeSentinel {
|
||||
t.Fatalf("viewer received %v, want only the whitelisted %q: the result frame is owner/admin only (design/11 §7.1)",
|
||||
frameTypesOf(viewerFrames), scopeSentinel)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSubagentResultFrame 按 api-client §3.10 登记的帧形状逐项断言一帧
|
||||
// `subagent_result`:帧壳 sessionId 必须是被回填的父会话,data 的**键集**必须
|
||||
// 恰好是登记的那四个,其汇总与逐代理字段必须与该批次发布的载荷一致。
|
||||
func assertSubagentResultFrame(t *testing.T, role string, frame subagentFrame, parentSessionID string, payload *orchestration.SubagentResultEvent) {
|
||||
t.Helper()
|
||||
if frame.SessionID != parentSessionID {
|
||||
t.Fatalf("%s frame sessionId = %q, want the parent session %q (the result must land in that session's stream)",
|
||||
role, frame.SessionID, parentSessionID)
|
||||
}
|
||||
wantKeys := []string{"sessionId", "status", "summary", "agents"}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(frame.Data, &keys); err != nil {
|
||||
t.Fatalf("%s frame data is not a JSON object: %v (%s)", role, err, frame.Data)
|
||||
}
|
||||
if len(keys) != len(wantKeys) {
|
||||
t.Fatalf("%s frame data keys = %v, want exactly %v (api-client §3.10)", role, keys, wantKeys)
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if _, ok := keys[k]; !ok {
|
||||
t.Fatalf("%s frame data lacks key %q (api-client §3.10): %v", role, k, keys)
|
||||
}
|
||||
}
|
||||
var data subagentFrameData
|
||||
if err := json.Unmarshal(frame.Data, &data); err != nil {
|
||||
t.Fatalf("%s frame data = %s: %v", role, frame.Data, err)
|
||||
}
|
||||
if data.SessionID != parentSessionID || data.Status != "done" {
|
||||
t.Fatalf("%s frame data = %+v, want the parent session and a settled batch", role, data)
|
||||
}
|
||||
if data.Summary != payload.Summary {
|
||||
t.Fatalf("%s frame summary = %q, want %q", role, data.Summary, payload.Summary)
|
||||
}
|
||||
if len(data.Agents) != 1 {
|
||||
t.Fatalf("%s frame agents = %+v, want one entry", role, data.Agents)
|
||||
}
|
||||
if got := data.Agents[0]; got.ID != "sub-a1" || got.Goal != "调研 X" || got.Role != "cyrene" ||
|
||||
got.Mode != "engineer" || got.Status != "done" || got.Text != "结论" || got.Steps != 2 {
|
||||
t.Fatalf("%s frame agent = %+v, want the dispatched sub-agent's outcome", role, got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainFrames 读取连接直到读超时,返回按到达顺序收到的帧。它只用于
|
||||
// 已发布的批次之后:第一批帧到达后剩余窗口用于确认「没有别的帧了」,
|
||||
// 这正是 viewer 负数用例所需的判定。
|
||||
func drainFrames(t *testing.T, conn *websocket.Conn) []subagentFrame {
|
||||
t.Helper()
|
||||
var out []subagentFrame
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(subagentResultReadWindow))
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var frame subagentFrame
|
||||
if uerr := json.Unmarshal(msg, &frame); uerr != nil {
|
||||
t.Fatalf("decode frame %s: %v", msg, uerr)
|
||||
}
|
||||
out = append(out, frame)
|
||||
}
|
||||
}
|
||||
|
||||
// assertFrameCount 断言角色收到的某类型帧恰好 want 条。
|
||||
func assertFrameCount(t *testing.T, role string, frames []subagentFrame, typ string, want int) {
|
||||
t.Helper()
|
||||
got := 0
|
||||
for _, f := range frames {
|
||||
if f.Type == typ {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s received %d %q frames, want %d (all frames: %v)", role, got, typ, want, frameTypesOf(frames))
|
||||
}
|
||||
}
|
||||
|
||||
// findFrame 返回角色收到的第一条 typ 帧。
|
||||
func findFrame(t *testing.T, role string, frames []subagentFrame, typ string) subagentFrame {
|
||||
t.Helper()
|
||||
for _, f := range frames {
|
||||
if f.Type == typ {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s received no %q frame (all frames: %v)", role, typ, frameTypesOf(frames))
|
||||
return subagentFrame{}
|
||||
}
|
||||
|
||||
// frameTypesOf 列出收到的帧类型,用于失败信息。
|
||||
func frameTypesOf(frames []subagentFrame) []string {
|
||||
out := make([]string, 0, len(frames))
|
||||
for _, f := range frames {
|
||||
out = append(out, f.Type)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user