@united-workforce/cli 0.6.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +120 -5
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
  4. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
  5. package/dist/__tests__/broker-prompt.test.d.ts +10 -0
  6. package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
  7. package/dist/__tests__/broker-prompt.test.js +129 -0
  8. package/dist/__tests__/broker-prompt.test.js.map +1 -0
  9. package/dist/__tests__/broker-step-active-turns.test.d.ts +20 -0
  10. package/dist/__tests__/broker-step-active-turns.test.d.ts.map +1 -0
  11. package/dist/__tests__/broker-step-active-turns.test.js +428 -0
  12. package/dist/__tests__/broker-step-active-turns.test.js.map +1 -0
  13. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts +13 -0
  14. package/dist/__tests__/broker-step-turn-chain-phase2.test.d.ts.map +1 -0
  15. package/dist/__tests__/broker-step-turn-chain-phase2.test.js +429 -0
  16. package/dist/__tests__/broker-step-turn-chain-phase2.test.js.map +1 -0
  17. package/dist/__tests__/config.test.js +33 -37
  18. package/dist/__tests__/config.test.js.map +1 -1
  19. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts +18 -0
  20. package/dist/__tests__/e2e-broker-step-suspend.test.d.ts.map +1 -0
  21. package/dist/__tests__/e2e-broker-step-suspend.test.js +313 -0
  22. package/dist/__tests__/e2e-broker-step-suspend.test.js.map +1 -0
  23. package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
  24. package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
  25. package/dist/__tests__/e2e-broker-step.test.js +278 -0
  26. package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
  27. package/dist/__tests__/e2e-mock-agent.test.js +1 -1
  28. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  29. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts +28 -0
  30. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.d.ts.map +1 -0
  31. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js +322 -0
  32. package/dist/__tests__/e2e-thread-resume-timeout-suspend.test.js.map +1 -0
  33. package/dist/__tests__/log-tag-validity.test.d.ts +2 -0
  34. package/dist/__tests__/log-tag-validity.test.d.ts.map +1 -0
  35. package/dist/__tests__/log-tag-validity.test.js +110 -0
  36. package/dist/__tests__/log-tag-validity.test.js.map +1 -0
  37. package/dist/__tests__/setup-agent-discovery.test.js +35 -23
  38. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  39. package/dist/__tests__/setup-no-llm.test.js +5 -2
  40. package/dist/__tests__/setup-no-llm.test.js.map +1 -1
  41. package/dist/__tests__/step-ask.test.js +9 -6
  42. package/dist/__tests__/step-ask.test.js.map +1 -1
  43. package/dist/__tests__/step-show-json.test.js +5 -5
  44. package/dist/__tests__/step-show-json.test.js.map +1 -1
  45. package/dist/__tests__/step-show-text.test.d.ts +2 -0
  46. package/dist/__tests__/step-show-text.test.d.ts.map +1 -0
  47. package/dist/__tests__/step-show-text.test.js +192 -0
  48. package/dist/__tests__/step-show-text.test.js.map +1 -0
  49. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts +21 -0
  50. package/dist/__tests__/step-turns-cli-subprocess.test.d.ts.map +1 -0
  51. package/dist/__tests__/step-turns-cli-subprocess.test.js +356 -0
  52. package/dist/__tests__/step-turns-cli-subprocess.test.js.map +1 -0
  53. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts +21 -0
  54. package/dist/__tests__/step-turns-panorama-phase3.test.d.ts.map +1 -0
  55. package/dist/__tests__/step-turns-panorama-phase3.test.js +476 -0
  56. package/dist/__tests__/step-turns-panorama-phase3.test.js.map +1 -0
  57. package/dist/__tests__/step-turns.test.d.ts +24 -0
  58. package/dist/__tests__/step-turns.test.d.ts.map +1 -0
  59. package/dist/__tests__/step-turns.test.js +646 -0
  60. package/dist/__tests__/step-turns.test.js.map +1 -0
  61. package/dist/__tests__/store-turn-chain.test.d.ts +2 -0
  62. package/dist/__tests__/store-turn-chain.test.d.ts.map +1 -0
  63. package/dist/__tests__/store-turn-chain.test.js +341 -0
  64. package/dist/__tests__/store-turn-chain.test.js.map +1 -0
  65. package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
  66. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
  67. package/dist/__tests__/thread-list-limit-offset.test.d.ts +24 -0
  68. package/dist/__tests__/thread-list-limit-offset.test.d.ts.map +1 -0
  69. package/dist/__tests__/thread-list-limit-offset.test.js +254 -0
  70. package/dist/__tests__/thread-list-limit-offset.test.js.map +1 -0
  71. package/dist/__tests__/thread-list-template-ms-date.test.js +7 -2
  72. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -1
  73. package/dist/__tests__/thread-poke.test.js +6 -6
  74. package/dist/__tests__/thread-poke.test.js.map +1 -1
  75. package/dist/__tests__/thread-resume.test.js +2 -2
  76. package/dist/__tests__/thread-resume.test.js.map +1 -1
  77. package/dist/__tests__/thread-suspend-step.test.js +1 -1
  78. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  79. package/dist/__tests__/thread.test.js +28 -14
  80. package/dist/__tests__/thread.test.js.map +1 -1
  81. package/dist/cli.js +910 -344
  82. package/dist/cli.js.map +1 -1
  83. package/dist/commands/broker-step.d.ts +117 -0
  84. package/dist/commands/broker-step.d.ts.map +1 -0
  85. package/dist/commands/broker-step.js +654 -0
  86. package/dist/commands/broker-step.js.map +1 -0
  87. package/dist/commands/config.d.ts.map +1 -1
  88. package/dist/commands/config.js +2 -23
  89. package/dist/commands/config.js.map +1 -1
  90. package/dist/commands/prompt.d.ts.map +1 -1
  91. package/dist/commands/prompt.js +43 -51
  92. package/dist/commands/prompt.js.map +1 -1
  93. package/dist/commands/setup.d.ts +6 -4
  94. package/dist/commands/setup.d.ts.map +1 -1
  95. package/dist/commands/setup.js +24 -27
  96. package/dist/commands/setup.js.map +1 -1
  97. package/dist/commands/step.d.ts +54 -6
  98. package/dist/commands/step.d.ts.map +1 -1
  99. package/dist/commands/step.js +484 -134
  100. package/dist/commands/step.js.map +1 -1
  101. package/dist/commands/thread.d.ts +4 -0
  102. package/dist/commands/thread.d.ts.map +1 -1
  103. package/dist/commands/thread.js +77 -151
  104. package/dist/commands/thread.js.map +1 -1
  105. package/dist/output-mappers.d.ts +8 -0
  106. package/dist/output-mappers.d.ts.map +1 -1
  107. package/dist/output-mappers.js +72 -18
  108. package/dist/output-mappers.js.map +1 -1
  109. package/dist/schemas.d.ts +3 -0
  110. package/dist/schemas.d.ts.map +1 -1
  111. package/dist/schemas.js +17 -3
  112. package/dist/schemas.js.map +1 -1
  113. package/dist/store.d.ts +147 -1
  114. package/dist/store.d.ts.map +1 -1
  115. package/dist/store.js +254 -1
  116. package/dist/store.js.map +1 -1
  117. package/dist/text-renderers.d.ts.map +1 -1
  118. package/dist/text-renderers.js +27 -2
  119. package/dist/text-renderers.js.map +1 -1
  120. package/package.json +7 -5
  121. package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
  122. package/src/__tests__/broker-prompt.test.ts +142 -0
  123. package/src/__tests__/broker-step-active-turns.test.ts +509 -0
  124. package/src/__tests__/broker-step-turn-chain-phase2.test.ts +525 -0
  125. package/src/__tests__/config.test.ts +35 -39
  126. package/src/__tests__/e2e-broker-step-suspend.test.ts +351 -0
  127. package/src/__tests__/e2e-broker-step.test.ts +320 -0
  128. package/src/__tests__/e2e-mock-agent.test.ts +1 -1
  129. package/src/__tests__/e2e-thread-resume-timeout-suspend.test.ts +360 -0
  130. package/src/__tests__/log-tag-validity.test.ts +124 -0
  131. package/src/__tests__/setup-agent-discovery.test.ts +35 -23
  132. package/src/__tests__/setup-no-llm.test.ts +5 -2
  133. package/src/__tests__/step-ask.test.ts +9 -6
  134. package/src/__tests__/step-show-json.test.ts +5 -5
  135. package/src/__tests__/step-show-text.test.ts +236 -0
  136. package/src/__tests__/step-turns-cli-subprocess.test.ts +411 -0
  137. package/src/__tests__/step-turns-panorama-phase3.test.ts +579 -0
  138. package/src/__tests__/step-turns.test.ts +734 -0
  139. package/src/__tests__/store-turn-chain.test.ts +386 -0
  140. package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
  141. package/src/__tests__/thread-list-limit-offset.test.ts +305 -0
  142. package/src/__tests__/thread-list-template-ms-date.test.ts +7 -2
  143. package/src/__tests__/thread-poke.test.ts +6 -6
  144. package/src/__tests__/thread-resume.test.ts +2 -2
  145. package/src/__tests__/thread-suspend-step.test.ts +1 -1
  146. package/src/__tests__/thread.test.ts +29 -15
  147. package/src/cli.ts +1056 -483
  148. package/src/commands/broker-step.ts +913 -0
  149. package/src/commands/config.ts +2 -24
  150. package/src/commands/prompt.ts +43 -51
  151. package/src/commands/setup.ts +25 -29
  152. package/src/commands/step.ts +645 -176
  153. package/src/commands/thread.ts +87 -192
  154. package/src/output-mappers.ts +99 -21
  155. package/src/schemas.ts +32 -2
  156. package/src/store.ts +297 -2
  157. package/src/text-renderers.ts +35 -2
  158. package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
  159. package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
  160. package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
  161. package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
  162. package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
  163. package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
  164. package/dist/__tests__/spawn-agent-json.test.js +0 -79
  165. package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
  166. package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
  167. package/src/__tests__/spawn-agent-json.test.ts +0 -100
