@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.
- package/LICENSE +21 -0
- package/README.md +89 -1
- package/dist/__tests__/agent-resolution-llm-free.test.js +9 -2
- package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -1
- package/dist/__tests__/broker-prompt.test.d.ts +10 -0
- package/dist/__tests__/broker-prompt.test.d.ts.map +1 -0
- package/dist/__tests__/broker-prompt.test.js +129 -0
- package/dist/__tests__/broker-prompt.test.js.map +1 -0
- package/dist/__tests__/config.test.js +33 -37
- package/dist/__tests__/config.test.js.map +1 -1
- package/dist/__tests__/e2e-broker-step.test.d.ts +13 -0
- package/dist/__tests__/e2e-broker-step.test.d.ts.map +1 -0
- package/dist/__tests__/e2e-broker-step.test.js +278 -0
- package/dist/__tests__/e2e-broker-step.test.js.map +1 -0
- package/dist/__tests__/e2e-mock-agent.test.js +1 -1
- package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
- package/dist/__tests__/setup-agent-discovery.test.js +17 -5
- package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
- package/dist/__tests__/setup-no-llm.test.js +5 -2
- package/dist/__tests__/setup-no-llm.test.js.map +1 -1
- package/dist/__tests__/step-ask.test.js +9 -6
- package/dist/__tests__/step-ask.test.js.map +1 -1
- package/dist/__tests__/thread-agent-failure-suspended.test.js +3 -3
- package/dist/__tests__/thread-agent-failure-suspended.test.js.map +1 -1
- package/dist/__tests__/thread-poke.test.js +6 -6
- package/dist/__tests__/thread-poke.test.js.map +1 -1
- package/dist/__tests__/thread-resume.test.js +2 -2
- package/dist/__tests__/thread-resume.test.js.map +1 -1
- package/dist/__tests__/thread-suspend-step.test.js +1 -1
- package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
- package/dist/commands/broker-step.d.ts +110 -0
- package/dist/commands/broker-step.d.ts.map +1 -0
- package/dist/commands/broker-step.js +450 -0
- package/dist/commands/broker-step.js.map +1 -0
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +2 -23
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/prompt.js +3 -3
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +8 -1
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/step.d.ts +6 -5
- package/dist/commands/step.d.ts.map +1 -1
- package/dist/commands/step.js +11 -154
- package/dist/commands/step.js.map +1 -1
- package/dist/commands/thread.d.ts +4 -0
- package/dist/commands/thread.d.ts.map +1 -1
- package/dist/commands/thread.js +77 -151
- package/dist/commands/thread.js.map +1 -1
- package/package.json +12 -11
- package/src/__tests__/agent-resolution-llm-free.test.ts +14 -2
- package/src/__tests__/broker-prompt.test.ts +142 -0
- package/src/__tests__/config.test.ts +35 -39
- package/src/__tests__/e2e-broker-step.test.ts +320 -0
- package/src/__tests__/e2e-mock-agent.test.ts +1 -1
- package/src/__tests__/setup-agent-discovery.test.ts +17 -5
- package/src/__tests__/setup-no-llm.test.ts +5 -2
- package/src/__tests__/step-ask.test.ts +9 -6
- package/src/__tests__/thread-agent-failure-suspended.test.ts +3 -3
- package/src/__tests__/thread-poke.test.ts +6 -6
- package/src/__tests__/thread-resume.test.ts +2 -2
- package/src/__tests__/thread-suspend-step.test.ts +1 -1
- package/src/cli.ts +0 -0
- package/src/commands/broker-step.ts +636 -0
- package/src/commands/config.ts +2 -24
- package/src/commands/prompt.ts +3 -3
- package/src/commands/setup.ts +9 -1
- package/src/commands/step.ts +21 -204
- package/src/commands/thread.ts +87 -192
- package/dist/.build-fingerprint +0 -1
- package/dist/__tests__/adapter-json-roundtrip.test.d.ts +0 -2
- package/dist/__tests__/adapter-json-roundtrip.test.d.ts.map +0 -1
- package/dist/__tests__/adapter-json-roundtrip.test.js +0 -160
- package/dist/__tests__/adapter-json-roundtrip.test.js.map +0 -1
- package/dist/__tests__/spawn-agent-json.test.d.ts +0 -2
- package/dist/__tests__/spawn-agent-json.test.d.ts.map +0 -1
- package/dist/__tests__/spawn-agent-json.test.js +0 -79
- package/dist/__tests__/spawn-agent-json.test.js.map +0 -1
- package/src/__tests__/adapter-json-roundtrip.test.ts +0 -193
- 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
|
+
}
|
package/src/commands/config.ts
CHANGED
|
@@ -14,7 +14,7 @@ const VALID_CONFIG_KEYS: Record<
|
|
|
14
14
|
> = {
|
|
15
15
|
agents: {
|
|
16
16
|
nested: true,
|
|
17
|
-
knownFields: ["
|
|
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 (
|
|
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) {
|
package/src/commands/prompt.ts
CHANGED
|
@@ -162,15 +162,15 @@ Or configure non-interactively:
|
|
|
162
162
|
uwf setup --agent <adapter-command>
|
|
163
163
|
\`\`\`
|
|
164
164
|
|
|
165
|
-
**Note:** \`--agent\` takes
|
|
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
|
-
|
|
173
|
-
|
|
172
|
+
host: http://127.0.0.1:7900
|
|
173
|
+
gateway: hermes
|
|
174
174
|
defaultAgent: hermes
|
|
175
175
|
agentOverrides: {}
|
|
176
176
|
\`\`\`
|