@united-workforce/cli 0.6.1 → 0.7.0

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 (78) hide show
  1. package/README.md +89 -1
  2. package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
  3. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
  4. package/dist/__tests__/broker-prompt.test.d.ts +10 -0
  5. package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
  6. package/dist/__tests__/broker-prompt.test.js +129 -0
  7. package/dist/__tests__/broker-prompt.test.js.map +1 -0
  8. package/dist/__tests__/config.test.js +33 -37
  9. package/dist/__tests__/config.test.js.map +1 -1
  10. package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
  11. package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
  12. package/dist/__tests__/e2e-broker-step.test.js +278 -0
  13. package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
  14. package/dist/__tests__/e2e-mock-agent.test.js +1 -1
  15. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  16. package/dist/__tests__/setup-agent-discovery.test.js +17 -5
  17. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  18. package/dist/__tests__/setup-no-llm.test.js +5 -2
  19. package/dist/__tests__/setup-no-llm.test.js.map +1 -1
  20. package/dist/__tests__/step-ask.test.js +9 -6
  21. package/dist/__tests__/step-ask.test.js.map +1 -1
  22. package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
  23. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
  24. package/dist/__tests__/thread-poke.test.js +6 -6
  25. package/dist/__tests__/thread-poke.test.js.map +1 -1
  26. package/dist/__tests__/thread-resume.test.js +2 -2
  27. package/dist/__tests__/thread-resume.test.js.map +1 -1
  28. package/dist/__tests__/thread-suspend-step.test.js +1 -1
  29. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  30. package/dist/commands/broker-step.d.ts +110 -0
  31. package/dist/commands/broker-step.d.ts.map +1 -0
  32. package/dist/commands/broker-step.js +450 -0
  33. package/dist/commands/broker-step.js.map +1 -0
  34. package/dist/commands/config.d.ts.map +1 -1
  35. package/dist/commands/config.js +2 -23
  36. package/dist/commands/config.js.map +1 -1
  37. package/dist/commands/prompt.js +3 -3
  38. package/dist/commands/setup.d.ts.map +1 -1
  39. package/dist/commands/setup.js +8 -1
  40. package/dist/commands/setup.js.map +1 -1
  41. package/dist/commands/step.d.ts +6 -5
  42. package/dist/commands/step.d.ts.map +1 -1
  43. package/dist/commands/step.js +11 -154
  44. package/dist/commands/step.js.map +1 -1
  45. package/dist/commands/thread.d.ts +4 -0
  46. package/dist/commands/thread.d.ts.map +1 -1
  47. package/dist/commands/thread.js +77 -151
  48. package/dist/commands/thread.js.map +1 -1
  49. package/package.json +5 -4
  50. package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
  51. package/src/__tests__/broker-prompt.test.ts +142 -0
  52. package/src/__tests__/config.test.ts +35 -39
  53. package/src/__tests__/e2e-broker-step.test.ts +320 -0
  54. package/src/__tests__/e2e-mock-agent.test.ts +1 -1
  55. package/src/__tests__/setup-agent-discovery.test.ts +17 -5
  56. package/src/__tests__/setup-no-llm.test.ts +5 -2
  57. package/src/__tests__/step-ask.test.ts +9 -6
  58. package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
  59. package/src/__tests__/thread-poke.test.ts +6 -6
  60. package/src/__tests__/thread-resume.test.ts +2 -2
  61. package/src/__tests__/thread-suspend-step.test.ts +1 -1
  62. package/src/commands/broker-step.ts +636 -0
  63. package/src/commands/config.ts +2 -24
  64. package/src/commands/prompt.ts +3 -3
  65. package/src/commands/setup.ts +9 -1
  66. package/src/commands/step.ts +21 -204
  67. package/src/commands/thread.ts +87 -192
  68. package/dist/.build-fingerprint +0 -1
  69. package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
  70. package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
  71. package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
  72. package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
  73. package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
  74. package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
  75. package/dist/__tests__/spawn-agent-json.test.js +0 -79
  76. package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
  77. package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
  78. package/src/__tests__/spawn-agent-json.test.ts +0 -100