@@ -0,0 +1,913 @@
1
+ /**
2
+ * Broker-driven step execution. Replaces the legacy `spawnAgent` /
3
+ * `executeAgentCommand` / last-stdout-line JSON parsing path with
4
+ * `broker.send()` over the Sumeru HTTP API.
5
+ *
6
+ * Phase 3 (#380) — `cmdThreadStepOnce`, `cmdThreadResume`, and `cmdThreadPoke`
7
+ * use this module instead of spawning per-role CLI binaries.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { putSchema, validate } from "@ocas/core";
12
+ import {
13
+ type AgentRoute,
14
+ type BrokerTurn,
15
+ createBroker,
16
+ createSessionStore,
17
+ type SendResult,
18
+ type SessionStore,
19
+ } from "@united-workforce/broker";
20
+ import {
21
+ type AgentAlias,
22
+ type AgentConfig,
23
+ type CasRef,
24
+ type StartNodePayload,
25
+ type StepContext,
26
+ type StepNodePayload,
27
+ SUSPEND_STATUS,
28
+ type ThreadId,
29
+ type Usage,
30
+ type WorkflowConfig,
31
+ type WorkflowPayload,
32
+ } from "@united-workforce/protocol";
33
+ import { createLogger, type ProcessLogger } from "@united-workforce/util";
34
+ import {
35
+ buildContinuationPrompt,
36
+ buildFrontmatterRetryPrompt,
37
+ buildOutputFormatInstruction,
38
+ buildRolePrompt,
39
+ buildThreadProgress,
40
+ mergeUsage,
41
+ tryFrontmatterFastPath,
42
+ trySuspendFastPath,
43
+ } from "@united-workforce/util-agent";
44
+ import {
45
+ clearActiveStep,
46
+ clearActiveTurns,
47
+ getActiveTurnHead,
48
+ setActiveStep,
49
+ setActiveTurnHead,
50
+ type UwfStore,
51
+ writeStepStart,
52
+ writeTurnNode,
53
+ } from "../store.js";
54
+ import { expandOutput, fail } from "./shared.js";
55
+
56
+ const log = createLogger({ sink: { kind: "stderr" } });
57
+
58
+ /** Tag for broker.send call site. */
59
+ const PL_BROKER_SEND = "BR0KR5ND";
60
+ /** Tag for frontmatter retry call sites. */
61
+ const PL_FRONTMATTER_RETRY = "F4RTM4RT";
62
+ /** Tag for frontmatter extraction failure. */
63
+ const PL_FRONTMATTER_FAIL = "F4FA117Z";
64
+
65
+ const MAX_FRONTMATTER_RETRIES = 2;
66
+
67
+ const DETAIL_SCHEMA = {
68
+ title: "broker-detail",
69
+ type: "object" as const,
70
+ required: ["sessionId", "duration", "turnCount"],
71
+ properties: {
72
+ sessionId: { type: "string" as const },
73
+ duration: { type: "integer" as const },
74
+ turnCount: { type: "integer" as const },
75
+ // Suspend diagnostics (issue #435) — present only on a timeout-suspended
76
+ // step. Optional, so the completed-path detail node is byte-for-byte
77
+ // unchanged (same content hash).
78
+ nativeId: { type: "string" as const },
79
+ elapsedMs: { type: "integer" as const },
80
+ reason: { type: "string" as const },
81
+ },
82
+ additionalProperties: false,
83
+ };
84
+
85
+ /** Result returned by `executeBrokerStep` — mirrors the legacy AdapterOutput surface. */
86
+ export type BrokerStepResult = {
87
+ stepHash: CasRef;
88
+ detailHash: CasRef;
89
+ role: string;
90
+ frontmatter: Record<string, unknown>;
91
+ body: string;
92
+ startedAtMs: number;
93
+ completedAtMs: number;
94
+ usage: Usage | null;
95
+ isError: boolean;
96
+ errorMessage: string | null;
97
+ };
98
+
99
+ /**
100
+ * Parse `--agent` overrides under the new `{host, gateway}` shape.
101
+ *
102
+ * Accepts:
103
+ * - alias e.g. `hermes` → `config.agents.hermes`
104
+ * - inline e.g. `http://h:7900 gw` → `{host: "http://h:7900", gateway: "gw"}`
105
+ *
106
+ * Single-token forms that don't match an alias fail with the documented
107
+ * message; this fully replaces the legacy "treat anything as a binary path"
108
+ * behaviour.
109
+ */
110
+ export function parseAgentOverride(override: string): AgentConfig {
111
+ const trimmed = override.trim();
112
+ if (trimmed === "") {
113
+ fail("agent override must not be empty");
114
+ }
115
+ const parts = trimmed.split(/\s+/).filter((p) => p.length > 0);
116
+ if (parts.length !== 2) {
117
+ fail(`agent override must be an alias or "<host> <gateway>"`);
118
+ }
119
+ const host = parts[0];
120
+ const gateway = parts[1];
121
+ if (host === undefined || gateway === undefined) {
122
+ fail(`agent override must be an alias or "<host> <gateway>"`);
123
+ }
124
+ return { host, gateway };
125
+ }
126
+
127
+ /**
128
+ * Resolve the agent route for a (workflow, role, override) triple.
129
+ * Mirrors the legacy `resolveAgentConfig` precedence:
130
+ * --agent override > agentOverrides[workflow][role] > defaultAgent
131
+ * Override may be an alias or an inline `"<host> <gateway>"` form.
132
+ */
133
+ export function resolveAgentRoute(
134
+ config: WorkflowConfig,
135
+ workflow: WorkflowPayload,
136
+ role: string,
137
+ agentOverride: string | null,
138
+ cwd: string | null,
139
+ ): AgentRoute {
140
+ if (agentOverride !== null) {
141
+ const fromAlias = config.agents[agentOverride as AgentAlias];
142
+ if (fromAlias !== undefined) {
143
+ return { host: fromAlias.host, gateway: fromAlias.gateway, cwd };
144
+ }
145
+ const parsed = parseAgentOverride(agentOverride);
146
+ return { host: parsed.host, gateway: parsed.gateway, cwd };
147
+ }
148
+
149
+ let alias: AgentAlias = config.defaultAgent;
150
+ if (config.agentOverrides !== null) {
151
+ const roleOverrides = config.agentOverrides[workflow.name];
152
+ if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
153
+ alias = roleOverrides[role];
154
+ }
155
+ }
156
+
157
+ const agentConfig = config.agents[alias];
158
+ if (agentConfig === undefined) {
159
+ fail(`unknown agent alias in config: ${alias}`);
160
+ }
161
+ return { host: agentConfig.host, gateway: agentConfig.gateway, cwd };
162
+ }
163
+
164
+ /**
165
+ * Path to the broker session store DB under the storage root. Mirrors the
166
+ * default used by `createSessionStore` but anchored at the user's `UWF_HOME`
167
+ * so multi-process scripts share the same SQLite file.
168
+ */
169
+ export function brokerSessionStorePath(storageRoot: string): string {
170
+ return join(storageRoot, "broker", "sessions.db");
171
+ }
172
+
173
+ /**
174
+ * Open (or create) the broker session store under `<storageRoot>/broker/sessions.db`.
175
+ * The caller is responsible for closing it.
176
+ */
177
+ export function openBrokerSessionStore(storageRoot: string): SessionStore {
178
+ return createSessionStore({ dbPath: brokerSessionStorePath(storageRoot) });
179
+ }
180
+
181
+ /**
182
+ * Look up the role's frontmatter / output schema in CAS so we can drive
183
+ * `tryFrontmatterFastPath`. The workflow payload only carries the schema's
184
+ * CAS hash; the JSON Schema itself lives in CAS via `WorkflowAdd`.
185
+ */
186
+ function loadRoleSchemaHash(workflow: WorkflowPayload, role: string): CasRef {
187
+ const roleDef = workflow.roles[role];
188
+ if (roleDef === undefined) {
189
+ fail(`unknown role "${role}" in workflow "${workflow.name}"`);
190
+ }
191
+ return roleDef.frontmatter as CasRef;
192
+ }
193
+
194
+ /**
195
+ * Build the output-format instruction for a role from its frontmatter schema in
196
+ * CAS. Returns an empty string when the schema node is missing.
197
+ */
198
+ function loadOutputFormatInstruction(uwf: UwfStore, schemaHash: CasRef): string {
199
+ const node = uwf.store.cas.get(schemaHash);
200
+ if (node === null) {
201
+ return "";
202
+ }
203
+ return buildOutputFormatInstruction(node.payload as Record<string, unknown>);
204
+ }
205
+
206
+ /** Extract the last assistant turn's content from a detail node, or null. */
207
+ function extractStepContent(uwf: UwfStore, detailRef: CasRef): string | null {
208
+ const detailNode = uwf.store.cas.get(detailRef);
209
+ if (detailNode === null) {
210
+ return null;
211
+ }
212
+ const detail = detailNode.payload as Record<string, unknown>;
213
+ const turns = detail.turns;
214
+ if (!Array.isArray(turns) || turns.length === 0) {
215
+ return null;
216
+ }
217
+ for (let i = turns.length - 1; i >= 0; i--) {
218
+ const turnRef = turns[i];
219
+ if (typeof turnRef !== "string") {
220
+ continue;
221
+ }
222
+ const turnNode = uwf.store.cas.get(turnRef as CasRef);
223
+ if (turnNode === null) {
224
+ continue;
225
+ }
226
+ const turn = turnNode.payload as Record<string, unknown>;
227
+ if (
228
+ turn.role === "assistant" &&
229
+ typeof turn.content === "string" &&
230
+ turn.content.trim() !== ""
231
+ ) {
232
+ return turn.content;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Walk the CAS step chain from `prevHash` back to the StartNode and return the
240
+ * steps in chronological order (oldest first) as StepContext records. Honors the
241
+ * caller-supplied `prev` pointer so poke replace-semantics (prev = old head's
242
+ * prev) produce the correct history. Mirrors the history assembly in
243
+ * util-agent's `buildContext`, but reuses the store the CLI already opened.
244
+ */
245
+ function collectStepContexts(uwf: UwfStore, prevHash: CasRef | null): StepContext[] {
246
+ const newestFirst: StepNodePayload[] = [];
247
+ let hash: CasRef | null = prevHash;
248
+ while (hash !== null) {
249
+ const node = uwf.store.cas.get(hash);
250
+ if (node === null || node.type !== uwf.schemas.stepNode) {
251
+ break;
252
+ }
253
+ const payload = node.payload as StepNodePayload;
254
+ newestFirst.push(payload);
255
+ hash = payload.prev;
256
+ }
257
+
258
+ const chronological = [...newestFirst].reverse();
259
+ return chronological.map((step) => ({
260
+ role: step.role,
261
+ output: expandOutput(uwf, step.output),
262
+ detail: step.detail,
263
+ agent: step.agent,
264
+ edgePrompt: step.edgePrompt ?? "",
265
+ startedAtMs: step.startedAtMs,
266
+ completedAtMs: step.completedAtMs,
267
+ cwd: step.cwd ?? "",
268
+ assembledPrompt: step.assembledPrompt ?? null,
269
+ usage: step.usage ?? null,
270
+ previousAttempts: step.previousAttempts ?? null,
271
+ content: extractStepContent(uwf, step.detail),
272
+ }));
273
+ }
274
+
275
+ export type AssembleBrokerPromptArgs = {
276
+ workflow: WorkflowPayload;
277
+ role: string;
278
+ threadId: ThreadId;
279
+ /** The thread's initial task prompt (StartNode.prompt). */
280
+ startPrompt: string;
281
+ /** Prior steps in chronological order (oldest first). */
282
+ steps: StepContext[];
283
+ /** Moderator edge prompt that routed to this step. */
284
+ edgePrompt: string;
285
+ /** Frontmatter deliverable-format instruction for the role's output schema. */
286
+ outputFormatInstruction: string;
287
+ };
288
+
289
+ /**
290
+ * Assemble the full agent prompt for a broker step. Combines the five
291
+ * components the legacy agent-CLI path produced (output-format instruction,
292
+ * thread progress, role prompt, task prompt, and continuation/edge context) so
293
+ * `broker.send()` receives the same context the spawned-agent path did.
294
+ *
295
+ * Mirrors `buildClaudeCodePrompt` from the agent-claude-code adapter.
296
+ */
297
+ export function assembleBrokerPrompt(args: AssembleBrokerPromptArgs): string {
298
+ const roleDef = args.workflow.roles[args.role];
299
+ const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
300
+ const isFirstVisit = !args.steps.some((s) => s.role === args.role);
301
+
302
+ const parts: string[] = [];
303
+
304
+ if (args.outputFormatInstruction !== "") {
305
+ parts.push(args.outputFormatInstruction, "");
306
+ }
307
+
308
+ // Inject thread progress so the agent knows step count and role visit count.
309
+ parts.push(buildThreadProgress(args.steps, args.role, args.threadId), "");
310
+
311
+ parts.push(rolePrompt, "", "## Task", args.startPrompt);
312
+
313
+ if (!isFirstVisit) {
314
+ // Re-entry (broker resumes the cached session): show only steps since the
315
+ // last visit, meta only.
316
+ parts.push("", buildContinuationPrompt(args.steps, args.role, args.edgePrompt));
317
+ } else if (args.steps.length > 0) {
318
+ // First visit with prior history: show steps with content for recent ones.
319
+ parts.push(
320
+ "",
321
+ buildContinuationPrompt(args.steps, args.role, args.edgePrompt, {
322
+ includeContent: true,
323
+ quota: 32000,
324
+ }),
325
+ );
326
+ } else {
327
+ parts.push("", "## Current Instruction", "", args.edgePrompt);
328
+ }
329
+
330
+ return parts.join("\n");
331
+ }
332
+
333
+ /**
334
+ * Persist the step's detail node. Phase 2 (#419): the detail no longer contains
335
+ * a `turns` array — turns are self-contained via their `prev`+`owner` chain.
336
+ * Only metadata (sessionId, duration, turnCount) is stored.
337
+ */
338
+ async function storeBrokerDetail(
339
+ uwf: UwfStore,
340
+ result: SendResult,
341
+ threadId: ThreadId,
342
+ role: string,
343
+ startedAtMs: number,
344
+ completedAtMs: number,
345
+ turnCount: number,
346
+ ): Promise<CasRef> {
347
+ const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
348
+
349
+ // Phase 2 (#419): clear the deprecated role-keyed active var for backward
350
+ // compatibility. The turns are already persisted via the turn chain.
351
+ clearActiveTurns(uwf.store, threadId, role);
352
+
353
+ const detail = {
354
+ sessionId: result.sessionId,
355
+ duration: Math.max(0, completedAtMs - startedAtMs),
356
+ turnCount,
357
+ };
358
+ return uwf.store.cas.put(detailSchemaHash, detail);
359
+ }
360
+
361
+ /**
362
+ * Build the realtime `onTurn` callback wired into `broker.send` (Phase 2, #419).
363
+ * For each arriving assistant turn it writes a TurnNode with:
364
+ * - `role: "assistant"`
365
+ * - `content: <turn content>`
366
+ * - `prev: <previous turn hash or null>`
367
+ * - `owner: <current step-start hash>`
368
+ * Then updates `@uwf/active-turn-head/<threadId>` to point to the new turn.
369
+ *
370
+ * The turn chain is self-contained — each turn links to its predecessor via
371
+ * `prev` and to its owning step via `owner`. No separate array accumulation
372
+ * is needed.
373
+ *
374
+ * Returns the turn count after the step completes (for detail node).
375
+ */
376
+ function makeOnTurn(
377
+ uwf: UwfStore,
378
+ threadId: ThreadId,
379
+ stepStartHash: CasRef,
380
+ ): { onTurn: (turn: BrokerTurn) => void; getTurnCount: () => number } {
381
+ let turnCount = 0;
382
+ // Get the current turn head before this step starts (could be from previous steps)
383
+ let prevTurnHash: CasRef | null = getActiveTurnHead(uwf.store, threadId);
384
+
385
+ const onTurn = (turn: BrokerTurn): void => {
386
+ // Write turn node with prev+owner chain
387
+ const turnHash = writeTurnNode(uwf, {
388
+ role: "assistant",
389
+ content: turn.content,
390
+ prev: prevTurnHash,
391
+ owner: stepStartHash,
392
+ });
393
+
394
+ // Update thread-keyed active turn head
395
+ setActiveTurnHead(uwf.store, threadId, turnHash);
396
+
397
+ // Also maintain deprecated role-keyed var for backward compatibility
398
+ // during transition period (can be removed in Phase 3)
399
+ // appendActiveTurn is called but we don't rely on it for turn retrieval
400
+
401
+ prevTurnHash = turnHash;
402
+ turnCount++;
403
+ };
404
+
405
+ const getTurnCount = (): number => turnCount;
406
+
407
+ return { onTurn, getTurnCount };
408
+ }
409
+
410
+ type WriteStepNodeArgs = {
411
+ uwf: UwfStore;
412
+ startHash: CasRef;
413
+ prevHash: CasRef | null;
414
+ role: string;
415
+ outputHash: CasRef;
416
+ detailHash: CasRef;
417
+ agentName: string;
418
+ edgePrompt: string;
419
+ startedAtMs: number;
420
+ completedAtMs: number;
421
+ cwd: string;
422
+ assembledPromptHash: CasRef | null;
423
+ usage: Usage | null;
424
+ previousAttempts: CasRef[] | null;
425
+ };
426
+
427
+ /** Persist a StepNode payload and verify it round-trips through schema validation. */
428
+ async function writeBrokerStepNode(args: WriteStepNodeArgs): Promise<CasRef> {
429
+ const payload: StepNodePayload = {
430
+ start: args.startHash,
431
+ prev: args.prevHash,
432
+ role: args.role,
433
+ output: args.outputHash,
434
+ detail: args.detailHash,
435
+ agent: args.agentName,
436
+ edgePrompt: args.edgePrompt,
437
+ startedAtMs: args.startedAtMs,
438
+ completedAtMs: args.completedAtMs,
439
+ cwd: args.cwd,
440
+ assembledPrompt: args.assembledPromptHash,
441
+ usage: args.usage,
442
+ previousAttempts: args.previousAttempts,
443
+ };
444
+ const hash = await args.uwf.store.cas.put(args.uwf.schemas.stepNode, payload);
445
+ const node = args.uwf.store.cas.get(hash);
446
+ if (node === null || !validate(args.uwf.store, node)) {
447
+ fail("broker step persisted a StepNode that failed schema validation");
448
+ }
449
+ return hash;
450
+ }
451
+
452
+ type ExtractOutcome = {
453
+ outputHash: CasRef;
454
+ frontmatter: Record<string, unknown>;
455
+ body: string;
456
+ };
457
+
458
+ /**
459
+ * Render the engine-level suspend (coroutine yield) wire format — frontmatter
460
+ * with `$status: "$SUSPEND"` plus a human-readable `reason`. Round-trips through
461
+ * the public {@link trySuspendFastPath}, which stores it against the reserved
462
+ * suspend-output schema.
463
+ *
464
+ * NOTE: this mirrors the adapter-side `buildSuspendOutput` in
465
+ * `@united-workforce/util-agent`, kept private here on purpose — the #381
466
+ * public-API cleanup deliberately keeps that helper OUT of the util-agent
467
+ * barrel, and `broker-step.ts` is engine/CLI code (not an adapter). The string
468
+ * is a one-liner over `SUSPEND_STATUS`, so duplicating it costs nothing and
469
+ * preserves the package boundary (see public-api-no-llm.test.ts).
470
+ */
471
+ function buildSuspendOutput(reason: string): string {
472
+ return `---\n$status: ${SUSPEND_STATUS}\nreason: ${reason}\n---\n`;
473
+ }
474
+
475
+ /**
476
+ * Suspend metadata carried by a broker `kind:"suspended"` SendResult — the
477
+ * fields needed to (a) build the human-readable `$SUSPEND` reason and (b)
478
+ * record diagnostics on the detail node for a future `--resume`.
479
+ */
480
+ type SuspendInfo = Readonly<{
481
+ reason: "timeout";
482
+ nativeId: string;
483
+ elapsedMs: number;
484
+ }>;
485
+
486
+ type WriteSuspendedStepArgs = {
487
+ uwf: UwfStore;
488
+ threadId: ThreadId;
489
+ suspend: SuspendInfo;
490
+ sessionId: string;
491
+ turnCount: number;
492
+ startHash: CasRef;
493
+ prevHash: CasRef | null;
494
+ role: string;
495
+ agentName: string;
496
+ edgePrompt: string;
497
+ startedAtMs: number;
498
+ completedAtMs: number;
499
+ cwd: string;
500
+ assembledPromptHash: CasRef | null;
501
+ previousAttempts: CasRef[] | null;
502
+ };
503
+
504
+ /**
505
+ * Route a broker `kind:"suspended"` result through the existing engine-level
506
+ * `$SUSPEND` exit (issue #435, Phase 2). A send timeout is NOT an error and NOT
507
+ * a frontmatter failure — it is a human gate. We build a suspend output via the
508
+ * shared {@link buildSuspendOutput} / {@link trySuspendFastPath} helpers (the
509
+ * same wire format any agent that prints `$status: "$SUSPEND"` produces), store
510
+ * it against the reserved `suspendOutput` schema, record `nativeId`/`elapsedMs`
511
+ * on the detail node for diagnostics, and persist a normal StepNode. Downstream
512
+ * thread-status resolution maps the head step's `$status: "$SUSPEND"` output to
513
+ * `status: "suspended"`, and `uwf thread resume` continues from `nativeId`.
514
+ */
515
+ async function writeSuspendedStep(args: WriteSuspendedStepArgs): Promise<BrokerStepResult> {
516
+ const reason =
517
+ `sumeru send timed out after ${args.suspend.elapsedMs}ms ` +
518
+ `(nativeId=${args.suspend.nativeId}); resume to continue`;
519
+ const suspendRaw = buildSuspendOutput(reason);
520
+ const extracted = await trySuspendFastPath(
521
+ suspendRaw,
522
+ args.uwf.schemas.suspendOutput,
523
+ args.uwf.store,
524
+ );
525
+ if (extracted === null) {
526
+ fail("broker step failed to build a $SUSPEND output node for a timeout-suspended send");
527
+ }
528
+
529
+ const detailSchemaHash = await putSchema(args.uwf.store, DETAIL_SCHEMA);
530
+ // Clear the deprecated role-keyed active var (parity with storeBrokerDetail).
531
+ clearActiveTurns(args.uwf.store, args.threadId, args.role);
532
+ const detail = {
533
+ sessionId: args.sessionId,
534
+ duration: Math.max(0, args.completedAtMs - args.startedAtMs),
535
+ turnCount: args.turnCount,
536
+ nativeId: args.suspend.nativeId,
537
+ elapsedMs: args.suspend.elapsedMs,
538
+ reason: args.suspend.reason,
539
+ };
540
+ const detailHash = await args.uwf.store.cas.put(detailSchemaHash, detail);
541
+
542
+ // Clear the active-step var: the step has reached a terminal (suspended) state.
543
+ clearActiveStep(args.uwf.store, args.threadId);
544
+
545
+ const stepHash = await writeBrokerStepNode({
546
+ uwf: args.uwf,
547
+ startHash: args.startHash,
548
+ prevHash: args.prevHash,
549
+ role: args.role,
550
+ outputHash: extracted.outputHash,
551
+ detailHash,
552
+ agentName: args.agentName,
553
+ edgePrompt: args.edgePrompt,
554
+ startedAtMs: args.startedAtMs,
555
+ completedAtMs: args.completedAtMs,
556
+ cwd: args.cwd,
557
+ assembledPromptHash: args.assembledPromptHash,
558
+ usage: null,
559
+ previousAttempts: args.previousAttempts,
560
+ });
561
+
562
+ return {
563
+ stepHash,
564
+ detailHash,
565
+ role: args.role,
566
+ frontmatter: extracted.frontmatter,
567
+ body: extracted.body,
568
+ startedAtMs: args.startedAtMs,
569
+ completedAtMs: args.completedAtMs,
570
+ usage: null,
571
+ isError: false,
572
+ errorMessage: null,
573
+ };
574
+ }
575
+
576
+ async function tryExtract(
577
+ uwf: UwfStore,
578
+ rawOutput: string,
579
+ outputSchema: CasRef,
580
+ ): Promise<ExtractOutcome | null> {
581
+ // `$status: "$SUSPEND"` is a reserved coroutine yield — store it against the
582
+ // suspend schema, bypassing the role's own frontmatter schema.
583
+ const suspend = await trySuspendFastPath(rawOutput, uwf.schemas.suspendOutput, uwf.store);
584
+ if (suspend !== null) {
585
+ return { outputHash: suspend.outputHash, frontmatter: suspend.frontmatter, body: suspend.body };
586
+ }
587
+ const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, uwf.store);
588
+ if (fastPath !== null) {
589
+ return {
590
+ outputHash: fastPath.outputHash,
591
+ frontmatter: fastPath.frontmatter,
592
+ body: fastPath.body,
593
+ };
594
+ }
595
+ return null;
596
+ }
597
+
598
+ /**
599
+ * Inputs for `executeBrokerStep`. The CLI pre-resolves the chain start, head,
600
+ * and workflow so this function only worries about the broker exchange + CAS
601
+ * write path.
602
+ */
603
+ export type ExecuteBrokerStepArgs = {
604
+ storageRoot: string;
605
+ uwf: UwfStore;
606
+ config: WorkflowConfig;
607
+ workflow: WorkflowPayload;
608
+ threadId: ThreadId;
609
+ role: string;
610
+ edgePrompt: string;
611
+ effectiveCwd: string;
612
+ startHash: CasRef;
613
+ prevHash: CasRef | null;
614
+ agentOverride: string | null;
615
+ previousAttempts: CasRef[] | null;
616
+ plog: ProcessLogger;
617
+ };
618
+
619
+ /**
620
+ * Drive one moderator-resolved role through `broker.send()`, frontmatter
621
+ * extraction (with retries on the same Sumeru session), and StepNode
622
+ * persistence. Returns a `BrokerStepResult` shaped for the existing
623
+ * `executeAndProcessAgentStep` flow.
624
+ *
625
+ * Phase 2 (#419) changes:
626
+ * - Writes step-start node at entry, sets `@uwf/active-step/<threadId>`
627
+ * - Turns are written with `prev`+`owner` chain via `writeTurnNode`
628
+ * - Updates `@uwf/active-turn-head/<threadId>` as turns arrive
629
+ * - Clears `@uwf/active-step/<threadId>` at completion
630
+ * - Detail node no longer contains `turns` array (turns self-contained)
631
+ *
632
+ * Side effects:
633
+ * - inserts a row in the broker session store keyed by (threadId, role)
634
+ * - writes step-start / turns / detail / StepNode to CAS
635
+ * - on extraction failure, persists an error StepNode (isError=true)
636
+ */
637
+ export async function executeBrokerStep(args: ExecuteBrokerStepArgs): Promise<BrokerStepResult> {
638
+ const sessionStore = openBrokerSessionStore(args.storageRoot);
639
+
640
+ try {
641
+ const route = resolveAgentRoute(
642
+ args.config,
643
+ args.workflow,
644
+ args.role,
645
+ args.agentOverride,
646
+ args.effectiveCwd === "" ? null : args.effectiveCwd,
647
+ );
648
+
649
+ const broker = createBroker({
650
+ sessionStore,
651
+ resolveRoute: () => route,
652
+ clientFactory: null,
653
+ });
654
+
655
+ args.plog.log(
656
+ PL_BROKER_SEND,
657
+ `broker.send role=${args.role} host=${route.host} gateway=${route.gateway}`,
658
+ null,
659
+ );
660
+
661
+ // Assemble the full agent prompt (output-format instruction + thread
662
+ // progress + role prompt + task + continuation/edge context) so the broker
663
+ // path sends the same context the legacy spawned-agent path did, rather than
664
+ // the bare edge prompt.
665
+ const outputSchemaHash = loadRoleSchemaHash(args.workflow, args.role);
666
+ const outputFormatInstruction = loadOutputFormatInstruction(args.uwf, outputSchemaHash);
667
+ const startNode = args.uwf.store.cas.get(args.startHash);
668
+ const startPrompt = startNode !== null ? (startNode.payload as StartNodePayload).prompt : "";
669
+ const steps = collectStepContexts(args.uwf, args.prevHash);
670
+ const assembledPrompt = assembleBrokerPrompt({
671
+ workflow: args.workflow,
672
+ role: args.role,
673
+ threadId: args.threadId,
674
+ startPrompt,
675
+ steps,
676
+ edgePrompt: args.edgePrompt,
677
+ outputFormatInstruction,
678
+ });
679
+ const assembledPromptHash = (await args.uwf.store.cas.put(
680
+ args.uwf.schemas.text,
681
+ assembledPrompt,
682
+ )) as CasRef;
683
+
684
+ const startedAtMs = Date.now();
685
+
686
+ // Phase 2 (#419): Write step-start node at entry
687
+ const stepStartHash = writeStepStart(args.uwf, {
688
+ role: args.role,
689
+ edgePrompt: args.edgePrompt,
690
+ stepIndex: steps.length,
691
+ prev: args.prevHash,
692
+ start: args.startHash,
693
+ startedAtMs,
694
+ cwd: args.effectiveCwd,
695
+ });
696
+
697
+ // Set the active-step var so other processes can detect in-flight state
698
+ setActiveStep(args.uwf.store, args.threadId, stepStartHash);
699
+
700
+ // Start-of-step clear (Phase 2, #398): a crash-rerun is a fresh attempt, so
701
+ // any residual active var from a failed prior attempt is dropped here —
702
+ // before any onTurn can fire — rather than appended onto. The clear is
703
+ // start-of-step only (NOT per-send): frontmatter retries below re-send on
704
+ // the cached session and must keep appending to the same attempt's var.
705
+ clearActiveTurns(args.uwf.store, args.threadId, args.role);
706
+
707
+ // Phase 2 (#419): makeOnTurn now writes turns with prev+owner chain
708
+ const { onTurn, getTurnCount } = makeOnTurn(args.uwf, args.threadId, stepStartHash);
709
+
710
+ const primary = await broker.send({
711
+ threadId: args.threadId,
712
+ role: args.role,
713
+ prompt: assembledPrompt,
714
+ onTurn,
715
+ });
716
+
717
+ // Suspend gate (issue #435, Phase 2): a broker `kind:"suspended"` result
718
+ // means the Sumeru send hit a timeout and emitted RFC #95 `suspend`. Route
719
+ // it through the existing `$SUSPEND` exit BEFORE any frontmatter work —
720
+ // suspend is a human gate, never retried, never an error. TypeScript's
721
+ // discriminated union forces this narrow before any `primary.output` read.
722
+ if (primary.kind === "suspended") {
723
+ return writeSuspendedStep({
724
+ uwf: args.uwf,
725
+ threadId: args.threadId,
726
+ suspend: {
727
+ reason: primary.reason,
728
+ nativeId: primary.nativeId,
729
+ elapsedMs: primary.elapsedMs,
730
+ },
731
+ sessionId: primary.sessionId,
732
+ turnCount: getTurnCount(),
733
+ startHash: args.startHash,
734
+ prevHash: args.prevHash,
735
+ role: args.role,
736
+ agentName: route.gateway,
737
+ edgePrompt: args.edgePrompt,
738
+ startedAtMs,
739
+ completedAtMs: Date.now(),
740
+ cwd: args.effectiveCwd,
741
+ assembledPromptHash,
742
+ previousAttempts: args.previousAttempts,
743
+ });
744
+ }
745
+
746
+ let extracted = await tryExtract(args.uwf, primary.output, outputSchemaHash);
747
+ let accumulatedUsage: Usage | null = brokerUsage(primary);
748
+ let lastOutput = primary.output;
749
+ let lastSessionId = primary.sessionId;
750
+
751
+ // Retry on the same (threadId, role) — the broker re-uses the cached
752
+ // Sumeru session, so the agent gets to "fix its frontmatter" with full
753
+ // context preserved. Retries carry the same onTurn and keep appending to
754
+ // the same attempt's active var (no clear between retries).
755
+ for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
756
+ const correctionPrompt = buildFrontmatterRetryPrompt(outputFormatInstruction);
757
+ log(
758
+ PL_FRONTMATTER_RETRY,
759
+ `frontmatter retry ${retry + 1}/${MAX_FRONTMATTER_RETRIES} thread=${args.threadId} role=${args.role}`,
760
+ );
761
+ const retryResult = await broker.send({
762
+ threadId: args.threadId,
763
+ role: args.role,
764
+ prompt: correctionPrompt,
765
+ onTurn,
766
+ });
767
+ // A retry can itself time out — honor the same suspend gate rather than
768
+ // dereferencing `retryResult.output` on a suspended result.
769
+ if (retryResult.kind === "suspended") {
770
+ return writeSuspendedStep({
771
+ uwf: args.uwf,
772
+ threadId: args.threadId,
773
+ suspend: {
774
+ reason: retryResult.reason,
775
+ nativeId: retryResult.nativeId,
776
+ elapsedMs: retryResult.elapsedMs,
777
+ },
778
+ sessionId: retryResult.sessionId,
779
+ turnCount: getTurnCount(),
780
+ startHash: args.startHash,
781
+ prevHash: args.prevHash,
782
+ role: args.role,
783
+ agentName: route.gateway,
784
+ edgePrompt: args.edgePrompt,
785
+ startedAtMs,
786
+ completedAtMs: Date.now(),
787
+ cwd: args.effectiveCwd,
788
+ assembledPromptHash,
789
+ previousAttempts: args.previousAttempts,
790
+ });
791
+ }
792
+ lastOutput = retryResult.output;
793
+ lastSessionId = retryResult.sessionId;
794
+ accumulatedUsage = mergeUsage(accumulatedUsage, brokerUsage(retryResult));
795
+ extracted = await tryExtract(args.uwf, lastOutput, outputSchemaHash);
796
+ }
797
+
798
+ const completedAtMs = Date.now();
799
+
800
+ // Phase 2 (#419): Pass turn count to detail (no longer from active var)
801
+ const detailHash = await storeBrokerDetail(
802
+ args.uwf,
803
+ { ...primary, output: lastOutput, sessionId: lastSessionId },
804
+ args.threadId,
805
+ args.role,
806
+ startedAtMs,
807
+ completedAtMs,
808
+ getTurnCount(),
809
+ );
810
+
811
+ // Phase 2 (#419): Clear active-step var on completion
812
+ clearActiveStep(args.uwf.store, args.threadId);
813
+
814
+ if (extracted === null) {
815
+ log(
816
+ PL_FRONTMATTER_FAIL,
817
+ `frontmatter extraction failed after ${MAX_FRONTMATTER_RETRIES} retries thread=${args.threadId} role=${args.role}`,
818
+ );
819
+ const errorMessage =
820
+ "Agent output does not contain valid YAML frontmatter matching the role schema " +
821
+ `after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
822
+ `Raw output (first 500 chars): ${lastOutput.slice(0, 500)}`;
823
+ const errorPayload = {
824
+ $status: "error" as const,
825
+ error: errorMessage,
826
+ phase: "frontmatter_extraction" as const,
827
+ };
828
+ const errorOutputHash = await args.uwf.store.cas.put(
829
+ args.uwf.schemas.errorOutput,
830
+ errorPayload,
831
+ );
832
+ const failedStepHash = await writeBrokerStepNode({
833
+ uwf: args.uwf,
834
+ startHash: args.startHash,
835
+ prevHash: args.prevHash,
836
+ role: args.role,
837
+ outputHash: errorOutputHash,
838
+ detailHash,
839
+ agentName: route.gateway,
840
+ edgePrompt: args.edgePrompt,
841
+ startedAtMs,
842
+ completedAtMs,
843
+ cwd: args.effectiveCwd,
844
+ assembledPromptHash,
845
+ usage: accumulatedUsage,
846
+ previousAttempts: null,
847
+ });
848
+ return {
849
+ stepHash: failedStepHash,
850
+ detailHash,
851
+ role: args.role,
852
+ frontmatter: { $status: "error" },
853
+ body: "",
854
+ startedAtMs,
855
+ completedAtMs,
856
+ usage: accumulatedUsage,
857
+ isError: true,
858
+ errorMessage,
859
+ };
860
+ }
861
+
862
+ const stepHash = await writeBrokerStepNode({
863
+ uwf: args.uwf,
864
+ startHash: args.startHash,
865
+ prevHash: args.prevHash,
866
+ role: args.role,
867
+ outputHash: extracted.outputHash,
868
+ detailHash,
869
+ agentName: route.gateway,
870
+ edgePrompt: args.edgePrompt,
871
+ startedAtMs,
872
+ completedAtMs,
873
+ cwd: args.effectiveCwd,
874
+ assembledPromptHash,
875
+ usage: accumulatedUsage,
876
+ previousAttempts: args.previousAttempts,
877
+ });
878
+
879
+ return {
880
+ stepHash,
881
+ detailHash,
882
+ role: args.role,
883
+ frontmatter: extracted.frontmatter,
884
+ body: extracted.body,
885
+ startedAtMs,
886
+ completedAtMs,
887
+ usage: accumulatedUsage,
888
+ isError: false,
889
+ errorMessage: null,
890
+ };
891
+ } finally {
892
+ sessionStore.close();
893
+ }
894
+ }
895
+
896
+ function brokerUsage(result: SendResult): Usage | null {
897
+ // Sumeru's `done` event reports per-exchange usage. Normalize into the
898
+ // engine's Usage shape so `mergeUsage` can sum across retries. A suspended
899
+ // result has no `done` (the discriminated union enforces this narrow) — a
900
+ // timeout carries no usage summary.
901
+ if (result.kind !== "completed") {
902
+ return null;
903
+ }
904
+ const done = result.done;
905
+ if (done === null || typeof done !== "object") {
906
+ return null;
907
+ }
908
+ const turns = done.turnCount;
909
+ const inputTokens = done.tokens !== null ? done.tokens.in : 0;
910
+ const outputTokens = done.tokens !== null ? done.tokens.out : 0;
911
+ const duration = done.durationMs;
912
+ return { turns, inputTokens, outputTokens, duration };
913
+ }