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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user