@@ -205,6 +205,14 @@ export async function _promptAgentSelection(
205
205
 
206
206
  // ──────────────────────────────────────────────────────────────────────────────
207
207
 
208
+ /**
209
+ * Default Sumeru host used when seeding a fresh agents.<alias> entry.
210
+ * Phase 3 (#380) breaking change — agents are routed through the broker via
211
+ * `host` + `gateway`, replacing the legacy `command` + `args` CLI binary
212
+ * path.
213
+ */
214
+ const DEFAULT_SUMERU_HOST = "http://127.0.0.1:7900";
215
+
208
216
  /**
209
217
  * Merge setup args into config.yaml structure. Non-destructive — preserves
210
218
  * existing entries (including agentOverrides). Engine config is LLM-free, so
@@ -219,7 +227,7 @@ function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record
219
227
 
220
228
  const agentName = _agentNameFromBinary(args.agent);
221
229
  if (!agents[agentName]) {
222
- agents[agentName] = { command: `uwf-${agentName}`, args: [] };
230
+ agents[agentName] = { host: DEFAULT_SUMERU_HOST, gateway: agentName };
223
231
  }
224
232
 
225
233
  const merged: Record<string, unknown> = {
@@ -1,8 +1,5 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import type { CasStore } from "@ocas/core";
3
2
  import type {
4
- AgentAlias,
5
- AgentConfig,
6
3
  CasRef,
7
4
  StartEntry,
8
5
  StepEntry,
@@ -10,11 +7,8 @@ import type {
10
7
  ThreadForkOutput,
11
8
  ThreadId,
12
9
  ThreadStepsOutput,
13
- WorkflowConfig,
14
- WorkflowPayload,
15
10
  } from "@united-workforce/protocol";
16
11
  import { createLogger, generateUlid } from "@united-workforce/util";
17
- import { getAskSessionId, loadWorkflowConfig, setAskSessionId } from "@united-workforce/util-agent";
18
12
  import { createUwfStore, setThread, type UwfStore } from "../store.js";
19
13
  import {
20
14
  collectOrderedSteps,
@@ -456,148 +450,13 @@ export async function cmdStepRead(
456
450
  }
457
451
 
458
452
  // ── step ask ────────────────────────────────────────────────────────────────
459
-
460
- function parseAgentOverride(override: string): AgentConfig {
461
- const parts = override
462
- .trim()
463
- .split(/\s+/)
464
- .filter((p) => p.length > 0);
465
- const command = parts[0];
466
- if (command === undefined) {
467
- fail("agent override must not be empty");
468
- }
469
- return { command, args: parts.slice(1) };
470
- }
471
-
472
- function resolveAskAgentConfig(
473
- config: WorkflowConfig,
474
- workflow: WorkflowPayload | null,
475
- role: string,
476
- agentOverride: string | null,
477
- recordedAgent: string,
478
- ): AgentConfig {
479
- if (agentOverride !== null) {
480
- const fromAlias = config.agents[agentOverride as AgentAlias];
481
- if (fromAlias !== undefined) {
482
- return fromAlias;
483
- }
484
- return parseAgentOverride(agentOverride);
485
- }
486
-
487
- // Try to resolve via the recorded agent name as a config alias.
488
- const fromRecorded = config.agents[recordedAgent as AgentAlias];
489
- if (fromRecorded !== undefined) {
490
- return fromRecorded;
491
- }
492
-
493
- // Fall back to default agent for the workflow / role.
494
- if (workflow !== null && config.agentOverrides !== null) {
495
- const roleOverrides = config.agentOverrides[workflow.name];
496
- if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
497
- const alias = roleOverrides[role];
498
- const agentConfig = config.agents[alias];
499
- if (agentConfig !== undefined) {
500
- return agentConfig;
501
- }
502
- }
503
- }
504
-
505
- // Treat the recorded value as a raw command path.
506
- return parseAgentOverride(recordedAgent);
507
- }
508
-
509
- /**
510
- * Derive the agent name used for cache file partitioning from an executable
511
- * path or alias. Examples:
512
- * uwf-hermes → hermes
513
- * uwf-claude-code → claude-code
514
- * /tmp/mock-agent.sh → mock
515
- * /usr/bin/agent → agent
516
- */
517
- function deriveAgentName(commandPath: string): string {
518
- const basename = commandPath.split(/[/\\]/).pop() ?? commandPath;
519
- // Strip a trailing extension (.sh, .js, .mjs, .cjs)
520
- const noExt = basename.replace(/\.(sh|js|mjs|cjs|ts)$/i, "");
521
- // Strip the `uwf-` prefix introduced by agentLabel().
522
- const noPrefix = noExt.startsWith("uwf-") ? noExt.slice(4) : noExt;
523
- // Strip the trailing `-agent` suffix used by tests / generic agent shells.
524
- const noSuffix = noPrefix.endsWith("-agent") ? noPrefix.slice(0, -"-agent".length) : noPrefix;
525
- return noSuffix === "" ? noExt : noSuffix;
526
- }
527
-
528
- function loadDetailNode(
529
- store: CasStore,
530
- detailRef: CasRef,
531
- ): { sessionId: string | null; payload: Record<string, unknown> } {
532
- const detailNode = store.get(detailRef);
533
- if (detailNode === null) {
534
- fail(`detail node not found: ${detailRef}`);
535
- }
536
- const payload = detailNode.payload as Record<string, unknown>;
537
- const sessionId = typeof payload.sessionId === "string" ? payload.sessionId : null;
538
- return { sessionId, payload };
539
- }
540
-
541
- function spawnAskAgent(agent: AgentConfig, argv: string[], cwd: string): { stdout: string } {
542
- try {
543
- const stdout = execFileSync(agent.command, [...agent.args, ...argv], {
544
- encoding: "utf8",
545
- stdio: ["ignore", "pipe", "pipe"],
546
- maxBuffer: 50 * 1024 * 1024,
547
- cwd,
548
- });
549
- return { stdout };
550
- } catch (e) {
551
- const err = e as NodeJS.ErrnoException & { stderr: Buffer | string | null };
552
- if (err.code === "ENOENT") {
553
- fail(
554
- `"${agent.command}" not found in PATH. Install it or check your PATH config. Run: which ${agent.command}`,
555
- );
556
- }
557
- const stderr =
558
- err.stderr == null
559
- ? ""
560
- : typeof err.stderr === "string"
561
- ? err.stderr
562
- : err.stderr.toString("utf8");
563
- const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
564
- fail(`agent command failed (${agent.command})${detail}`);
565
- }
566
- }
567
-
568
- function resolveAskWorkflow(uwf: UwfStore, payload: StepNodePayload): WorkflowPayload | null {
569
- const startNode = uwf.store.cas.get(payload.start);
570
- if (startNode === null) {
571
- return null;
572
- }
573
- const start = startNode.payload as { workflow: CasRef };
574
- const workflowNode = uwf.store.cas.get(start.workflow);
575
- if (workflowNode === null) {
576
- return null;
577
- }
578
- return workflowNode.payload as WorkflowPayload;
579
- }
580
-
581
- async function performFork(
582
- agent: AgentConfig,
583
- agentName: string,
584
- stepHash: CasRef,
585
- sourceSessionId: string,
586
- storageRoot: string,
587
- cwd: string,
588
- ): Promise<string> {
589
- const cached = await getAskSessionId(agentName, stepHash, storageRoot);
590
- if (cached !== null) {
591
- return cached;
592
- }
593
- const { stdout } = spawnAskAgent(agent, ["--mode", "fork", "--session", sourceSessionId], cwd);
594
- const newSessionId = stdout.trim().split("\n").pop()?.trim() ?? "";
595
- if (newSessionId === "") {
596
- fail(`agent fork did not return a session id (${agent.command})`);
597
- }
598
- await setAskSessionId(agentName, stepHash, newSessionId, storageRoot);
599
- return newSessionId;
600
- }
453
+ //
454
+ // Phase 3 (#380) — Option B: `step ask` is disabled while broker integration
455
+ // lands. The pre-broker spawn-agent path depended on the legacy
456
+ // `agents.<alias>: {command, args}` config shape; that shape was replaced by
457
+ // `{host, gateway}` and the equivalent broker `ask`/`fork` primitives are
458
+ // scheduled for Phase 4 (#381). The command exits non-zero with a clear
459
+ // migration pointer so existing scripts fail fast rather than silently.
601
460
 
602
461
  export type CmdStepAskOptions = {
603
462
  prompt: string;
@@ -607,64 +466,22 @@ export type CmdStepAskOptions = {
607
466
  };
608
467
 
609
468
  /**
610
- * Ask a follow-up question to a historical step's agent (read-only).
611
- *
612
- * Does NOT write a new StepNode and does NOT mutate thread state. The agent's
613
- * raw stdout is returned so the CLI entry point can stream it directly.
469
+ * `uwf step ask` is unavailable in 0.x while broker integration (#381) is in
470
+ * progress. The legacy spawn-agent code path was removed alongside the
471
+ * `agents.<alias>: {command, args}` config shape. Use `uwf thread exec` /
472
+ * `uwf thread resume` instead those routes go through `broker.send()` and
473
+ * preserve the Sumeru session.
614
474
  */
615
475
  export async function cmdStepAsk(
616
- storageRoot: string,
617
- stepHash: CasRef,
618
- options: CmdStepAskOptions,
476
+ _storageRoot: string,
477
+ _stepHash: CasRef,
478
+ _options: CmdStepAskOptions,
619
479
  ): Promise<string> {
620
- const uwf = await createUwfStore(storageRoot);
621
- const node = uwf.store.cas.get(stepHash);
622
- if (node === null) {
623
- fail(`CAS node not found: ${stepHash}`);
624
- }
625
- if (node.type !== uwf.schemas.stepNode) {
626
- fail(`node ${stepHash} is not a StepNode`);
627
- }
628
- const payload = node.payload as StepNodePayload;
629
- if (payload.detail === null) {
630
- fail(`step ${stepHash} has no detail; cannot ask`);
631
- }
632
-
633
- const detailRef = payload.detail;
634
- const { sessionId: sourceSessionId } = loadDetailNode(uwf.store.cas, detailRef);
635
-
636
- const workflow = resolveAskWorkflow(uwf, payload);
637
- const config = await loadWorkflowConfig(storageRoot);
638
- const agent = resolveAskAgentConfig(
639
- config,
640
- workflow,
641
- payload.role,
642
- options.agentOverride,
643
- payload.agent,
480
+ fail(
481
+ "step ask is unavailable in 0.x while broker integration (#381) is in progress. " +
482
+ "The pre-broker spawn-agent path was removed in #380; equivalent ask/fork primitives " +
483
+ "will return in Phase 4 once the Sumeru broker exposes session-fork APIs. " +
484
+ "Use `uwf thread resume <id> -p '...'` to continue a suspended thread, or " +
485
+ "`uwf thread exec <id>` to advance an idle thread.",
644
486
  );
645
- const agentName = deriveAgentName(agent.command);
646
-
647
- const cwd = payload.cwd !== "" ? payload.cwd : process.cwd();
648
-
649
- // Fork path: fork (or reuse cached fork) → ask with that session.
650
- if (options.fork && sourceSessionId !== null) {
651
- const askSessionId = await performFork(
652
- agent,
653
- agentName,
654
- stepHash,
655
- sourceSessionId,
656
- storageRoot,
657
- cwd,
658
- );
659
- const argv = ["--mode", "ask", "--session", askSessionId, "--prompt", options.prompt];
660
- argv.push("--detail", detailRef);
661
- const { stdout } = spawnAskAgent(agent, argv, cwd);
662
- return stdout;
663
- }
664
-
665
- // Fallback path: ask without forking; inject detail ref for context.
666
- const argv = ["--mode", "ask", "--prompt", options.prompt];
667
- argv.push("--detail", detailRef);
668
- const { stdout } = spawnAskAgent(agent, argv, cwd);
669
- return stdout;
670
487
  }
@@ -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
 
@@ -1 +0,0 @@
1
- 8f6475d4155e0628083a10a7c1b3bc83482b887cdcfd2a704a00492f70553d87
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=adapter-json-roundtrip.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"adapter-json-roundtrip.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/adapter-json-roundtrip.test.ts"],"names":[],"mappings":""}