@united-workforce/cli 0.6.0 → 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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -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__/config.test.js +33 -37
  10. package/dist/__tests__/config.test.js.map +1 -1
  11. package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
  12. package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
  13. package/dist/__tests__/e2e-broker-step.test.js +278 -0
  14. package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
  15. package/dist/__tests__/e2e-mock-agent.test.js +1 -1
  16. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  17. package/dist/__tests__/setup-agent-discovery.test.js +17 -5
  18. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  19. package/dist/__tests__/setup-no-llm.test.js +5 -2
  20. package/dist/__tests__/setup-no-llm.test.js.map +1 -1
  21. package/dist/__tests__/step-ask.test.js +9 -6
  22. package/dist/__tests__/step-ask.test.js.map +1 -1
  23. package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
  24. package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
  25. package/dist/__tests__/thread-poke.test.js +6 -6
  26. package/dist/__tests__/thread-poke.test.js.map +1 -1
  27. package/dist/__tests__/thread-resume.test.js +2 -2
  28. package/dist/__tests__/thread-resume.test.js.map +1 -1
  29. package/dist/__tests__/thread-suspend-step.test.js +1 -1
  30. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  31. package/dist/commands/broker-step.d.ts +110 -0
  32. package/dist/commands/broker-step.d.ts.map +1 -0
  33. package/dist/commands/broker-step.js +450 -0
  34. package/dist/commands/broker-step.js.map +1 -0
  35. package/dist/commands/config.d.ts.map +1 -1
  36. package/dist/commands/config.js +2 -23
  37. package/dist/commands/config.js.map +1 -1
  38. package/dist/commands/prompt.js +3 -3
  39. package/dist/commands/setup.d.ts.map +1 -1
  40. package/dist/commands/setup.js +8 -1
  41. package/dist/commands/setup.js.map +1 -1
  42. package/dist/commands/step.d.ts +6 -5
  43. package/dist/commands/step.d.ts.map +1 -1
  44. package/dist/commands/step.js +11 -154
  45. package/dist/commands/step.js.map +1 -1
  46. package/dist/commands/thread.d.ts +4 -0
  47. package/dist/commands/thread.d.ts.map +1 -1
  48. package/dist/commands/thread.js +77 -151
  49. package/dist/commands/thread.js.map +1 -1
  50. package/package.json +12 -11
  51. package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
  52. package/src/__tests__/broker-prompt.test.ts +142 -0
  53. package/src/__tests__/config.test.ts +35 -39
  54. package/src/__tests__/e2e-broker-step.test.ts +320 -0
  55. package/src/__tests__/e2e-mock-agent.test.ts +1 -1
  56. package/src/__tests__/setup-agent-discovery.test.ts +17 -5
  57. package/src/__tests__/setup-no-llm.test.ts +5 -2
  58. package/src/__tests__/step-ask.test.ts +9 -6
  59. package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
  60. package/src/__tests__/thread-poke.test.ts +6 -6
  61. package/src/__tests__/thread-resume.test.ts +2 -2
  62. package/src/__tests__/thread-suspend-step.test.ts +1 -1
  63. package/src/cli.ts +0 -0
  64. package/src/commands/broker-step.ts +636 -0
  65. package/src/commands/config.ts +2 -24
  66. package/src/commands/prompt.ts +3 -3
  67. package/src/commands/setup.ts +9 -1
  68. package/src/commands/step.ts +21 -204
  69. package/src/commands/thread.ts +87 -192
  70. package/dist/.build-fingerprint +0 -1
  71. package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
  72. package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
  73. package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
  74. package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
  75. package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
  76. package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
  77. package/dist/__tests__/spawn-agent-json.test.js +0 -79
  78. package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
  79. package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
  80. package/src/__tests__/spawn-agent-json.test.ts +0 -100
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Broker-driven step execution. Replaces the legacy `spawnAgent` /
3
+ * `executeAgentCommand` / last-stdout-line JSON parsing path with
4
+ * `broker.send()` over the Sumeru HTTP API.
5
+ *
6
+ * Phase 3 (#380) — `cmdThreadStepOnce`, `cmdThreadResume`, and `cmdThreadPoke`
7
+ * use this module instead of spawning per-role CLI binaries.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { putSchema, validate } from "@ocas/core";
12
+ import {
13
+ type AgentRoute,
14
+ createBroker,
15
+ createSessionStore,
16
+ type SendResult,
17
+ type SessionStore,
18
+ } from "@united-workforce/broker";
19
+ import type {
20
+ AgentAlias,
21
+ AgentConfig,
22
+ CasRef,
23
+ StartNodePayload,
24
+ StepContext,
25
+ StepNodePayload,
26
+ ThreadId,
27
+ Usage,
28
+ WorkflowConfig,
29
+ WorkflowPayload,
30
+ } from "@united-workforce/protocol";
31
+ import { createLogger, type ProcessLogger } from "@united-workforce/util";
32
+ import {
33
+ buildContinuationPrompt,
34
+ buildFrontmatterRetryPrompt,
35
+ buildOutputFormatInstruction,
36
+ buildRolePrompt,
37
+ buildThreadProgress,
38
+ mergeUsage,
39
+ tryFrontmatterFastPath,
40
+ trySuspendFastPath,
41
+ } from "@united-workforce/util-agent";
42
+ import type { UwfStore } from "../store.js";
43
+ import { expandOutput, fail } from "./shared.js";
44
+
45
+ const log = createLogger({ sink: { kind: "stderr" } });
46
+
47
+ /** Tag for broker.send call site. */
48
+ const PL_BROKER_SEND = "BR0KR5ND";
49
+ /** Tag for frontmatter retry call sites. */
50
+ const PL_FRONTMATTER_RETRY = "F4RTM4RT";
51
+ /** Tag for frontmatter extraction failure. */
52
+ const PL_FRONTMATTER_FAIL = "F4FA1L7Z";
53
+
54
+ const MAX_FRONTMATTER_RETRIES = 2;
55
+
56
+ const TURN_SCHEMA = {
57
+ title: "broker-turn",
58
+ type: "object" as const,
59
+ required: ["role", "content"],
60
+ properties: {
61
+ role: { type: "string" as const, enum: ["assistant", "tool"] },
62
+ content: { type: "string" as const },
63
+ },
64
+ additionalProperties: false,
65
+ };
66
+
67
+ const DETAIL_SCHEMA = {
68
+ title: "broker-detail",
69
+ type: "object" as const,
70
+ required: ["sessionId", "duration", "turnCount", "turns"],
71
+ properties: {
72
+ sessionId: { type: "string" as const },
73
+ duration: { type: "integer" as const },
74
+ turnCount: { type: "integer" as const },
75
+ turns: {
76
+ type: "array" as const,
77
+ items: { type: "string" as const, format: "ocas_ref" },
78
+ },
79
+ },
80
+ additionalProperties: false,
81
+ };
82
+
83
+ /** Result returned by `executeBrokerStep` — mirrors the legacy AdapterOutput surface. */
84
+ export type BrokerStepResult = {
85
+ stepHash: CasRef;
86
+ detailHash: CasRef;
87
+ role: string;
88
+ frontmatter: Record<string, unknown>;
89
+ body: string;
90
+ startedAtMs: number;
91
+ completedAtMs: number;
92
+ usage: Usage | null;
93
+ isError: boolean;
94
+ errorMessage: string | null;
95
+ };
96
+
97
+ /**
98
+ * Parse `--agent` overrides under the new `{host, gateway}` shape.
99
+ *
100
+ * Accepts:
101
+ * - alias e.g. `hermes` → `config.agents.hermes`
102
+ * - inline e.g. `http://h:7900 gw` → `{host: "http://h:7900", gateway: "gw"}`
103
+ *
104
+ * Single-token forms that don't match an alias fail with the documented
105
+ * message; this fully replaces the legacy "treat anything as a binary path"
106
+ * behaviour.
107
+ */
108
+ export function parseAgentOverride(override: string): AgentConfig {
109
+ const trimmed = override.trim();
110
+ if (trimmed === "") {
111
+ fail("agent override must not be empty");
112
+ }
113
+ const parts = trimmed.split(/\s+/).filter((p) => p.length > 0);
114
+ if (parts.length !== 2) {
115
+ fail(`agent override must be an alias or "<host> <gateway>"`);
116
+ }
117
+ const host = parts[0];
118
+ const gateway = parts[1];
119
+ if (host === undefined || gateway === undefined) {
120
+ fail(`agent override must be an alias or "<host> <gateway>"`);
121
+ }
122
+ return { host, gateway };
123
+ }
124
+
125
+ /**
126
+ * Resolve the agent route for a (workflow, role, override) triple.
127
+ * Mirrors the legacy `resolveAgentConfig` precedence:
128
+ * --agent override > agentOverrides[workflow][role] > defaultAgent
129
+ * Override may be an alias or an inline `"<host> <gateway>"` form.
130
+ */
131
+ export function resolveAgentRoute(
132
+ config: WorkflowConfig,
133
+ workflow: WorkflowPayload,
134
+ role: string,
135
+ agentOverride: string | null,
136
+ cwd: string | null,
137
+ ): AgentRoute {
138
+ if (agentOverride !== null) {
139
+ const fromAlias = config.agents[agentOverride as AgentAlias];
140
+ if (fromAlias !== undefined) {
141
+ return { host: fromAlias.host, gateway: fromAlias.gateway, cwd };
142
+ }
143
+ const parsed = parseAgentOverride(agentOverride);
144
+ return { host: parsed.host, gateway: parsed.gateway, cwd };
145
+ }
146
+
147
+ let alias: AgentAlias = config.defaultAgent;
148
+ if (config.agentOverrides !== null) {
149
+ const roleOverrides = config.agentOverrides[workflow.name];
150
+ if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
151
+ alias = roleOverrides[role];
152
+ }
153
+ }
154
+
155
+ const agentConfig = config.agents[alias];
156
+ if (agentConfig === undefined) {
157
+ fail(`unknown agent alias in config: ${alias}`);
158
+ }
159
+ return { host: agentConfig.host, gateway: agentConfig.gateway, cwd };
160
+ }
161
+
162
+ /**
163
+ * Path to the broker session store DB under the storage root. Mirrors the
164
+ * default used by `createSessionStore` but anchored at the user's `UWF_HOME`
165
+ * so multi-process scripts share the same SQLite file.
166
+ */
167
+ export function brokerSessionStorePath(storageRoot: string): string {
168
+ return join(storageRoot, "broker", "sessions.db");
169
+ }
170
+
171
+ /**
172
+ * Open (or create) the broker session store under `<storageRoot>/broker/sessions.db`.
173
+ * The caller is responsible for closing it.
174
+ */
175
+ export function openBrokerSessionStore(storageRoot: string): SessionStore {
176
+ return createSessionStore({ dbPath: brokerSessionStorePath(storageRoot) });
177
+ }
178
+
179
+ /**
180
+ * Look up the role's frontmatter / output schema in CAS so we can drive
181
+ * `tryFrontmatterFastPath`. The workflow payload only carries the schema's
182
+ * CAS hash; the JSON Schema itself lives in CAS via `WorkflowAdd`.
183
+ */
184
+ function loadRoleSchemaHash(workflow: WorkflowPayload, role: string): CasRef {
185
+ const roleDef = workflow.roles[role];
186
+ if (roleDef === undefined) {
187
+ fail(`unknown role "${role}" in workflow "${workflow.name}"`);
188
+ }
189
+ return roleDef.frontmatter as CasRef;
190
+ }
191
+
192
+ /**
193
+ * Build the output-format instruction for a role from its frontmatter schema in
194
+ * CAS. Returns an empty string when the schema node is missing.
195
+ */
196
+ function loadOutputFormatInstruction(uwf: UwfStore, schemaHash: CasRef): string {
197
+ const node = uwf.store.cas.get(schemaHash);
198
+ if (node === null) {
199
+ return "";
200
+ }
201
+ return buildOutputFormatInstruction(node.payload as Record<string, unknown>);
202
+ }
203
+
204
+ /** Extract the last assistant turn's content from a detail node, or null. */
205
+ function extractStepContent(uwf: UwfStore, detailRef: CasRef): string | null {
206
+ const detailNode = uwf.store.cas.get(detailRef);
207
+ if (detailNode === null) {
208
+ return null;
209
+ }
210
+ const detail = detailNode.payload as Record<string, unknown>;
211
+ const turns = detail.turns;
212
+ if (!Array.isArray(turns) || turns.length === 0) {
213
+ return null;
214
+ }
215
+ for (let i = turns.length - 1; i >= 0; i--) {
216
+ const turnRef = turns[i];
217
+ if (typeof turnRef !== "string") {
218
+ continue;
219
+ }
220
+ const turnNode = uwf.store.cas.get(turnRef as CasRef);
221
+ if (turnNode === null) {
222
+ continue;
223
+ }
224
+ const turn = turnNode.payload as Record<string, unknown>;
225
+ if (
226
+ turn.role === "assistant" &&
227
+ typeof turn.content === "string" &&
228
+ turn.content.trim() !== ""
229
+ ) {
230
+ return turn.content;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * Walk the CAS step chain from `prevHash` back to the StartNode and return the
238
+ * steps in chronological order (oldest first) as StepContext records. Honors the
239
+ * caller-supplied `prev` pointer so poke replace-semantics (prev = old head's
240
+ * prev) produce the correct history. Mirrors the history assembly in
241
+ * util-agent's `buildContext`, but reuses the store the CLI already opened.
242
+ */
243
+ function collectStepContexts(uwf: UwfStore, prevHash: CasRef | null): StepContext[] {
244
+ const newestFirst: StepNodePayload[] = [];
245
+ let hash: CasRef | null = prevHash;
246
+ while (hash !== null) {
247
+ const node = uwf.store.cas.get(hash);
248
+ if (node === null || node.type !== uwf.schemas.stepNode) {
249
+ break;
250
+ }
251
+ const payload = node.payload as StepNodePayload;
252
+ newestFirst.push(payload);
253
+ hash = payload.prev;
254
+ }
255
+
256
+ const chronological = [...newestFirst].reverse();
257
+ return chronological.map((step) => ({
258
+ role: step.role,
259
+ output: expandOutput(uwf, step.output),
260
+ detail: step.detail,
261
+ agent: step.agent,
262
+ edgePrompt: step.edgePrompt ?? "",
263
+ startedAtMs: step.startedAtMs,
264
+ completedAtMs: step.completedAtMs,
265
+ cwd: step.cwd ?? "",
266
+ assembledPrompt: step.assembledPrompt ?? null,
267
+ usage: step.usage ?? null,
268
+ previousAttempts: step.previousAttempts ?? null,
269
+ content: extractStepContent(uwf, step.detail),
270
+ }));
271
+ }
272
+
273
+ export type AssembleBrokerPromptArgs = {
274
+ workflow: WorkflowPayload;
275
+ role: string;
276
+ threadId: ThreadId;
277
+ /** The thread's initial task prompt (StartNode.prompt). */
278
+ startPrompt: string;
279
+ /** Prior steps in chronological order (oldest first). */
280
+ steps: StepContext[];
281
+ /** Moderator edge prompt that routed to this step. */
282
+ edgePrompt: string;
283
+ /** Frontmatter deliverable-format instruction for the role's output schema. */
284
+ outputFormatInstruction: string;
285
+ };
286
+
287
+ /**
288
+ * Assemble the full agent prompt for a broker step. Combines the five
289
+ * components the legacy agent-CLI path produced (output-format instruction,
290
+ * thread progress, role prompt, task prompt, and continuation/edge context) so
291
+ * `broker.send()` receives the same context the spawned-agent path did.
292
+ *
293
+ * Mirrors `buildClaudeCodePrompt` from the agent-claude-code adapter.
294
+ */
295
+ export function assembleBrokerPrompt(args: AssembleBrokerPromptArgs): string {
296
+ const roleDef = args.workflow.roles[args.role];
297
+ const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
298
+ const isFirstVisit = !args.steps.some((s) => s.role === args.role);
299
+
300
+ const parts: string[] = [];
301
+
302
+ if (args.outputFormatInstruction !== "") {
303
+ parts.push(args.outputFormatInstruction, "");
304
+ }
305
+
306
+ // Inject thread progress so the agent knows step count and role visit count.
307
+ parts.push(buildThreadProgress(args.steps, args.role, args.threadId), "");
308
+
309
+ parts.push(rolePrompt, "", "## Task", args.startPrompt);
310
+
311
+ if (!isFirstVisit) {
312
+ // Re-entry (broker resumes the cached session): show only steps since the
313
+ // last visit, meta only.
314
+ parts.push("", buildContinuationPrompt(args.steps, args.role, args.edgePrompt));
315
+ } else if (args.steps.length > 0) {
316
+ // First visit with prior history: show steps with content for recent ones.
317
+ parts.push(
318
+ "",
319
+ buildContinuationPrompt(args.steps, args.role, args.edgePrompt, {
320
+ includeContent: true,
321
+ quota: 32000,
322
+ }),
323
+ );
324
+ } else {
325
+ parts.push("", "## Current Instruction", "", args.edgePrompt);
326
+ }
327
+
328
+ return parts.join("\n");
329
+ }
330
+
331
+ /** Persist the raw broker.send output as a CAS detail node — single assistant turn. */
332
+ async function storeBrokerDetail(
333
+ uwf: UwfStore,
334
+ result: SendResult,
335
+ startedAtMs: number,
336
+ completedAtMs: number,
337
+ ): Promise<CasRef> {
338
+ const turnSchemaHash = await putSchema(uwf.store, TURN_SCHEMA);
339
+ const detailSchemaHash = await putSchema(uwf.store, DETAIL_SCHEMA);
340
+
341
+ const turn = { role: "assistant", content: result.output };
342
+ const turnHash = await uwf.store.cas.put(turnSchemaHash, turn);
343
+
344
+ const detail = {
345
+ sessionId: result.sessionId,
346
+ duration: Math.max(0, completedAtMs - startedAtMs),
347
+ turnCount: 1,
348
+ turns: [turnHash],
349
+ };
350
+ return uwf.store.cas.put(detailSchemaHash, detail);
351
+ }
352
+
353
+ type WriteStepNodeArgs = {
354
+ uwf: UwfStore;
355
+ startHash: CasRef;
356
+ prevHash: CasRef | null;
357
+ role: string;
358
+ outputHash: CasRef;
359
+ detailHash: CasRef;
360
+ agentName: string;
361
+ edgePrompt: string;
362
+ startedAtMs: number;
363
+ completedAtMs: number;
364
+ cwd: string;
365
+ assembledPromptHash: CasRef | null;
366
+ usage: Usage | null;
367
+ previousAttempts: CasRef[] | null;
368
+ };
369
+
370
+ /** Persist a StepNode payload and verify it round-trips through schema validation. */
371
+ async function writeBrokerStepNode(args: WriteStepNodeArgs): Promise<CasRef> {
372
+ const payload: StepNodePayload = {
373
+ start: args.startHash,
374
+ prev: args.prevHash,
375
+ role: args.role,
376
+ output: args.outputHash,
377
+ detail: args.detailHash,
378
+ agent: args.agentName,
379
+ edgePrompt: args.edgePrompt,
380
+ startedAtMs: args.startedAtMs,
381
+ completedAtMs: args.completedAtMs,
382
+ cwd: args.cwd,
383
+ assembledPrompt: args.assembledPromptHash,
384
+ usage: args.usage,
385
+ previousAttempts: args.previousAttempts,
386
+ };
387
+ const hash = await args.uwf.store.cas.put(args.uwf.schemas.stepNode, payload);
388
+ const node = args.uwf.store.cas.get(hash);
389
+ if (node === null || !validate(args.uwf.store, node)) {
390
+ fail("broker step persisted a StepNode that failed schema validation");
391
+ }
392
+ return hash;
393
+ }
394
+
395
+ type ExtractOutcome = {
396
+ outputHash: CasRef;
397
+ frontmatter: Record<string, unknown>;
398
+ body: string;
399
+ };
400
+
401
+ async function tryExtract(
402
+ uwf: UwfStore,
403
+ rawOutput: string,
404
+ outputSchema: CasRef,
405
+ ): Promise<ExtractOutcome | null> {
406
+ // `$status: "$SUSPEND"` is a reserved coroutine yield — store it against the
407
+ // suspend schema, bypassing the role's own frontmatter schema.
408
+ const suspend = await trySuspendFastPath(rawOutput, uwf.schemas.suspendOutput, uwf.store);
409
+ if (suspend !== null) {
410
+ return { outputHash: suspend.outputHash, frontmatter: suspend.frontmatter, body: suspend.body };
411
+ }
412
+ const fastPath = await tryFrontmatterFastPath(rawOutput, outputSchema, uwf.store);
413
+ if (fastPath !== null) {
414
+ return {
415
+ outputHash: fastPath.outputHash,
416
+ frontmatter: fastPath.frontmatter,
417
+ body: fastPath.body,
418
+ };
419
+ }
420
+ return null;
421
+ }
422
+
423
+ /**
424
+ * Inputs for `executeBrokerStep`. The CLI pre-resolves the chain start, head,
425
+ * and workflow so this function only worries about the broker exchange + CAS
426
+ * write path.
427
+ */
428
+ export type ExecuteBrokerStepArgs = {
429
+ storageRoot: string;
430
+ uwf: UwfStore;
431
+ config: WorkflowConfig;
432
+ workflow: WorkflowPayload;
433
+ threadId: ThreadId;
434
+ role: string;
435
+ edgePrompt: string;
436
+ effectiveCwd: string;
437
+ startHash: CasRef;
438
+ prevHash: CasRef | null;
439
+ agentOverride: string | null;
440
+ previousAttempts: CasRef[] | null;
441
+ plog: ProcessLogger;
442
+ };
443
+
444
+ /**
445
+ * Drive one moderator-resolved role through `broker.send()`, frontmatter
446
+ * extraction (with retries on the same Sumeru session), and StepNode
447
+ * persistence. Returns a `BrokerStepResult` shaped for the existing
448
+ * `executeAndProcessAgentStep` flow.
449
+ *
450
+ * Side effects:
451
+ * - inserts a row in the broker session store keyed by (threadId, role)
452
+ * - writes a turn / detail / StepNode triplet to CAS
453
+ * - on extraction failure, persists an error StepNode (isError=true)
454
+ */
455
+ export async function executeBrokerStep(args: ExecuteBrokerStepArgs): Promise<BrokerStepResult> {
456
+ const sessionStore = openBrokerSessionStore(args.storageRoot);
457
+
458
+ try {
459
+ const route = resolveAgentRoute(
460
+ args.config,
461
+ args.workflow,
462
+ args.role,
463
+ args.agentOverride,
464
+ args.effectiveCwd === "" ? null : args.effectiveCwd,
465
+ );
466
+
467
+ const broker = createBroker({
468
+ sessionStore,
469
+ resolveRoute: () => route,
470
+ clientFactory: null,
471
+ });
472
+
473
+ args.plog.log(
474
+ PL_BROKER_SEND,
475
+ `broker.send role=${args.role} host=${route.host} gateway=${route.gateway}`,
476
+ null,
477
+ );
478
+
479
+ // Assemble the full agent prompt (output-format instruction + thread
480
+ // progress + role prompt + task + continuation/edge context) so the broker
481
+ // path sends the same context the legacy spawned-agent path did, rather than
482
+ // the bare edge prompt.
483
+ const outputSchemaHash = loadRoleSchemaHash(args.workflow, args.role);
484
+ const outputFormatInstruction = loadOutputFormatInstruction(args.uwf, outputSchemaHash);
485
+ const startNode = args.uwf.store.cas.get(args.startHash);
486
+ const startPrompt = startNode !== null ? (startNode.payload as StartNodePayload).prompt : "";
487
+ const steps = collectStepContexts(args.uwf, args.prevHash);
488
+ const assembledPrompt = assembleBrokerPrompt({
489
+ workflow: args.workflow,
490
+ role: args.role,
491
+ threadId: args.threadId,
492
+ startPrompt,
493
+ steps,
494
+ edgePrompt: args.edgePrompt,
495
+ outputFormatInstruction,
496
+ });
497
+ const assembledPromptHash = (await args.uwf.store.cas.put(
498
+ args.uwf.schemas.text,
499
+ assembledPrompt,
500
+ )) as CasRef;
501
+
502
+ const startedAtMs = Date.now();
503
+ const primary = await broker.send({
504
+ threadId: args.threadId,
505
+ role: args.role,
506
+ prompt: assembledPrompt,
507
+ });
508
+
509
+ let extracted = await tryExtract(args.uwf, primary.output, outputSchemaHash);
510
+ let accumulatedUsage: Usage | null = brokerUsage(primary);
511
+ let lastOutput = primary.output;
512
+ let lastSessionId = primary.sessionId;
513
+
514
+ // Retry on the same (threadId, role) — the broker re-uses the cached
515
+ // Sumeru session, so the agent gets to "fix its frontmatter" with full
516
+ // context preserved.
517
+ for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && extracted === null; retry++) {
518
+ const correctionPrompt = buildFrontmatterRetryPrompt(outputFormatInstruction);
519
+ log(
520
+ PL_FRONTMATTER_RETRY,
521
+ `frontmatter retry ${retry + 1}/${MAX_FRONTMATTER_RETRIES} thread=${args.threadId} role=${args.role}`,
522
+ );
523
+ const retryResult = await broker.send({
524
+ threadId: args.threadId,
525
+ role: args.role,
526
+ prompt: correctionPrompt,
527
+ });
528
+ lastOutput = retryResult.output;
529
+ lastSessionId = retryResult.sessionId;
530
+ accumulatedUsage = mergeUsage(accumulatedUsage, brokerUsage(retryResult));
531
+ extracted = await tryExtract(args.uwf, lastOutput, outputSchemaHash);
532
+ }
533
+
534
+ const completedAtMs = Date.now();
535
+ const detailHash = await storeBrokerDetail(
536
+ args.uwf,
537
+ { ...primary, output: lastOutput, sessionId: lastSessionId },
538
+ startedAtMs,
539
+ completedAtMs,
540
+ );
541
+
542
+ if (extracted === null) {
543
+ log(
544
+ PL_FRONTMATTER_FAIL,
545
+ `frontmatter extraction failed after ${MAX_FRONTMATTER_RETRIES} retries thread=${args.threadId} role=${args.role}`,
546
+ );
547
+ const errorMessage =
548
+ "Agent output does not contain valid YAML frontmatter matching the role schema " +
549
+ `after ${MAX_FRONTMATTER_RETRIES} retries.\n` +
550
+ `Raw output (first 500 chars): ${lastOutput.slice(0, 500)}`;
551
+ const errorPayload = {
552
+ $status: "error" as const,
553
+ error: errorMessage,
554
+ phase: "frontmatter_extraction" as const,
555
+ };
556
+ const errorOutputHash = await args.uwf.store.cas.put(
557
+ args.uwf.schemas.errorOutput,
558
+ errorPayload,
559
+ );
560
+ const failedStepHash = await writeBrokerStepNode({
561
+ uwf: args.uwf,
562
+ startHash: args.startHash,
563
+ prevHash: args.prevHash,
564
+ role: args.role,
565
+ outputHash: errorOutputHash,
566
+ detailHash,
567
+ agentName: route.gateway,
568
+ edgePrompt: args.edgePrompt,
569
+ startedAtMs,
570
+ completedAtMs,
571
+ cwd: args.effectiveCwd,
572
+ assembledPromptHash,
573
+ usage: accumulatedUsage,
574
+ previousAttempts: null,
575
+ });
576
+ return {
577
+ stepHash: failedStepHash,
578
+ detailHash,
579
+ role: args.role,
580
+ frontmatter: { $status: "error" },
581
+ body: "",
582
+ startedAtMs,
583
+ completedAtMs,
584
+ usage: accumulatedUsage,
585
+ isError: true,
586
+ errorMessage,
587
+ };
588
+ }
589
+
590
+ const stepHash = await writeBrokerStepNode({
591
+ uwf: args.uwf,
592
+ startHash: args.startHash,
593
+ prevHash: args.prevHash,
594
+ role: args.role,
595
+ outputHash: extracted.outputHash,
596
+ detailHash,
597
+ agentName: route.gateway,
598
+ edgePrompt: args.edgePrompt,
599
+ startedAtMs,
600
+ completedAtMs,
601
+ cwd: args.effectiveCwd,
602
+ assembledPromptHash,
603
+ usage: accumulatedUsage,
604
+ previousAttempts: args.previousAttempts,
605
+ });
606
+
607
+ return {
608
+ stepHash,
609
+ detailHash,
610
+ role: args.role,
611
+ frontmatter: extracted.frontmatter,
612
+ body: extracted.body,
613
+ startedAtMs,
614
+ completedAtMs,
615
+ usage: accumulatedUsage,
616
+ isError: false,
617
+ errorMessage: null,
618
+ };
619
+ } finally {
620
+ sessionStore.close();
621
+ }
622
+ }
623
+
624
+ function brokerUsage(result: SendResult): Usage | null {
625
+ // Sumeru's `done` event reports per-exchange usage. Normalize into the
626
+ // engine's Usage shape so `mergeUsage` can sum across retries.
627
+ const done = result.done;
628
+ if (done === null || typeof done !== "object") {
629
+ return null;
630
+ }
631
+ const turns = done.turnCount;
632
+ const inputTokens = done.tokens !== null ? done.tokens.in : 0;
633
+ const outputTokens = done.tokens !== null ? done.tokens.out : 0;
634
+ const duration = done.durationMs;
635
+ return { turns, inputTokens, outputTokens, duration };
636
+ }
@@ -14,7 +14,7 @@ const VALID_CONFIG_KEYS: Record<
14
14
  > = {
15
15
  agents: {
16
16
  nested: true,
17
- knownFields: ["command", "args"],
17
+ knownFields: ["host", "gateway"],
18
18
  },
19
19
  agentOverrides: {
20
20
  nested: true,
@@ -203,26 +203,6 @@ export async function cmdConfigGet(storageRoot: string, key: string): Promise<un
203
203
  return value;
204
204
  }
205
205
 
206
- /**
207
- * Parse value for args key (must be JSON array)
208
- */
209
- function parseArgsValue(value: string): unknown {
210
- if (value.startsWith("[")) {
211
- try {
212
- const parsed = JSON.parse(value);
213
- if (!Array.isArray(parsed)) {
214
- throw new Error("Value must be an array");
215
- }
216
- return parsed;
217
- } catch (error) {
218
- throw new Error(
219
- `Invalid JSON array for args key: ${error instanceof Error ? error.message : String(error)}`,
220
- );
221
- }
222
- }
223
- throw new Error("Value for 'args' key must be a JSON array starting with '['");
224
- }
225
-
226
206
  /**
227
207
  * Parse value for a top-level string array key (must be JSON array of strings).
228
208
  */
@@ -292,12 +272,10 @@ export async function cmdConfigSet(
292
272
 
293
273
  const lastSegment = path[path.length - 1];
294
274
 
295
- // Parse value if it's for an array key (args, workflowPaths)
275
+ // Parse value if it's for an array key (workflowPaths)
296
276
  let parsedValue: unknown = value;
297
277
  if (path[0] === "workflowPaths") {
298
278
  parsedValue = parseStringArrayValue(value, "workflowPaths");
299
- } else if (lastSegment === "args") {
300
- parsedValue = parseArgsValue(value);
301
279
  } else if (lastSegment === "maxRunning") {
302
280
  const num = Number(value);
303
281
  if (!Number.isInteger(num) || num < 1) {
@@ -162,15 +162,15 @@ Or configure non-interactively:
162
162
  uwf setup --agent <adapter-command>
163
163
  \`\`\`
164
164
 
165
- **Note:** \`--agent\` takes the adapter **command name** (e.g. \`uwf-hermes\`, \`uwf-claude-code\`), not the npm package name.
165
+ **Note:** \`--agent\` takes an alias declared in your \`agents\` map (e.g. \`hermes\`, \`claude-code\`) — **not** an adapter command name. Each alias resolves to a \`{host, gateway}\` Sumeru endpoint that the broker contacts over HTTP. \`uwf thread exec --agent\` additionally accepts an inline \`"<host> <gateway>"\` pair for ad-hoc routing.
166
166
 
167
167
  Config is saved to \`~/.uwf/config.yaml\`:
168
168
 
169
169
  \`\`\`yaml
170
170
  agents:
171
171
  hermes:
172
- command: uwf-hermes
173
- args: []
172
+ host: http://127.0.0.1:7900
173
+ gateway: hermes
174
174
  defaultAgent: hermes
175
175
  agentOverrides: {}
176
176
  \`\`\`