@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
@@ -1,11 +1,9 @@
1
- import { execFileSync, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { access, readFile } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
4
4
  import type { VarStore } from "@ocas/core";
5
5
  import { validate } from "@ocas/core";
6
6
  import type {
7
- AgentAlias,
8
- AgentConfig,
9
7
  CasRef,
10
8
  StartNodePayload,
11
9
  StartOutput,
@@ -31,7 +29,6 @@ import {
31
29
  generateUlid,
32
30
  type ProcessLogger,
33
31
  } from "@united-workforce/util";
34
- import type { AdapterOutput } from "@united-workforce/util-agent";
35
32
  import { getEnvPath, loadWorkflowConfig } from "@united-workforce/util-agent";
36
33
  import { config as loadDotenv } from "dotenv";
37
34
  import { parse } from "yaml";
@@ -61,6 +58,7 @@ import {
61
58
  } from "../store.js";
62
59
  import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
63
60
  import { validateWorkflow } from "../validate-semantic.js";
61
+ import { type BrokerStepResult, executeBrokerStep } from "./broker-step.js";
64
62
  import {
65
63
  getConfigPath,
66
64
  getNestedValue,
@@ -213,7 +211,6 @@ function resolveCurrentRole(uwf: UwfStore, head: CasRef, workflowRef: CasRef): s
213
211
 
214
212
  const PL_THREAD_START = "7HNQ4B2X";
215
213
  const PL_MODERATOR = "M3K8V9T1";
216
- const PL_AGENT_SPAWN = "R5J2W8N4";
217
214
  const PL_AGENT_DONE = "C6P9E3H7";
218
215
  const PL_AGENT_ERROR = "Z3F7K8M2";
219
216
  const PL_THREAD_ARCHIVED = "F4D8Q2K5";
@@ -1120,140 +1117,6 @@ function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayloa
1120
1117
  return node.payload as WorkflowPayload;
1121
1118
  }
1122
1119
 
1123
- function parseAgentOverride(override: string): AgentConfig {
1124
- const parts = override
1125
- .trim()
1126
- .split(/\s+/)
1127
- .filter((p) => p.length > 0);
1128
- const command = parts[0];
1129
- if (command === undefined) {
1130
- fail("agent override must not be empty");
1131
- }
1132
- return { command, args: parts.slice(1) };
1133
- }
1134
-
1135
- function resolveAgentConfig(
1136
- config: WorkflowConfig,
1137
- workflow: WorkflowPayload,
1138
- role: string,
1139
- agentOverride: string | null,
1140
- ): AgentConfig {
1141
- if (agentOverride !== null) {
1142
- // Try config alias first (e.g. "hermes" → config.agents.hermes),
1143
- // then fall back to raw command name (e.g. "uwf-hermes" or "/usr/bin/agent").
1144
- const fromAlias = config.agents[agentOverride as AgentAlias];
1145
- if (fromAlias !== undefined) {
1146
- return fromAlias;
1147
- }
1148
- return parseAgentOverride(agentOverride);
1149
- }
1150
-
1151
- let alias: AgentAlias = config.defaultAgent;
1152
- if (config.agentOverrides !== null) {
1153
- const roleOverrides = config.agentOverrides[workflow.name];
1154
- if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
1155
- alias = roleOverrides[role];
1156
- }
1157
- }
1158
-
1159
- const agentConfig = config.agents[alias];
1160
- if (agentConfig === undefined) {
1161
- fail(`unknown agent alias in config: ${alias}`);
1162
- }
1163
- return agentConfig;
1164
- }
1165
-
1166
- function executeAgentCommand(
1167
- agent: AgentConfig,
1168
- argv: readonly string[],
1169
- cwd: string,
1170
- plog: ProcessLogger,
1171
- ): string {
1172
- try {
1173
- return execFileSync(agent.command, argv, {
1174
- encoding: "utf8",
1175
- stdio: ["ignore", "pipe", "pipe"],
1176
- maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
1177
- cwd,
1178
- });
1179
- } catch (e) {
1180
- const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string | null };
1181
- if (err.code === "ENOENT") {
1182
- failStep(
1183
- plog,
1184
- `"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
1185
- );
1186
- }
1187
- const stderr =
1188
- err.stderr == null
1189
- ? ""
1190
- : typeof err.stderr === "string"
1191
- ? err.stderr
1192
- : err.stderr.toString("utf8");
1193
- const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
1194
- failStep(plog, `agent command failed (${agent.command})${detail}`);
1195
- }
1196
- }
1197
-
1198
- function parseAgentOutput(stdout: string, plog: ProcessLogger): unknown {
1199
- const line = stdout.trim().split("\n").pop()?.trim() ?? "";
1200
- try {
1201
- return JSON.parse(line);
1202
- } catch {
1203
- failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
1204
- }
1205
- }
1206
-
1207
- function validateAndNormalizeOutput(
1208
- parsed: unknown,
1209
- line: string,
1210
- plog: ProcessLogger,
1211
- ): AdapterOutput {
1212
- const obj = parsed as Record<string, unknown>;
1213
- if (
1214
- typeof obj !== "object" ||
1215
- obj === null ||
1216
- typeof obj.stepHash !== "string" ||
1217
- !isCasRef(obj.stepHash as string)
1218
- ) {
1219
- failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
1220
- }
1221
- // Normalize isError / errorMessage so downstream code can rely on them.
1222
- // Legacy adapters that don't emit these fields default to isError=false.
1223
- if (obj.isError !== undefined && typeof obj.isError !== "boolean") {
1224
- failStep(plog, `agent stdout JSON has non-boolean isError: ${line}`);
1225
- }
1226
- if (obj.isError === undefined) {
1227
- obj.isError = false;
1228
- }
1229
- if (
1230
- obj.errorMessage !== undefined &&
1231
- obj.errorMessage !== null &&
1232
- typeof obj.errorMessage !== "string"
1233
- ) {
1234
- failStep(plog, `agent stdout JSON has non-string errorMessage: ${line}`);
1235
- }
1236
- if (obj.errorMessage === undefined) {
1237
- obj.errorMessage = null;
1238
- }
1239
- return obj as unknown as AdapterOutput;
1240
- }
1241
-
1242
- function spawnAgent(
1243
- plog: ProcessLogger,
1244
- agent: AgentConfig,
1245
- threadId: ThreadId,
1246
- role: string,
1247
- edgePrompt: string,
1248
- cwd: string,
1249
- ): AdapterOutput {
1250
- const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
1251
- const stdout = executeAgentCommand(agent, argv, cwd, plog);
1252
- const line = stdout.trim().split("\n").pop()?.trim() ?? "";
1253
- const parsed = parseAgentOutput(stdout, plog);
1254
- return validateAndNormalizeOutput(parsed, line, plog);
1255
- }
1256
-
1257
1120
  function archiveThread(uwf: UwfStore, threadId: ThreadId, _workflow: CasRef, _head: CasRef): void {
1258
1121
  completeThread(uwf.varStore, threadId, "end");
1259
1122
  }
@@ -1412,6 +1275,10 @@ function resolveCurrentRoleFromChain(
1412
1275
  * replacing the head step's output. The new step's `prev` points to the OLD head's
1413
1276
  * `prev` — semantically replacing (not appending to) the head. The moderator is NOT
1414
1277
  * re-evaluated for routing; the role of the head step is re-used.
1278
+ *
1279
+ * Phase 3 (#380) — drives the broker via `executeBrokerStep` rather than the
1280
+ * legacy `spawnAgent` path. The replace-semantics StepNode is built directly by
1281
+ * passing `prevHash = oldHeadPayload.prev`, so no post-hoc rewrite is needed.
1415
1282
  */
1416
1283
  export async function cmdThreadPoke(
1417
1284
  storageRoot: string,
@@ -1431,59 +1298,71 @@ export async function cmdThreadPoke(
1431
1298
  context: { thread: threadId, workflow: workflowHash },
1432
1299
  });
1433
1300
 
1434
- // Resolve the agent: --agent override wins; otherwise read from old head step's `agent` field.
1435
1301
  const config = await loadWorkflowConfig(storageRoot);
1436
1302
  const workflow = loadWorkflowPayload(uwf, workflowHash);
1437
1303
  const role = oldHeadPayload.role;
1438
- const agent =
1439
- agentOverride !== null
1440
- ? resolveAgentConfig(config, workflow, role, agentOverride)
1441
- : parseAgentOverride(oldHeadPayload.agent);
1442
-
1443
1304
  const effectiveCwd = oldHeadPayload.cwd !== "" ? oldHeadPayload.cwd : threadCwd;
1444
1305
 
1445
- plog.log(PL_THREAD_POKE, `poke role=${role} agent=${agent.command}`, null);
1446
- plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
1447
- args: [...agent.args, threadId, role].join(" "),
1448
- });
1306
+ plog.log(PL_THREAD_POKE, `poke role=${role}`, null);
1449
1307
 
1450
1308
  loadDotenv({ path: getEnvPath(storageRoot) });
1451
1309
 
1452
- // Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
1453
- // the active thread head). After the agent returns, we rewrite that node's prev so
1454
- // that the new head replaces the old head instead of appending after it.
1455
- let agentResult: AdapterOutput;
1310
+ // Replace semantics: the new step's `prev` is the OLD head's prev, not the
1311
+ // OLD head itself. `executeBrokerStep` writes the StepNode with this prev,
1312
+ // so no post-hoc rewrite is needed.
1313
+ let result: BrokerStepResult;
1456
1314
  try {
1457
- agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
1315
+ result = await executeBrokerStep({
1316
+ storageRoot,
1317
+ uwf,
1318
+ config,
1319
+ workflow,
1320
+ threadId,
1321
+ role,
1322
+ edgePrompt: prompt,
1323
+ effectiveCwd,
1324
+ startHash: chain.startHash,
1325
+ prevHash: oldHeadPayload.prev,
1326
+ agentOverride,
1327
+ previousAttempts: null,
1328
+ plog,
1329
+ });
1458
1330
  } catch (e) {
1459
1331
  if (e instanceof StepFailureError) {
1460
- // Fatal agent failure in poke — persist suspended state before propagating
1332
+ // Fatal broker failure in poke — persist suspended state before propagating
1461
1333
  const uwfErr = await createUwfStore(storageRoot);
1462
1334
  const errEntry = getThread(uwfErr.varStore, threadId) ?? entry;
1463
1335
  setThread(uwfErr.varStore, threadId, markThreadSuspended(errEntry, role, e.message));
1464
1336
  }
1465
1337
  throw e;
1466
1338
  }
1467
- const agentStepHash = agentResult.stepHash as CasRef;
1468
1339
 
1469
- plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
1340
+ const replacedHash = result.stepHash;
1341
+ plog.log(PL_AGENT_DONE, `broker returned head=${replacedHash}`, null);
1470
1342
 
1471
1343
  const uwfAfter = await createUwfStore(storageRoot);
1472
- const agentNode = uwfAfter.store.cas.get(agentStepHash);
1473
- if (agentNode === null || agentNode.type !== uwfAfter.schemas.stepNode) {
1474
- failStep(plog, `agent returned hash that is not a StepNode: ${agentStepHash}`);
1475
- }
1476
- const agentPayload = agentNode.payload as StepNodePayload;
1477
1344
 
1478
- // Rewrite the new step so that its `prev` points to the OLD head's prev (replace semantics).
1479
- const replacedPayload: StepNodePayload = {
1480
- ...agentPayload,
1481
- prev: oldHeadPayload.prev,
1482
- };
1483
- const replacedHash = await uwfAfter.store.cas.put(uwfAfter.schemas.stepNode, replacedPayload);
1484
- const replacedNode = uwfAfter.store.cas.get(replacedHash);
1485
- if (replacedNode === null || !validate(uwfAfter.store, replacedNode)) {
1486
- failStep(plog, "rewritten StepNode failed schema validation");
1345
+ // Recoverable broker error: do NOT advance head, persist suspended state.
1346
+ if (result.isError) {
1347
+ const errorMsg = result.errorMessage ?? "broker reported error";
1348
+ plog.log(
1349
+ PL_AGENT_ERROR,
1350
+ `poke recoverable failure stepHash=${replacedHash} message=${errorMsg}`,
1351
+ null,
1352
+ );
1353
+ setThread(uwfAfter.varStore, threadId, markThreadSuspended(entry, role, errorMsg));
1354
+ return {
1355
+ workflow: workflowHash,
1356
+ thread: threadId,
1357
+ head: entry.head,
1358
+ status: "suspended",
1359
+ currentRole: role,
1360
+ suspendedRole: role,
1361
+ suspendMessage: errorMsg,
1362
+ done: false,
1363
+ background: null,
1364
+ error: { stepHash: replacedHash, message: errorMsg },
1365
+ };
1487
1366
  }
1488
1367
 
1489
1368
  // Update thread head to the replaced step. Status becomes idle (no moderator re-route).
@@ -1928,16 +1807,11 @@ async function cmdThreadStepOnce(
1928
1807
  const { role, edgePrompt, effectiveCwd } = targetOrOutput;
1929
1808
 
1930
1809
  const config = await loadWorkflowConfig(storageRoot);
1931
- const agent = resolveAgentConfig(config, workflow, role, agentOverride);
1932
-
1933
- plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
1934
- args: [...agent.args, threadId, role].join(" "),
1935
- });
1936
1810
 
1937
1811
  loadDotenv({ path: getEnvPath(storageRoot) });
1938
1812
 
1939
- // Wrap agent execution in a try-catch: when the agent command crashes
1940
- // (non-zero exit, unparseable output, invalid CAS node, etc.), failStep throws
1813
+ // Wrap broker execution in a try-catch: when the broker raises a fatal
1814
+ // error (HTTP failure, schema validation crash, etc.), failStep throws
1941
1815
  // StepFailureError. We catch it to persist suspended state before re-throwing
1942
1816
  // so the CLI still exits non-zero.
1943
1817
  try {
@@ -1950,7 +1824,10 @@ async function cmdThreadStepOnce(
1950
1824
  role,
1951
1825
  edgePrompt,
1952
1826
  effectiveCwd,
1953
- agent,
1827
+ uwf,
1828
+ config,
1829
+ chain.startHash,
1830
+ agentOverride,
1954
1831
  plog,
1955
1832
  );
1956
1833
  } catch (e) {
@@ -1965,8 +1842,13 @@ async function cmdThreadStepOnce(
1965
1842
  }
1966
1843
 
1967
1844
  /**
1968
- * Execute the agent command and process the result. Separated from cmdThreadStepOnce
1845
+ * Execute the broker step and process the result. Separated from cmdThreadStepOnce
1969
1846
  * so that fatal failures (StepFailureError) can be caught and handled by the caller.
1847
+ *
1848
+ * Phase 3 (#380) — drives the broker via `executeBrokerStep` rather than the
1849
+ * legacy `spawnAgent` path. The broker writes the StepNode (including the
1850
+ * frontmatter retry chain) directly into CAS; the CLI only advances the head
1851
+ * pointer afterwards.
1970
1852
  */
1971
1853
  async function executeAndProcessAgentStep(
1972
1854
  storageRoot: string,
@@ -1977,30 +1859,43 @@ async function executeAndProcessAgentStep(
1977
1859
  role: string,
1978
1860
  edgePrompt: string,
1979
1861
  effectiveCwd: string,
1980
- agent: AgentConfig,
1862
+ uwf: UwfStore,
1863
+ config: WorkflowConfig,
1864
+ startHash: CasRef,
1865
+ agentOverride: string | null,
1981
1866
  plog: ProcessLogger,
1982
1867
  ): Promise<StepOutput> {
1983
- const agentResult = spawnAgent(plog, agent, threadId, role, edgePrompt, effectiveCwd);
1984
- const newHead = agentResult.stepHash as CasRef;
1868
+ const result = await executeBrokerStep({
1869
+ storageRoot,
1870
+ uwf,
1871
+ config,
1872
+ workflow,
1873
+ threadId,
1874
+ role,
1875
+ edgePrompt,
1876
+ effectiveCwd,
1877
+ startHash,
1878
+ prevHash: headHash === startHash ? null : headHash,
1879
+ agentOverride,
1880
+ previousAttempts: null,
1881
+ plog,
1882
+ });
1985
1883
 
1986
- plog.log(PL_AGENT_DONE, `agent returned head=${newHead}`, null);
1884
+ const newHead = result.stepHash;
1885
+ plog.log(PL_AGENT_DONE, `broker returned head=${newHead}`, null);
1987
1886
 
1988
1887
  const uwfAfter = await createUwfStore(storageRoot);
1989
- const newNode = uwfAfter.store.cas.get(newHead);
1990
- if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
1991
- failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
1992
- }
1993
1888
 
1994
- // Recoverable failure: agent persisted a failed StepNode (e.g. frontmatter
1889
+ // Recoverable failure: broker persisted a failed StepNode (e.g. frontmatter
1995
1890
  // validation exhausted retries) but the engine MUST NOT advance head. The
1996
1891
  // moderator graph is also untouched — the same role will be replayed on the
1997
1892
  // next exec (until eventual success records `previousAttempts` linking the
1998
1893
  // failed step hashes).
1999
- if (agentResult.isError === true) {
2000
- const errorMsg = agentResult.errorMessage ?? "agent reported error";
1894
+ if (result.isError === true) {
1895
+ const errorMsg = result.errorMessage ?? "broker reported error";
2001
1896
  plog.log(
2002
1897
  PL_AGENT_ERROR,
2003
- `agent reported recoverable failure stepHash=${newHead} message=${errorMsg}`,
1898
+ `broker reported recoverable failure stepHash=${newHead} message=${errorMsg}`,
2004
1899
  null,
2005
1900
  );
2006
1901
 
@@ -106,6 +106,13 @@ export function toThreadExecPayload(results: StepOutput[]): ThreadExecPayload {
106
106
  };
107
107
  }
108
108
 
109
+ export type StepDetailUsagePayload = {
110
+ turns: number;
111
+ inputTokens: number;
112
+ outputTokens: number;
113
+ duration: number;
114
+ };
115
+
109
116
  export type StepDetailPayload = {
110
117
  hash: string;
111
118
  role: string;
@@ -114,38 +121,105 @@ export type StepDetailPayload = {
114
121
  startedAtMs: number | null;
115
122
  completedAtMs: number | null;
116
123
  durationMs: number | null;
124
+ usage: StepDetailUsagePayload | null;
117
125
  frontmatter: Record<string, unknown>;
118
126
  turns: Array<{ role: string; content: string; timestamp: number | null }>;
127
+ detail: Record<string, unknown> | null;
119
128
  };
120
129
 
130
+ function normalizeUsage(value: unknown): StepDetailUsagePayload | null {
131
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
132
+ return null;
133
+ }
134
+ const u = value as Record<string, unknown>;
135
+ const turns = numericOrNull(u.turns);
136
+ const inputTokens = numericOrNull(u.inputTokens);
137
+ const outputTokens = numericOrNull(u.outputTokens);
138
+ const duration = numericOrNull(u.duration);
139
+ if (turns === null && inputTokens === null && outputTokens === null && duration === null) {
140
+ return null;
141
+ }
142
+ return {
143
+ turns: turns ?? 0,
144
+ inputTokens: inputTokens ?? 0,
145
+ outputTokens: outputTokens ?? 0,
146
+ duration: duration ?? 0,
147
+ };
148
+ }
149
+
150
+ function extractDetailRecord(r: Record<string, unknown>): Record<string, unknown> {
151
+ return r.detail !== undefined && r.detail !== null && typeof r.detail === "object"
152
+ ? (r.detail as Record<string, unknown>)
153
+ : r;
154
+ }
155
+
156
+ function stringFromSources(primary: unknown, fallback: unknown): string {
157
+ if (typeof primary === "string") return primary;
158
+ if (typeof fallback === "string") return fallback;
159
+ return "";
160
+ }
161
+
162
+ function extractFrontmatter(
163
+ r: Record<string, unknown>,
164
+ detailRaw: Record<string, unknown>,
165
+ ): Record<string, unknown> {
166
+ if (
167
+ r.frontmatter !== null &&
168
+ typeof r.frontmatter === "object" &&
169
+ !Array.isArray(r.frontmatter)
170
+ ) {
171
+ return r.frontmatter as Record<string, unknown>;
172
+ }
173
+ if (
174
+ detailRaw.frontmatter !== null &&
175
+ typeof detailRaw.frontmatter === "object" &&
176
+ !Array.isArray(detailRaw.frontmatter)
177
+ ) {
178
+ return detailRaw.frontmatter as Record<string, unknown>;
179
+ }
180
+ return {};
181
+ }
182
+
183
+ function extractStatus(
184
+ frontmatterSource: Record<string, unknown>,
185
+ r: Record<string, unknown>,
186
+ detailRaw: Record<string, unknown>,
187
+ ): string {
188
+ if (typeof frontmatterSource.$status === "string") return frontmatterSource.$status;
189
+ return stringFromSources(r.status, detailRaw.status);
190
+ }
191
+
192
+ function computeDurationMs(
193
+ r: Record<string, unknown>,
194
+ startedAtMs: number | null,
195
+ completedAtMs: number | null,
196
+ ): number | null {
197
+ if (typeof r.durationMs === "number" && Number.isFinite(r.durationMs)) return r.durationMs;
198
+ if (startedAtMs !== null && completedAtMs !== null && completedAtMs >= startedAtMs)
199
+ return completedAtMs - startedAtMs;
200
+ return null;
201
+ }
202
+
121
203
  export function toStepDetailPayload(stepHash: CasRef, raw: unknown): StepDetailPayload {
122
204
  const r = (raw ?? {}) as Record<string, unknown>;
123
- const turnsIn = Array.isArray(r.turns) ? (r.turns as unknown[]) : [];
124
- const startedAtMs = numericOrNull(r.startedAtMs);
125
- const completedAtMs = numericOrNull(r.completedAtMs);
126
- const durationMs =
127
- startedAtMs !== null && completedAtMs !== null && completedAtMs >= startedAtMs
128
- ? completedAtMs - startedAtMs
129
- : null;
130
- const frontmatter =
131
- r.frontmatter !== null && typeof r.frontmatter === "object" && !Array.isArray(r.frontmatter)
132
- ? (r.frontmatter as Record<string, unknown>)
133
- : {};
134
- const status =
135
- typeof frontmatter.$status === "string"
136
- ? (frontmatter.$status as string)
137
- : typeof r.status === "string"
138
- ? (r.status as string)
139
- : "";
205
+ const detailRaw = extractDetailRecord(r);
206
+ const turnsIn = Array.isArray(detailRaw.turns) ? (detailRaw.turns as unknown[]) : [];
207
+ const startedAtMs = numericOrNull(r.startedAtMs ?? detailRaw.startedAtMs);
208
+ const completedAtMs = numericOrNull(r.completedAtMs ?? detailRaw.completedAtMs);
209
+ const durationMs = computeDurationMs(r, startedAtMs, completedAtMs);
210
+ const frontmatterSource = extractFrontmatter(r, detailRaw);
211
+ const status = extractStatus(frontmatterSource, r, detailRaw);
212
+ const usage = normalizeUsage(r.usage);
140
213
  return {
141
- hash: stepHash,
142
- role: typeof r.role === "string" ? r.role : "",
143
- agent: typeof r.agent === "string" ? r.agent : "",
214
+ hash: typeof r.hash === "string" ? (r.hash as string) : stepHash,
215
+ role: stringFromSources(r.role, detailRaw.role),
216
+ agent: stringFromSources(r.agent, detailRaw.agent),
144
217
  status,
145
218
  startedAtMs,
146
219
  completedAtMs,
147
220
  durationMs,
148
- frontmatter,
221
+ usage,
222
+ frontmatter: frontmatterSource,
149
223
  turns: turnsIn.map((t) => {
150
224
  const o = (t ?? {}) as Record<string, unknown>;
151
225
  return {
@@ -154,6 +228,10 @@ export function toStepDetailPayload(stepHash: CasRef, raw: unknown): StepDetailP
154
228
  timestamp: numericOrNull(o.timestamp),
155
229
  };
156
230
  }),
231
+ detail:
232
+ r.detail !== undefined && r.detail !== null && typeof r.detail === "object"
233
+ ? (r.detail as Record<string, unknown>)
234
+ : null,
157
235
  };
158
236
  }
159
237
 
package/src/schemas.ts CHANGED
@@ -7,8 +7,11 @@ import {
7
7
  type OutputSchemaName,
8
8
  outputSchemaVarName,
9
9
  START_NODE_SCHEMA,
10
+ STEP_COMPLETE_SCHEMA,
10
11
  STEP_NODE_SCHEMA,
12
+ STEP_START_SCHEMA,
11
13
  SUSPEND_OUTPUT_SCHEMA,
14
+ TURN_NODE_SCHEMA,
12
15
  WORKFLOW_SCHEMA,
13
16
  } from "@united-workforce/protocol";
14
17
 
@@ -18,6 +21,9 @@ export type UwfSchemaHashes = {
18
21
  workflow: Hash;
19
22
  startNode: Hash;
20
23
  stepNode: Hash;
24
+ stepStart: Hash;
25
+ stepComplete: Hash;
26
+ turnNode: Hash;
21
27
  text: Hash;
22
28
  errorOutput: Hash;
23
29
  suspendOutput: Hash;
@@ -30,16 +36,40 @@ export type UwfSchemaHashes = {
30
36
  * Idempotent: safe to call on every CLI invocation.
31
37
  */
32
38
  export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
33
- const [workflow, startNode, stepNode, text, errorOutput, suspendOutput] = await Promise.all([
39
+ const [
40
+ workflow,
41
+ startNode,
42
+ stepNode,
43
+ stepStart,
44
+ stepComplete,
45
+ turnNode,
46
+ text,
47
+ errorOutput,
48
+ suspendOutput,
49
+ ] = await Promise.all([
34
50
  putSchema(store, WORKFLOW_SCHEMA),
35
51
  putSchema(store, START_NODE_SCHEMA),
36
52
  putSchema(store, STEP_NODE_SCHEMA),
53
+ putSchema(store, STEP_START_SCHEMA),
54
+ putSchema(store, STEP_COMPLETE_SCHEMA),
55
+ putSchema(store, TURN_NODE_SCHEMA),
37
56
  putSchema(store, TEXT_SCHEMA),
38
57
  putSchema(store, ERROR_OUTPUT_SCHEMA),
39
58
  putSchema(store, SUSPEND_OUTPUT_SCHEMA),
40
59
  ]);
41
60
  const outputs = await registerOutputSchemas(store);
42
- return { workflow, startNode, stepNode, text, errorOutput, suspendOutput, outputs };
61
+ return {
62
+ workflow,
63
+ startNode,
64
+ stepNode,
65
+ stepStart,
66
+ stepComplete,
67
+ turnNode,
68
+ text,
69
+ errorOutput,
70
+ suspendOutput,
71
+ outputs,
72
+ };
43
73
  }
44
74
 
45
75
  /**