@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,654 @@
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
+ import { join } from "node:path";
10
+ import { putSchema, validate } from "@ocas/core";
11
+ import { createBroker, createSessionStore, } from "@united-workforce/broker";
12
+ import { SUSPEND_STATUS, } from "@united-workforce/protocol";
13
+ import { createLogger } from "@united-workforce/util";
14
+ import { buildContinuationPrompt, buildFrontmatterRetryPrompt, buildOutputFormatInstruction, buildRolePrompt, buildThreadProgress, mergeUsage, tryFrontmatterFastPath, trySuspendFastPath, } from "@united-workforce/util-agent";
15
+ import { clearActiveStep, clearActiveTurns, getActiveTurnHead, setActiveStep, setActiveTurnHead, writeStepStart, writeTurnNode, } from "../store.js";
16
+ import { expandOutput, fail } from "./shared.js";
17
+ const log = createLogger({ sink: { kind: "stderr" } });
18
+ /** Tag for broker.send call site. */
19
+ const PL_BROKER_SEND = "BR0KR5ND";
20
+ /** Tag for frontmatter retry call sites. */
21
+ const PL_FRONTMATTER_RETRY = "F4RTM4RT";
22
+ /** Tag for frontmatter extraction failure. */
23
+ const PL_FRONTMATTER_FAIL = "F4FA117Z";
24
+ const MAX_FRONTMATTER_RETRIES = 2;
25
+ const DETAIL_SCHEMA = {
26
+ title: "broker-detail",
27
+ type: "object",
28
+ required: ["sessionId", "duration", "turnCount"],
29
+ properties: {
30
+ sessionId: { type: "string" },
31
+ duration: { type: "integer" },
32
+ turnCount: { type: "integer" },
33
+ // Suspend diagnostics (issue #435) — present only on a timeout-suspended
34
+ // step. Optional, so the completed-path detail node is byte-for-byte
35
+ // unchanged (same content hash).
36
+ nativeId: { type: "string" },
37
+ elapsedMs: { type: "integer" },
38
+ reason: { type: "string" },
39
+ },
40
+ additionalProperties: false,
41
+ };
42
+ /**
43
+ * Parse `--agent` overrides under the new `{host, gateway}` shape.
44
+ *
45
+ * Accepts:
46
+ * - alias e.g. `hermes` → `config.agents.hermes`
47
+ * - inline e.g. `http://h:7900 gw` → `{host: "http://h:7900", gateway: "gw"}`
48
+ *
49
+ * Single-token forms that don't match an alias fail with the documented
50
+ * message; this fully replaces the legacy "treat anything as a binary path"
51
+ * behaviour.
52
+ */
53
+ export function parseAgentOverride(override) {
54
+ const trimmed = override.trim();
55
+ if (trimmed === "") {
56
+ fail("agent override must not be empty");
57
+ }
58
+ const parts = trimmed.split(/\s+/).filter((p) => p.length > 0);
59
+ if (parts.length !== 2) {
60
+ fail(`agent override must be an alias or "<host> <gateway>"`);
61
+ }
62
+ const host = parts[0];
63
+ const gateway = parts[1];
64
+ if (host === undefined || gateway === undefined) {
65
+ fail(`agent override must be an alias or "<host> <gateway>"`);
66
+ }
67
+ return { host, gateway };
68
+ }
69
+ /**
70
+ * Resolve the agent route for a (workflow, role, override) triple.
71
+ * Mirrors the legacy `resolveAgentConfig` precedence:
72
+ * --agent override > agentOverrides[workflow][role] > defaultAgent
73
+ * Override may be an alias or an inline `"<host> <gateway>"` form.
74
+ */
75
+ export function resolveAgentRoute(config, workflow, role, agentOverride, cwd) {
76
+ if (agentOverride !== null) {
77
+ const fromAlias = config.agents[agentOverride];
78
+ if (fromAlias !== undefined) {
79
+ return { host: fromAlias.host, gateway: fromAlias.gateway, cwd };
80
+ }
81
+ const parsed = parseAgentOverride(agentOverride);
82
+ return { host: parsed.host, gateway: parsed.gateway, cwd };
83
+ }
84
+ let alias = config.defaultAgent;
85
+ if (config.agentOverrides !== null) {
86
+ const roleOverrides = config.agentOverrides[workflow.name];
87
+ if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
88
+ alias = roleOverrides[role];
89
+ }
90
+ }
91
+ const agentConfig = config.agents[alias];
92
+ if (agentConfig === undefined) {
93
+ fail(`unknown agent alias in config: ${alias}`);
94
+ }
95
+ return { host: agentConfig.host, gateway: agentConfig.gateway, cwd };
96
+ }
97
+ /**
98
+ * Path to the broker session store DB under the storage root. Mirrors the
99
+ * default used by `createSessionStore` but anchored at the user's `UWF_HOME`
100
+ * so multi-process scripts share the same SQLite file.
101
+ */
102
+ export function brokerSessionStorePath(storageRoot) {
103
+ return join(storageRoot, "broker", "sessions.db");
104
+ }
105
+ /**
106
+ * Open (or create) the broker session store under `<storageRoot>/broker/sessions.db`.
107
+ * The caller is responsible for closing it.
108
+ */
109
+ export function openBrokerSessionStore(storageRoot) {
110
+ return createSessionStore({ dbPath: brokerSessionStorePath(storageRoot) });
111
+ }
112
+ /**
113
+ * Look up the role's frontmatter / output schema in CAS so we can drive
114
+ * `tryFrontmatterFastPath`. The workflow payload only carries the schema's
115
+ * CAS hash; the JSON Schema itself lives in CAS via `WorkflowAdd`.
116
+ */
117
+ function loadRoleSchemaHash(workflow, role) {
118
+ const roleDef = workflow.roles[role];
119
+ if (roleDef === undefined) {
120
+ fail(`unknown role "${role}" in workflow "${workflow.name}"`);
121
+ }
122
+ return roleDef.frontmatter;
123
+ }
124
+ /**
125
+ * Build the output-format instruction for a role from its frontmatter schema in
126
+ * CAS. Returns an empty string when the schema node is missing.
127
+ */
128
+ function loadOutputFormatInstruction(uwf, schemaHash) {
129
+ const node = uwf.store.cas.get(schemaHash);
130
+ if (node === null) {
131
+ return "";
132
+ }
133
+ return buildOutputFormatInstruction(node.payload);
134
+ }
135
+ /** Extract the last assistant turn's content from a detail node, or null. */
136
+ function extractStepContent(uwf, detailRef) {
137
+ const detailNode = uwf.store.cas.get(detailRef);
138
+ if (detailNode === null) {
139
+ return null;
140
+ }
141
+ const detail = detailNode.payload;
142
+ const turns = detail.turns;
143
+ if (!Array.isArray(turns) || turns.length === 0) {
144
+ return null;
145
+ }
146
+ for (let i = turns.length - 1; i >= 0; i--) {
147
+ const turnRef = turns[i];
148
+ if (typeof turnRef !== "string") {
149
+ continue;
150
+ }
151
+ const turnNode = uwf.store.cas.get(turnRef);
152
+ if (turnNode === null) {
153
+ continue;
154
+ }
155
+ const turn = turnNode.payload;
156
+ if (turn.role === "assistant" &&
157
+ typeof turn.content === "string" &&
158
+ turn.content.trim() !== "") {
159
+ return turn.content;
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ /**
165
+ * Walk the CAS step chain from `prevHash` back to the StartNode and return the
166
+ * steps in chronological order (oldest first) as StepContext records. Honors the
167
+ * caller-supplied `prev` pointer so poke replace-semantics (prev = old head's
168
+ * prev) produce the correct history. Mirrors the history assembly in
169
+ * util-agent's `buildContext`, but reuses the store the CLI already opened.
170
+ */
171
+ function collectStepContexts(uwf, prevHash) {
172
+ const newestFirst = [];
173
+ let hash = prevHash;
174
+ while (hash !== null) {
175
+ const node = uwf.store.cas.get(hash);
176
+ if (node === null || node.type !== uwf.schemas.stepNode) {
177
+ break;
178
+ }
179
+ const payload = node.payload;
180
+ newestFirst.push(payload);
181
+ hash = payload.prev;
182
+ }
183
+ const chronological = [...newestFirst].reverse();
184
+ return chronological.map((step) => ({
185
+ role: step.role,
186
+ output: expandOutput(uwf, step.output),
187
+ detail: step.detail,
188
+ agent: step.agent,
189
+ edgePrompt: step.edgePrompt ?? "",
190
+ startedAtMs: step.startedAtMs,
191
+ completedAtMs: step.completedAtMs,
192
+ cwd: step.cwd ?? "",
193
+ assembledPrompt: step.assembledPrompt ?? null,
194
+ usage: step.usage ?? null,
195
+ previousAttempts: step.previousAttempts ?? null,
196
+ content: extractStepContent(uwf, step.detail),
197
+ }));
198
+ }
199
+ /**
200
+ * Assemble the full agent prompt for a broker step. Combines the five
201
+ * components the legacy agent-CLI path produced (output-format instruction,
202
+ * thread progress, role prompt, task prompt, and continuation/edge context) so
203
+ * `broker.send()` receives the same context the spawned-agent path did.
204
+ *
205
+ * Mirrors `buildClaudeCodePrompt` from the agent-claude-code adapter.
206
+ */
207
+ export function assembleBrokerPrompt(args) {
208
+ const roleDef = args.workflow.roles[args.role];
209
+ const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
210
+ const isFirstVisit = !args.steps.some((s) => s.role === args.role);
211
+ const parts = [];
212
+ if (args.outputFormatInstruction !== "") {
213
+ parts.push(args.outputFormatInstruction, "");
214
+ }
215
+ // Inject thread progress so the agent knows step count and role visit count.
216
+ parts.push(buildThreadProgress(args.steps, args.role, args.threadId), "");
217
+ parts.push(rolePrompt, "", "## Task", args.startPrompt);
218
+ if (!isFirstVisit) {
219
+ // Re-entry (broker resumes the cached session): show only steps since the
220
+ // last visit, meta only.
221
+ parts.push("", buildContinuationPrompt(args.steps, args.role, args.edgePrompt));
222
+ }
223
+ else if (args.steps.length > 0) {
224
+ // First visit with prior history: show steps with content for recent ones.
225
+ parts.push("", buildContinuationPrompt(args.steps, args.role, args.edgePrompt, {
226
+ includeContent: true,
227
+ quota: 32000,
228
+ }));
229
+ }
230
+ else {
231
+ parts.push("", "## Current Instruction", "", args.edgePrompt);
232
+ }
233
+ return parts.join("\n");
234
+ }
235
+ /**
236
+ * Persist the step's detail node. Phase 2 (#419): the detail no longer contains
237
+ * a `turns` array — turns are self-contained via their `prev`+`owner` chain.
238
+ * Only metadata (sessionId, duration, turnCount) is stored.
239
+ */
240
+ async function storeBrokerDetail(uwf, result, threadId, role, startedAtMs, completedAtMs, turnCount) {
241
+ const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
242
+ // Phase 2 (#419): clear the deprecated role-keyed active var for backward
243
+ // compatibility. The turns are already persisted via the turn chain.
244
+ clearActiveTurns(uwf.store, threadId, role);
245
+ const detail = {
246
+ sessionId: result.sessionId,
247
+ duration: Math.max(0, completedAtMs - startedAtMs),
248
+ turnCount,
249
+ };
250
+ return uwf.store.cas.put(detailSchemaHash, detail);
251
+ }
252
+ /**
253
+ * Build the realtime `onTurn` callback wired into `broker.send` (Phase 2, #419).
254
+ * For each arriving assistant turn it writes a TurnNode with:
255
+ * - `role: "assistant"`
256
+ * - `content: <turn content>`
257
+ * - `prev: <previous turn hash or null>`
258
+ * - `owner: <current step-start hash>`
259
+ * Then updates `@uwf/active-turn-head/<threadId>` to point to the new turn.
260
+ *
261
+ * The turn chain is self-contained — each turn links to its predecessor via
262
+ * `prev` and to its owning step via `owner`. No separate array accumulation
263
+ * is needed.
264
+ *
265
+ * Returns the turn count after the step completes (for detail node).
266
+ */
267
+ function makeOnTurn(uwf, threadId, stepStartHash) {
268
+ let turnCount = 0;
269
+ // Get the current turn head before this step starts (could be from previous steps)
270
+ let prevTurnHash = getActiveTurnHead(uwf.store, threadId);
271
+ const onTurn = (turn) => {
272
+ // Write turn node with prev+owner chain
273
+ const turnHash = writeTurnNode(uwf, {
274
+ role: "assistant",
275
+ content: turn.content,
276
+ prev: prevTurnHash,
277
+ owner: stepStartHash,
278
+ });
279
+ // Update thread-keyed active turn head
280
+ setActiveTurnHead(uwf.store, threadId, turnHash);
281
+ // Also maintain deprecated role-keyed var for backward compatibility
282
+ // during transition period (can be removed in Phase 3)
283
+ // appendActiveTurn is called but we don't rely on it for turn retrieval
284
+ prevTurnHash = turnHash;
285
+ turnCount++;
286
+ };
287
+ const getTurnCount = () => turnCount;
288
+ return { onTurn, getTurnCount };
289
+ }
290
+ /** Persist a StepNode payload and verify it round-trips through schema validation. */
291
+ async function writeBrokerStepNode(args) {
292
+ const payload = {
293
+ start: args.startHash,
294
+ prev: args.prevHash,
295
+ role: args.role,
296
+ output: args.outputHash,
297
+ detail: args.detailHash,
298
+ agent: args.agentName,
299
+ edgePrompt: args.edgePrompt,
300
+ startedAtMs: args.startedAtMs,
301
+ completedAtMs: args.completedAtMs,
302
+ cwd: args.cwd,
303
+ assembledPrompt: args.assembledPromptHash,
304
+ usage: args.usage,
305
+ previousAttempts: args.previousAttempts,
306
+ };
307
+ const hash = await args.uwf.store.cas.put(args.uwf.schemas.stepNode, payload);
308
+ const node = args.uwf.store.cas.get(hash);
309
+ if (node === null || !validate(args.uwf.store, node)) {
310
+ fail("broker step persisted a StepNode that failed schema validation");
311
+ }
312
+ return hash;
313
+ }
314
+ /**
315
+ * Render the engine-level suspend (coroutine yield) wire format — frontmatter
316
+ * with `$status: "$SUSPEND"` plus a human-readable `reason`. Round-trips through
317
+ * the public {@link trySuspendFastPath}, which stores it against the reserved
318
+ * suspend-output schema.
319
+ *
320
+ * NOTE: this mirrors the adapter-side `buildSuspendOutput` in
321
+ * `@united-workforce/util-agent`, kept private here on purpose — the #381
322
+ * public-API cleanup deliberately keeps that helper OUT of the util-agent
323
+ * barrel, and `broker-step.ts` is engine/CLI code (not an adapter). The string
324
+ * is a one-liner over `SUSPEND_STATUS`, so duplicating it costs nothing and
325
+ * preserves the package boundary (see public-api-no-llm.test.ts).
326
+ */
327
+ function buildSuspendOutput(reason) {
328
+ return `---\n$status: ${SUSPEND_STATUS}\nreason: ${reason}\n---\n`;
329
+ }
330
+ /**
331
+ * Route a broker `kind:"suspended"` result through the existing engine-level
332
+ * `$SUSPEND` exit (issue #435, Phase 2). A send timeout is NOT an error and NOT
333
+ * a frontmatter failure — it is a human gate. We build a suspend output via the
334
+ * shared {@link buildSuspendOutput} / {@link trySuspendFastPath} helpers (the
335
+ * same wire format any agent that prints `$status: "$SUSPEND"` produces), store
336
+ * it against the reserved `suspendOutput` schema, record `nativeId`/`elapsedMs`
337
+ * on the detail node for diagnostics, and persist a normal StepNode. Downstream
338
+ * thread-status resolution maps the head step's `$status: "$SUSPEND"` output to
339
+ * `status: "suspended"`, and `uwf thread resume` continues from `nativeId`.
340
+ */
341
+ async function writeSuspendedStep(args) {
342
+ const reason = `sumeru send timed out after ${args.suspend.elapsedMs}ms ` +
343
+ `(nativeId=${args.suspend.nativeId}); resume to continue`;
344
+ const suspendRaw = buildSuspendOutput(reason);
345
+ const extracted = await trySuspendFastPath(suspendRaw, args.uwf.schemas.suspendOutput, args.uwf.store);
346
+ if (extracted === null) {
347
+ fail("broker step failed to build a $SUSPEND output node for a timeout-suspended send");
348
+ }
349
+ const detailSchemaHash = await putSchema(args.uwf.store, DETAIL_SCHEMA);
350
+ // Clear the deprecated role-keyed active var (parity with storeBrokerDetail).
351
+ clearActiveTurns(args.uwf.store, args.threadId, args.role);
352
+ const detail = {
353
+ sessionId: args.sessionId,
354
+ duration: Math.max(0, args.completedAtMs - args.startedAtMs),
355
+ turnCount: args.turnCount,
356
+ nativeId: args.suspend.nativeId,
357
+ elapsedMs: args.suspend.elapsedMs,
358
+ reason: args.suspend.reason,
359
+ };
360
+ const detailHash = await args.uwf.store.cas.put(detailSchemaHash, detail);
361
+ // Clear the active-step var: the step has reached a terminal (suspended) state.
362
+ clearActiveStep(args.uwf.store, args.threadId);
363
+ const stepHash = await writeBrokerStepNode({
364
+ uwf: args.uwf,
365
+ startHash: args.startHash,
366
+ prevHash: args.prevHash,
367
+ role: args.role,
368
+ outputHash: extracted.outputHash,
369
+ detailHash,
370
+ agentName: args.agentName,
371
+ edgePrompt: args.edgePrompt,
372
+ startedAtMs: args.startedAtMs,
373
+ completedAtMs: args.completedAtMs,
374
+ cwd: args.cwd,
375
+ assembledPromptHash: args.assembledPromptHash,
376
+ usage: null,
377
+ previousAttempts: args.previousAttempts,
378
+ });
379
+ return {
380
+ stepHash,
381
+ detailHash,
382
+ role: args.role,
383
+ frontmatter: extracted.frontmatter,
384
+ body: extracted.body,
385
+ startedAtMs: args.startedAtMs,
386
+ completedAtMs: args.completedAtMs,
387
+ usage: null,
388
+ isError: false,
389
+ errorMessage: null,
390
+ };
391
+ }
392
+ async function tryExtract(uwf, rawOutput, outputSchema) {
393
+ // `$status: "$SUSPEND"` is a reserved coroutine yield — store it against the
394
+ // suspend schema, bypassing the role's own frontmatter schema.
395
+ const suspend = await trySuspendFastPath(rawOutput, uwf.schemas.suspendOutput, uwf.store);
396
+ if (suspend !== null) {
397
+ return { outputHash: suspend.outputHash, frontmatter: suspend.frontmatter, body: suspend.body };
398
+ }
399
+ const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, uwf.store);
400
+ if (fastPath !== null) {
401
+ return {
402
+ outputHash: fastPath.outputHash,
403
+ frontmatter: fastPath.frontmatter,
404
+ body: fastPath.body,
405
+ };
406
+ }
407
+ return null;
408
+ }
409
+ /**
410
+ * Drive one moderator-resolved role through `broker.send()`, frontmatter
411
+ * extraction (with retries on the same Sumeru session), and StepNode
412
+ * persistence. Returns a `BrokerStepResult` shaped for the existing
413
+ * `executeAndProcessAgentStep` flow.
414
+ *
415
+ * Phase 2 (#419) changes:
416
+ * - Writes step-start node at entry, sets `@uwf/active-step/<threadId>`
417
+ * - Turns are written with `prev`+`owner` chain via `writeTurnNode`
418
+ * - Updates `@uwf/active-turn-head/<threadId>` as turns arrive
419
+ * - Clears `@uwf/active-step/<threadId>` at completion
420
+ * - Detail node no longer contains `turns` array (turns self-contained)
421
+ *
422
+ * Side effects:
423
+ * - inserts a row in the broker session store keyed by (threadId, role)
424
+ * - writes step-start / turns / detail / StepNode to CAS
425
+ * - on extraction failure, persists an error StepNode (isError=true)
426
+ */
427
+ export async function executeBrokerStep(args) {
428
+ const sessionStore = openBrokerSessionStore(args.storageRoot);
429
+ try {
430
+ const route = resolveAgentRoute(args.config, args.workflow, args.role, args.agentOverride, args.effectiveCwd === "" ? null : args.effectiveCwd);
431
+ const broker = createBroker({
432
+ sessionStore,
433
+ resolveRoute: () => route,
434
+ clientFactory: null,
435
+ });
436
+ args.plog.log(PL_BROKER_SEND, `broker.send role=${args.role} host=${route.host} gateway=${route.gateway}`, null);
437
+ // Assemble the full agent prompt (output-format instruction + thread
438
+ // progress + role prompt + task + continuation/edge context) so the broker
439
+ // path sends the same context the legacy spawned-agent path did, rather than
440
+ // the bare edge prompt.
441
+ const outputSchemaHash = loadRoleSchemaHash(args.workflow, args.role);
442
+ const outputFormatInstruction = loadOutputFormatInstruction(args.uwf, outputSchemaHash);
443
+ const startNode = args.uwf.store.cas.get(args.startHash);
444
+ const startPrompt = startNode !== null ? startNode.payload.prompt : "";
445
+ const steps = collectStepContexts(args.uwf, args.prevHash);
446
+ const assembledPrompt = assembleBrokerPrompt({
447
+ workflow: args.workflow,
448
+ role: args.role,
449
+ threadId: args.threadId,
450
+ startPrompt,
451
+ steps,
452
+ edgePrompt: args.edgePrompt,
453
+ outputFormatInstruction,
454
+ });
455
+ const assembledPromptHash = (await args.uwf.store.cas.put(args.uwf.schemas.text, assembledPrompt));
456
+ const startedAtMs = Date.now();
457
+ // Phase 2 (#419): Write step-start node at entry
458
+ const stepStartHash = writeStepStart(args.uwf, {
459
+ role: args.role,
460
+ edgePrompt: args.edgePrompt,
461
+ stepIndex: steps.length,
462
+ prev: args.prevHash,
463
+ start: args.startHash,
464
+ startedAtMs,
465
+ cwd: args.effectiveCwd,
466
+ });
467
+ // Set the active-step var so other processes can detect in-flight state
468
+ setActiveStep(args.uwf.store, args.threadId, stepStartHash);
469
+ // Start-of-step clear (Phase 2, #398): a crash-rerun is a fresh attempt, so
470
+ // any residual active var from a failed prior attempt is dropped here —
471
+ // before any onTurn can fire — rather than appended onto. The clear is
472
+ // start-of-step only (NOT per-send): frontmatter retries below re-send on
473
+ // the cached session and must keep appending to the same attempt's var.
474
+ clearActiveTurns(args.uwf.store, args.threadId, args.role);
475
+ // Phase 2 (#419): makeOnTurn now writes turns with prev+owner chain
476
+ const { onTurn, getTurnCount } = makeOnTurn(args.uwf, args.threadId, stepStartHash);
477
+ const primary = await broker.send({
478
+ threadId: args.threadId,
479
+ role: args.role,
480
+ prompt: assembledPrompt,
481
+ onTurn,
482
+ });
483
+ // Suspend gate (issue #435, Phase 2): a broker `kind:"suspended"` result
484
+ // means the Sumeru send hit a timeout and emitted RFC #95 `suspend`. Route
485
+ // it through the existing `$SUSPEND` exit BEFORE any frontmatter work —
486
+ // suspend is a human gate, never retried, never an error. TypeScript's
487
+ // discriminated union forces this narrow before any `primary.output` read.
488
+ if (primary.kind === "suspended") {
489
+ return writeSuspendedStep({
490
+ uwf: args.uwf,
491
+ threadId: args.threadId,
492
+ suspend: {
493
+ reason: primary.reason,
494
+ nativeId: primary.nativeId,
495
+ elapsedMs: primary.elapsedMs,
496
+ },
497
+ sessionId: primary.sessionId,
498
+ turnCount: getTurnCount(),
499
+ startHash: args.startHash,
500
+ prevHash: args.prevHash,
501
+ role: args.role,
502
+ agentName: route.gateway,
503
+ edgePrompt: args.edgePrompt,
504
+ startedAtMs,
505
+ completedAtMs: Date.now(),
506
+ cwd: args.effectiveCwd,
507
+ assembledPromptHash,
508
+ previousAttempts: args.previousAttempts,
509
+ });
510
+ }
511
+ let extracted = await tryExtract(args.uwf, primary.output, outputSchemaHash);
512
+ let accumulatedUsage = brokerUsage(primary);
513
+ let lastOutput = primary.output;
514
+ let lastSessionId = primary.sessionId;
515
+ // Retry on the same (threadId, role) — the broker re-uses the cached
516
+ // Sumeru session, so the agent gets to "fix its frontmatter" with full
517
+ // context preserved. Retries carry the same onTurn and keep appending to
518
+ // the same attempt's active var (no clear between retries).
519
+ for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
520
+ const correctionPrompt = buildFrontmatterRetryPrompt(outputFormatInstruction);
521
+ log(PL_FRONTMATTER_RETRY, `frontmatter retry ${retry + 1}/${MAX_FRONTMATTER_RETRIES} thread=${args.threadId} role=${args.role}`);
522
+ const retryResult = await broker.send({
523
+ threadId: args.threadId,
524
+ role: args.role,
525
+ prompt: correctionPrompt,
526
+ onTurn,
527
+ });
528
+ // A retry can itself time out — honor the same suspend gate rather than
529
+ // dereferencing `retryResult.output` on a suspended result.
530
+ if (retryResult.kind === "suspended") {
531
+ return writeSuspendedStep({
532
+ uwf: args.uwf,
533
+ threadId: args.threadId,
534
+ suspend: {
535
+ reason: retryResult.reason,
536
+ nativeId: retryResult.nativeId,
537
+ elapsedMs: retryResult.elapsedMs,
538
+ },
539
+ sessionId: retryResult.sessionId,
540
+ turnCount: getTurnCount(),
541
+ startHash: args.startHash,
542
+ prevHash: args.prevHash,
543
+ role: args.role,
544
+ agentName: route.gateway,
545
+ edgePrompt: args.edgePrompt,
546
+ startedAtMs,
547
+ completedAtMs: Date.now(),
548
+ cwd: args.effectiveCwd,
549
+ assembledPromptHash,
550
+ previousAttempts: args.previousAttempts,
551
+ });
552
+ }
553
+ lastOutput = retryResult.output;
554
+ lastSessionId = retryResult.sessionId;
555
+ accumulatedUsage = mergeUsage(accumulatedUsage, brokerUsage(retryResult));
556
+ extracted = await tryExtract(args.uwf, lastOutput, outputSchemaHash);
557
+ }
558
+ const completedAtMs = Date.now();
559
+ // Phase 2 (#419): Pass turn count to detail (no longer from active var)
560
+ const detailHash = await storeBrokerDetail(args.uwf, { ...primary, output: lastOutput, sessionId: lastSessionId }, args.threadId, args.role, startedAtMs, completedAtMs, getTurnCount());
561
+ // Phase 2 (#419): Clear active-step var on completion
562
+ clearActiveStep(args.uwf.store, args.threadId);
563
+ if (extracted === null) {
564
+ log(PL_FRONTMATTER_FAIL, `frontmatter extraction failed after ${MAX_FRONTMATTER_RETRIES} retries thread=${args.threadId} role=${args.role}`);
565
+ const errorMessage = "Agent output does not contain valid YAML frontmatter matching the role schema " +
566
+ `after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
567
+ `Raw output (first 500 chars): ${lastOutput.slice(0, 500)}`;
568
+ const errorPayload = {
569
+ $status: "error",
570
+ error: errorMessage,
571
+ phase: "frontmatter_extraction",
572
+ };
573
+ const errorOutputHash = await args.uwf.store.cas.put(args.uwf.schemas.errorOutput, errorPayload);
574
+ const failedStepHash = await writeBrokerStepNode({
575
+ uwf: args.uwf,
576
+ startHash: args.startHash,
577
+ prevHash: args.prevHash,
578
+ role: args.role,
579
+ outputHash: errorOutputHash,
580
+ detailHash,
581
+ agentName: route.gateway,
582
+ edgePrompt: args.edgePrompt,
583
+ startedAtMs,
584
+ completedAtMs,
585
+ cwd: args.effectiveCwd,
586
+ assembledPromptHash,
587
+ usage: accumulatedUsage,
588
+ previousAttempts: null,
589
+ });
590
+ return {
591
+ stepHash: failedStepHash,
592
+ detailHash,
593
+ role: args.role,
594
+ frontmatter: { $status: "error" },
595
+ body: "",
596
+ startedAtMs,
597
+ completedAtMs,
598
+ usage: accumulatedUsage,
599
+ isError: true,
600
+ errorMessage,
601
+ };
602
+ }
603
+ const stepHash = await writeBrokerStepNode({
604
+ uwf: args.uwf,
605
+ startHash: args.startHash,
606
+ prevHash: args.prevHash,
607
+ role: args.role,
608
+ outputHash: extracted.outputHash,
609
+ detailHash,
610
+ agentName: route.gateway,
611
+ edgePrompt: args.edgePrompt,
612
+ startedAtMs,
613
+ completedAtMs,
614
+ cwd: args.effectiveCwd,
615
+ assembledPromptHash,
616
+ usage: accumulatedUsage,
617
+ previousAttempts: args.previousAttempts,
618
+ });
619
+ return {
620
+ stepHash,
621
+ detailHash,
622
+ role: args.role,
623
+ frontmatter: extracted.frontmatter,
624
+ body: extracted.body,
625
+ startedAtMs,
626
+ completedAtMs,
627
+ usage: accumulatedUsage,
628
+ isError: false,
629
+ errorMessage: null,
630
+ };
631
+ }
632
+ finally {
633
+ sessionStore.close();
634
+ }
635
+ }
636
+ function brokerUsage(result) {
637
+ // Sumeru's `done` event reports per-exchange usage. Normalize into the
638
+ // engine's Usage shape so `mergeUsage` can sum across retries. A suspended
639
+ // result has no `done` (the discriminated union enforces this narrow) — a
640
+ // timeout carries no usage summary.
641
+ if (result.kind !== "completed") {
642
+ return null;
643
+ }
644
+ const done = result.done;
645
+ if (done === null || typeof done !== "object") {
646
+ return null;
647
+ }
648
+ const turns = done.turnCount;
649
+ const inputTokens = done.tokens !== null ? done.tokens.in : 0;
650
+ const outputTokens = done.tokens !== null ? done.tokens.out : 0;
651
+ const duration = done.durationMs;
652
+ return { turns, inputTokens, outputTokens, duration };
653
+ }
654
+ //# sourceMappingURL=broker-step.js.map