@agentswf/engine 0.1.0-beta.3 → 0.1.0-beta.5

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 (72) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +611 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +3 -15
  6. package/src/attempt-ending.ts +2 -4
  7. package/src/attempt-output.ts +3 -2
  8. package/src/attempt-view.ts +1 -1
  9. package/src/control-plane.ts +38 -6
  10. package/src/coordinator-socket.ts +15 -6
  11. package/src/deadlines.ts +69 -2
  12. package/src/decisions/directory.ts +9 -11
  13. package/src/deprecations.ts +177 -0
  14. package/src/duration.ts +50 -20
  15. package/src/emit-command.ts +57 -54
  16. package/src/emit.ts +124 -15
  17. package/src/help/allowance.txt +13 -0
  18. package/src/help/emit.txt +21 -0
  19. package/src/help/inbox.txt +12 -0
  20. package/src/help/init.txt +12 -0
  21. package/src/help/logs.txt +14 -0
  22. package/src/help/reply.txt +18 -0
  23. package/src/help/run.txt +58 -0
  24. package/src/help/runs.txt +11 -0
  25. package/src/help/test.txt +9 -0
  26. package/src/help/tick.txt +12 -0
  27. package/src/herdr-run-session.ts +2 -2
  28. package/src/here.ts +1 -1
  29. package/src/holder-command.ts +38 -51
  30. package/src/inbox-command.ts +9 -24
  31. package/src/init-command.ts +2 -11
  32. package/src/init-template/AGENTS.md.template +3 -3
  33. package/src/init-template/hello/workflow.test.ts.template +3 -3
  34. package/src/init-template/hello/workflow.ts.template +6 -6
  35. package/src/keep-alive.ts +28 -13
  36. package/src/logs-command.ts +14 -27
  37. package/src/mailbox.ts +16 -21
  38. package/src/messaging/limits.ts +5 -5
  39. package/src/messaging/messaging.ts +48 -43
  40. package/src/messaging/routes.ts +2 -2
  41. package/src/messaging/text.ts +24 -22
  42. package/src/operation-liveness.ts +1 -1
  43. package/src/operator-cli.ts +23 -9
  44. package/src/operator-runtime.ts +9 -12
  45. package/src/pane-layout.ts +125 -32
  46. package/src/progress-view.ts +8 -8
  47. package/src/result-validation.ts +1 -1
  48. package/src/run-command.ts +27 -47
  49. package/src/run-commands.ts +1 -1
  50. package/src/run-prepare.ts +46 -8
  51. package/src/run-usage.ts +2 -2
  52. package/src/run-view.ts +9 -4
  53. package/src/runs-command.ts +14 -23
  54. package/src/runs.ts +44 -8
  55. package/src/sandboxes.ts +3 -2
  56. package/src/simple-input.ts +13 -3
  57. package/src/sources.ts +42 -6
  58. package/src/stage-ledger.ts +43 -13
  59. package/src/stage-plan.ts +37 -1
  60. package/src/test-command.ts +9 -14
  61. package/src/testing.ts +8 -9
  62. package/src/tick-command.ts +2 -13
  63. package/src/tools.ts +62 -25
  64. package/src/workflow-loader.ts +5 -2
  65. package/src/workflow-runner.ts +636 -271
  66. package/src/workflow-testing/coordinator.typecheck.ts +12 -13
  67. package/src/workflow-testing/coordinators.ts +2 -2
  68. package/src/workflow-testing/host.ts +115 -46
  69. package/src/workflow-testing/index.ts +45 -837
  70. package/src/workflow-testing/run-test.ts +1213 -0
  71. package/src/workflow-testing/script.ts +106 -45
  72. package/src/workflow-testing/workflow-testing.typecheck.ts +13 -10
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@agentswf/engine",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.5",
4
4
  "description": "agents.wf: the engine that runs workflows made of coding agents",
5
5
  "license": "MIT",
6
- "homepage": "https://github.com/romanlv/agentswf",
6
+ "homepage": "https://agents.wf",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/romanlv/agentswf.git",
@@ -29,10 +29,9 @@
29
29
  "!src/**/fixtures"
30
30
  ],
31
31
  "dependencies": {
32
- "@agentswf/wf": "0.1.0-beta.3",
33
- "@agentswf/contract": "0.1.0-beta.3",
34
- "@agentswf/harness": "0.1.0-beta.3",
35
- "@agentswf/sandbox": "0.1.0-beta.3",
32
+ "@agentswf/contract": "0.1.0-beta.5",
33
+ "@agentswf/harness": "0.1.0-beta.5",
34
+ "@agentswf/sandbox": "0.1.0-beta.5",
36
35
  "typebox": "^1.3.34"
37
36
  },
38
37
  "publishConfig": {
@@ -0,0 +1,611 @@
1
+ import {
2
+ type ControlRequest,
3
+ type ControlResponse,
4
+ type MessageRequest,
5
+ validWaitingReason,
6
+ WIRE_VERSION,
7
+ } from "@agentswf/contract/wire";
8
+ import { durationMilliseconds } from "@agentswf/contract/workflow";
9
+ import { NotSentError, submitControl } from "./client";
10
+
11
+ /** `wfa` is its launcher's full path, as an agent must type it; `wfa` alone when it is not known. */
12
+ function usage(wfa: string): string {
13
+ return [
14
+ `usage: ${wfa} result <call-id> --<name> '<value>' ...`,
15
+ ` ${wfa} result <call-id> <<'WF_JSON'`,
16
+ " <json>",
17
+ " WF_JSON",
18
+ ` ${wfa} tool <name> --<option> '<value>' ...`,
19
+ ` ${wfa} tool <name> <<'WF_JSON'`,
20
+ " <json>",
21
+ " WF_JSON",
22
+ ` ${wfa} waiting <call-id> --reason <text> [--timeout <duration>]`,
23
+ ` ${wfa} peers [--json]`,
24
+ ...messageUsage(wfa, " "),
25
+ "",
26
+ "The call id is the one named in the request. Give the result as the flags the request names,",
27
+ "each value in single quotes, or as JSON on standard input, as a quoted heredoc. A tool takes",
28
+ "its input the same way, or nothing for one that takes none. wfa tool exits 0 with the tool's",
29
+ "result, 1 when the tool failed, 3 when the call was refused and didn't run, and 4 when its",
30
+ "answer is unknown, so it may have run.",
31
+ ].join("\n");
32
+ }
33
+
34
+ function messageUsage(wfa: string, indent = "usage: "): string[] {
35
+ return [
36
+ `${indent}${wfa} send [--await-reply [--timeout <duration>]] <peer> <<'EOF'`,
37
+ " <message>",
38
+ " EOF",
39
+ ` ${wfa} reply [--await-reply [--timeout <duration>]] <peer> <<'EOF'`,
40
+ " <answer>",
41
+ " EOF",
42
+ "",
43
+ "Flags go before the message. The message comes from a quoted heredoc, standard input or one",
44
+ "final argument; given an argument, standard input is not read. One that starts with -- goes",
45
+ "in a heredoc.",
46
+ ];
47
+ }
48
+ const MAX_STDIN_BYTES = 1024 * 1024;
49
+ const STDIN_TIMEOUT_MS = 30_000;
50
+
51
+ export type CliOutcome = { exitCode: number; stdout: string; stderr: string };
52
+
53
+ type Submit = (
54
+ endpoint: string,
55
+ request: ControlRequest,
56
+ timeoutSeconds?: number | null,
57
+ ) => Promise<ControlResponse>;
58
+
59
+ export async function runCli(
60
+ argv: readonly string[],
61
+ stdin: string | null,
62
+ submit: Submit = submitControl,
63
+ ): Promise<CliOutcome> {
64
+ // The launcher the engine installs supplies `--at`; the socket is an address, not a secret.
65
+ const {
66
+ endpoint,
67
+ session,
68
+ launcher,
69
+ command: [command, ...args],
70
+ } = launched(argv);
71
+ const wfa = launcher ?? "wfa";
72
+ if (!endpoint) {
73
+ return usageError("wfa must be run through the launcher the workflow engine installed");
74
+ }
75
+ switch (command) {
76
+ case "waiting":
77
+ case "send":
78
+ case "reply": {
79
+ const said: Said = {};
80
+ const outcome =
81
+ command === "waiting"
82
+ ? await runWaiting(endpoint, session, args, submit, said)
83
+ : await runMessage(endpoint, session, command, args, stdin, wfa, submit, said);
84
+ return noticed(outcome, said.notice);
85
+ }
86
+ case "result":
87
+ return runResult(endpoint, session, args, stdin, submit);
88
+ case "tool":
89
+ return runTool(endpoint, session, args, stdin, submit);
90
+ case "peers":
91
+ return runPeers(endpoint, session, args, submit);
92
+ default:
93
+ return usageError(usage(wfa));
94
+ }
95
+ }
96
+
97
+ /** What a command accepted in a deprecated form, said beside its outcome. */
98
+ type Said = { notice?: string };
99
+
100
+ /** Never beside a usage error: the form said was not taken then. */
101
+ function noticed(outcome: CliOutcome, notice: string | undefined): CliOutcome {
102
+ return notice && outcome.exitCode !== 2
103
+ ? { ...outcome, stderr: outcome.stderr ? `${notice}\n${outcome.stderr}` : notice }
104
+ : outcome;
105
+ }
106
+
107
+ async function runResult(
108
+ endpoint: string,
109
+ session: string | undefined,
110
+ args: readonly string[],
111
+ stdin: string | null,
112
+ submit: Submit,
113
+ ): Promise<CliOutcome> {
114
+ const [operationId, ...flags] = args;
115
+ if (!operationId?.trim() || operationId.startsWith("--"))
116
+ return usageError("wfa result needs the call id it is answering");
117
+ // The engine reads the flags by the schema, which wfa doesn't know. Standard input was read only
118
+ // with nothing after the call id; with nothing there either, the result is no input.
119
+ const given = givenInput(flags, stdin);
120
+
121
+ let response: ControlResponse;
122
+ try {
123
+ response = await submit(endpoint, {
124
+ version: WIRE_VERSION,
125
+ command: "result",
126
+ operationId,
127
+ ...given,
128
+ ...(session ? { session } : {}),
129
+ });
130
+ } catch (error) {
131
+ const reason = error instanceof Error ? error.message : String(error);
132
+ return { exitCode: 1, stdout: "", stderr: `wfa: result submission failed.\n${reason}` };
133
+ }
134
+
135
+ if (response.kind === "rejected") {
136
+ return refused(response, `wfa: ${rejection(response, operationId)}`);
137
+ }
138
+ if (response.kind !== "accepted")
139
+ return {
140
+ exitCode: 1,
141
+ stdout: "",
142
+ stderr: "wfa: unexpected waiting acknowledgement for result",
143
+ };
144
+ return succeeded(response, "wfa: result accepted");
145
+ }
146
+
147
+ /** Standard input was read only with nothing after the name or call id; with nothing there, no input. */
148
+ function givenInput(words: readonly string[], stdin: string | null) {
149
+ if (words.length > 0) return { args: [...words] };
150
+ return stdin !== null && stdin.trim() !== "" ? { raw: stdin } : {};
151
+ }
152
+
153
+ /**
154
+ * The result is the only stdout; anything else goes to stderr, so a script reads the result as it
155
+ * is. Each kind of answer has its exit code, so the agent never has to guess whether it ran.
156
+ */
157
+ async function runTool(
158
+ endpoint: string,
159
+ session: string | undefined,
160
+ args: readonly string[],
161
+ stdin: string | null,
162
+ submit: Submit,
163
+ ): Promise<CliOutcome> {
164
+ const [tool, ...words] = args;
165
+ if (!tool?.trim() || tool.startsWith("--")) {
166
+ return usageError("wfa tool needs the name of the tool to call");
167
+ }
168
+ let response: ControlResponse;
169
+ try {
170
+ response = await submit(
171
+ endpoint,
172
+ {
173
+ version: WIRE_VERSION,
174
+ command: "tool",
175
+ tool,
176
+ ...givenInput(words, stdin),
177
+ ...(session ? { session } : {}),
178
+ },
179
+ // The engine answers once the call settles, within its budget; it has no other limit here.
180
+ null,
181
+ );
182
+ } catch (error) {
183
+ const reason = error instanceof Error ? error.message : String(error);
184
+ return error instanceof NotSentError
185
+ ? {
186
+ exitCode: 3,
187
+ stdout: "",
188
+ stderr: `wfa: the call of ${tool} didn't reach the workflow, so it didn't run.\n${reason}`,
189
+ }
190
+ : {
191
+ exitCode: 4,
192
+ stdout: "",
193
+ stderr: `wfa: the answer to the call of ${tool} was lost (${reason}); it may have run, so check before calling it again.`,
194
+ };
195
+ }
196
+ const owing = response.owing ? `wfa: ${response.owing}\n` : "";
197
+ switch (response.kind) {
198
+ case "tool-result":
199
+ return { exitCode: 0, stdout: response.output ?? "", stderr: owing.trimEnd() };
200
+ case "tool-failed":
201
+ return {
202
+ exitCode: response.outcome === "failed" ? 1 : 4,
203
+ stdout: "",
204
+ stderr: `${owing}wfa: ${response.error}`,
205
+ };
206
+ case "rejected":
207
+ return {
208
+ // The code says nothing of whether the handler ran: its end's record may be what failed.
209
+ exitCode: response.code === "internal-error" ? 4 : 3,
210
+ stdout: "",
211
+ stderr: `${owing}wfa: ${tool} refused (${response.code}): ${response.error}`,
212
+ };
213
+ default:
214
+ return {
215
+ exitCode: 4,
216
+ stdout: "",
217
+ stderr: `wfa: an unexpected answer to the call of ${tool}; it may have run`,
218
+ };
219
+ }
220
+ }
221
+
222
+ async function runWaiting(
223
+ endpoint: string,
224
+ session: string | undefined,
225
+ args: readonly string[],
226
+ submit: Submit,
227
+ said: Said,
228
+ ): Promise<CliOutcome> {
229
+ const [operationId, ...flags] = args;
230
+ if (!operationId?.trim() || operationId.startsWith("--"))
231
+ return usageError("wfa waiting needs the call id it is waiting on");
232
+ const values = new Map<string, string>();
233
+ for (let i = 0; i < flags.length; i += 2) {
234
+ const flag = flags[i]!;
235
+ const value = flags[i + 1];
236
+ if ((flag !== "--reason" && flag !== "--timeout") || values.has(flag) || value === undefined)
237
+ return usageError("wfa waiting accepts --reason once and optional --timeout once");
238
+ values.set(flag, value);
239
+ }
240
+ const reason = values.get("--reason");
241
+ if (!validWaitingReason(reason))
242
+ return usageError("wfa waiting --reason must be non-blank and at most 2048 UTF-8 bytes");
243
+ const duration = values.get("--timeout");
244
+ let timeoutMs: number | undefined;
245
+ if (duration !== undefined) {
246
+ const read = durationOf(duration);
247
+ if (!read) return usageError(`wfa waiting --timeout ${DURATION_GRAMMAR}`);
248
+ timeoutMs = read.milliseconds;
249
+ said.notice = read.notice;
250
+ }
251
+ let response: ControlResponse;
252
+ try {
253
+ response = await submit(endpoint, {
254
+ version: WIRE_VERSION,
255
+ command: "waiting",
256
+ operationId,
257
+ reason,
258
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
259
+ ...(session ? { session } : {}),
260
+ });
261
+ } catch (error) {
262
+ const detail = error instanceof Error ? error.message : String(error);
263
+ return {
264
+ exitCode: 1,
265
+ stdout: "",
266
+ stderr: `wfa: waiting acknowledgement is uncertain; the engine may have granted the wait. No retry was sent.\n${detail}`,
267
+ };
268
+ }
269
+ if (response.kind === "rejected")
270
+ return refused(
271
+ response,
272
+ response.code === "owes-reply" || response.code === "no-result"
273
+ ? `wfa: waiting refused (${response.code}): ${response.error}`
274
+ : `wfa: waiting rejected (${response.code}).\n${response.error}`,
275
+ );
276
+ if (response.kind === "awaiting-reply") return succeeded(response, `wfa: ${response.notice}`);
277
+ if (response.kind !== "waiting")
278
+ return {
279
+ exitCode: 1,
280
+ stdout: "",
281
+ stderr: "wfa: unexpected acknowledgement for waiting; wait is uncertain",
282
+ };
283
+ return succeeded(
284
+ response,
285
+ response.deadline === undefined
286
+ ? `wfa: waiting granted until ${response.waitUntil}; the operation has no deadline (Unix milliseconds)`
287
+ : `wfa: waiting granted until ${response.waitUntil}; operation deadline ${response.deadline} (Unix milliseconds)`,
288
+ );
289
+ }
290
+
291
+ async function runPeers(
292
+ endpoint: string,
293
+ session: string | undefined,
294
+ args: readonly string[],
295
+ submit: Submit,
296
+ ): Promise<CliOutcome> {
297
+ if (args.length > 1 || (args.length === 1 && args[0] !== "--json"))
298
+ return usageError("wfa peers takes only --json");
299
+ let response: ControlResponse;
300
+ try {
301
+ response = await submit(endpoint, {
302
+ version: WIRE_VERSION,
303
+ command: "peers",
304
+ ...(session ? { session } : {}),
305
+ });
306
+ } catch (error) {
307
+ const detail = error instanceof Error ? error.message : String(error);
308
+ return { exitCode: 1, stdout: "", stderr: `wfa: peers failed; nothing changed.\n${detail}` };
309
+ }
310
+ if (response.kind === "rejected")
311
+ return refused(response, `wfa: peers refused (${response.code}): ${response.error}`);
312
+ if (response.kind !== "peers")
313
+ return { exitCode: 1, stdout: "", stderr: "wfa: unexpected acknowledgement for peers" };
314
+ // The table says what is owed itself, so the owed-answer line does not lead it.
315
+ if (args[0] === "--json") {
316
+ const { peers, owes, awaits } = response;
317
+ return {
318
+ exitCode: 0,
319
+ stdout: JSON.stringify({ peers, owes, ...(awaits ? { awaits } : {}) }),
320
+ stderr: "",
321
+ };
322
+ }
323
+ return { exitCode: 0, stdout: response.text, stderr: "" };
324
+ }
325
+
326
+ async function runMessage(
327
+ endpoint: string,
328
+ session: string | undefined,
329
+ command: "send" | "reply",
330
+ args: readonly string[],
331
+ stdin: string | null,
332
+ wfa: string,
333
+ submit: Submit,
334
+ said: Said,
335
+ ): Promise<CliOutcome> {
336
+ const parsed = messageArgs(args);
337
+ const wrong = (problem: string) =>
338
+ usageError(`wfa: ${command}: ${problem}\n\n${messageUsage(wfa).join("\n")}`);
339
+ if ("error" in parsed) return wrong(parsed.error);
340
+ const { peer, message, awaitReply, timeoutMs } = parsed;
341
+ said.notice = parsed.notice;
342
+ // A heredoc ends with a newline the agent did not mean as part of the message.
343
+ const text = message ?? stdin?.replace(/\r?\n$/, "") ?? null;
344
+ if (text === null || text.trim() === "") return wrong("it needs a message");
345
+ const request: MessageRequest = {
346
+ version: WIRE_VERSION,
347
+ command,
348
+ peer,
349
+ text,
350
+ ...(awaitReply ? { awaitReply: true as const } : {}),
351
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
352
+ ...(session ? { session } : {}),
353
+ };
354
+ let response: ControlResponse;
355
+ try {
356
+ response = await submit(endpoint, request);
357
+ } catch (error) {
358
+ const detail = error instanceof Error ? error.message : String(error);
359
+ const what = command === "send" ? "message" : "reply";
360
+ // Refused at the connection, nothing reached the engine; after, it may have been admitted.
361
+ if (error instanceof NotSentError) {
362
+ return {
363
+ exitCode: 1,
364
+ stdout: "",
365
+ stderr: `wfa: the ${what} was not sent: the engine could not be reached. Try again.\n${detail}`,
366
+ };
367
+ }
368
+ return {
369
+ exitCode: 1,
370
+ stdout: "",
371
+ stderr: `wfa: the engine did not confirm this ${what}, so it may have been queued. Nothing was retried: do not send it again.\n${detail}`,
372
+ };
373
+ }
374
+ if (response.kind === "rejected")
375
+ return refused(response, `wfa: ${command} refused (${response.code}): ${response.error}`);
376
+ if (response.kind !== "sent")
377
+ return { exitCode: 1, stdout: "", stderr: `wfa: unexpected acknowledgement for ${command}` };
378
+ return succeeded(response, `wfa: ${response.notice}`);
379
+ }
380
+
381
+ /**
382
+ * Flags, the peer and the message if it is an argument. Flags may come before the peer or right
383
+ * after it, as agents type them; one after the message would otherwise go as its text.
384
+ */
385
+ function messageArgs(
386
+ args: readonly string[],
387
+ ):
388
+ | { peer: string; message?: string; awaitReply: boolean; timeoutMs?: number; notice?: string }
389
+ | { error: string } {
390
+ let awaitReply = false;
391
+ let timeoutMs: number | undefined;
392
+ let notice: string | undefined;
393
+ const positional: string[] = [];
394
+ for (let i = 0; i < args.length; i += 1) {
395
+ const arg = args[i]!;
396
+ if (!arg.startsWith("--")) {
397
+ positional.push(arg);
398
+ continue;
399
+ }
400
+ if (positional.length > 1) return { error: "flags go before the message" };
401
+ if (arg === "--await-reply" && !awaitReply) awaitReply = true;
402
+ else if (arg === "--timeout" && timeoutMs === undefined) {
403
+ const read = durationOf(args[i + 1] ?? "");
404
+ if (!read) return { error: `--timeout ${DURATION_GRAMMAR}` };
405
+ ({ milliseconds: timeoutMs, notice } = read);
406
+ i += 1;
407
+ } else if (positional.length === 1 && arg !== "--await-reply" && arg !== "--timeout") {
408
+ return { error: `${arg} is not a flag; a message that starts with -- goes in a heredoc` };
409
+ } else return { error: `${arg} is not a flag it takes, or is given twice` };
410
+ }
411
+ if (timeoutMs !== undefined && !awaitReply) return { error: "--timeout needs --await-reply" };
412
+ const [peer, ...rest] = positional;
413
+ if (!peer) return { error: "it needs the peer to send to" };
414
+ if (rest.length > 1)
415
+ return { error: "the message is one argument; quote it, or use a heredoc or standard input" };
416
+ return {
417
+ peer,
418
+ ...(rest[0] === undefined ? {} : { message: rest[0] }),
419
+ awaitReply,
420
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
421
+ ...(notice === undefined ? {} : { notice }),
422
+ };
423
+ }
424
+
425
+ const DURATION_GRAMMAR = 'takes a duration, a decimal and s, m, h or d, such as "5m"';
426
+ const LEGACY_MS = /^([0-9]+)ms$/;
427
+
428
+ /**
429
+ * The one grammar's, longer than none. A whole number of `ms` is read until 0.1.0, with the line
430
+ * `wfa` says beside its outcome.
431
+ */
432
+ function durationOf(text: string): { milliseconds: number; notice?: string } | undefined {
433
+ const legacy = LEGACY_MS.exec(text);
434
+ let milliseconds: number;
435
+ try {
436
+ milliseconds = legacy ? Number(legacy[1]) : durationMilliseconds(text);
437
+ } catch {
438
+ return undefined;
439
+ }
440
+ if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) return undefined;
441
+ return legacy
442
+ ? {
443
+ milliseconds,
444
+ notice: `wfa: --timeout ${text} is deprecated; give ${milliseconds / 1000}s: a duration is a decimal and s, m, h or d`,
445
+ }
446
+ : { milliseconds };
447
+ }
448
+
449
+ /** What is owed leads, so it comes back after a harness compacts; then the acknowledgement. */
450
+ function succeeded(response: { owing?: string }, line: string): CliOutcome {
451
+ return {
452
+ exitCode: 0,
453
+ stdout: response.owing ? `wfa: ${response.owing}\n${line}` : line,
454
+ stderr: "",
455
+ };
456
+ }
457
+
458
+ function refused(response: { owing?: string }, line: string): CliOutcome {
459
+ return {
460
+ exitCode: 1,
461
+ stdout: "",
462
+ stderr: response.owing ? `wfa: ${response.owing}\n${line}` : line,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * An agent reads this and decides whether to try again, so each case has to say what it can do
468
+ * about it. E5 measured what that is worth: a rejection carrying the reason was corrected in 2.00
469
+ * attempts, a bare refusal in 2.90 to 4.95.
470
+ */
471
+ function rejection(
472
+ response: Extract<ControlResponse, { kind: "rejected" }>,
473
+ operationId: string,
474
+ ): string {
475
+ switch (response.code) {
476
+ case "invalid-result":
477
+ return `result rejected.\n${response.error}\nFix the value and run wfa result again.`;
478
+ case "wrong-agent":
479
+ return `${operationId} is another agent's call, not yours to answer`;
480
+ case "unknown-operation":
481
+ // Correctable, unlike the two below: the id is wrong, not the call gone. Transposing the id
482
+ // and the JSON lands here, and telling the agent to give up would end a live turn.
483
+ return (
484
+ `no call named ${operationId} is open. ` +
485
+ "Check the id in the request and run wfa result again."
486
+ );
487
+ case "expired-operation":
488
+ case "closed-operation":
489
+ return "that call is no longer open; the workflow engine must start a new one";
490
+ // Messaging's: the engine names the peer and the next step.
491
+ case "owes-reply":
492
+ case "no-result":
493
+ case "tool-running":
494
+ return `result refused (${response.code}): ${response.error}`;
495
+ default:
496
+ // Everything else — too large, malformed, wrong version — is only distinguishable by what
497
+ // the engine said, and whether a shorter or different value would be taken.
498
+ return `result submission was rejected.\n${response.error}`;
499
+ }
500
+ }
501
+
502
+ /**
503
+ * The launcher supplies `--at <socket>`, then `--as <its own path>`, then `--session <id>`
504
+ * expanded from the harness's own variable, which is empty when the agent's shell does not set it.
505
+ */
506
+ function launched(argv: readonly string[]): {
507
+ endpoint?: string;
508
+ session?: string;
509
+ launcher?: string;
510
+ command: readonly string[];
511
+ } {
512
+ const [flag, endpoint, ...rest] = argv;
513
+ if (flag !== "--at" || !endpoint) return { command: rest };
514
+ let command = rest;
515
+ let launcher: string | undefined;
516
+ if (command[0] === "--as") {
517
+ launcher = command[1]?.trim() || undefined;
518
+ command = command.slice(2);
519
+ }
520
+ const named = launcher ? { launcher } : {};
521
+ if (command[0] !== "--session") return { endpoint, ...named, command };
522
+ const session = command[1]?.trim();
523
+ return { endpoint, ...named, ...(session ? { session } : {}), command: command.slice(2) };
524
+ }
525
+
526
+ function usageError(stderr: string): CliOutcome {
527
+ return { exitCode: 2, stdout: "", stderr };
528
+ }
529
+
530
+ if (import.meta.main) {
531
+ let stdinText: string | null;
532
+ try {
533
+ stdinText = await readCliStdin(
534
+ process.argv.slice(2),
535
+ process.stdin.isTTY === true,
536
+ Bun.stdin.stream(),
537
+ );
538
+ } catch (error) {
539
+ console.error(`wfa: ${error instanceof Error ? error.message : String(error)}`);
540
+ process.exit(2);
541
+ }
542
+ const outcome = await runCli(process.argv.slice(2), stdinText);
543
+ if (outcome.stdout) console.log(outcome.stdout);
544
+ if (outcome.stderr) console.error(outcome.stderr);
545
+ process.exit(outcome.exitCode);
546
+ }
547
+
548
+ /**
549
+ * Standard input, for the commands that take it: `result <call-id>` or `tool <name>` and nothing
550
+ * more, or `send` and `reply` with no message argument after the peer.
551
+ */
552
+ export async function readCliStdin(
553
+ argv: readonly string[],
554
+ isTTY: boolean,
555
+ stream: ReadableStream<Uint8Array>,
556
+ ): Promise<string | null> {
557
+ if (isTTY) return null;
558
+ const [verb, ...args] = launched(argv).command;
559
+ const parsed = verb === "send" || verb === "reply" ? messageArgs(args) : undefined;
560
+ const reads =
561
+ ((verb === "result" || verb === "tool") && args.length === 1) ||
562
+ (parsed !== undefined && !("error" in parsed) && parsed.message === undefined);
563
+ if (!reads) return null;
564
+ const text = await readBoundedStdin(stream);
565
+ return text === "" ? null : text;
566
+ }
567
+
568
+ /**
569
+ * Bounded in time as well as size. Everything else on this path has a deadline — the client waits
570
+ * 30s, the control plane 30s — and a tool runner that hands the child an inherited pipe instead of
571
+ * `/dev/null` would otherwise stall the agent's whole turn on a read that never ends.
572
+ */
573
+ export async function readBoundedStdin(
574
+ stream: ReadableStream<Uint8Array>,
575
+ maxBytes = MAX_STDIN_BYTES,
576
+ timeoutMs = STDIN_TIMEOUT_MS,
577
+ ): Promise<string> {
578
+ const reader = stream.getReader();
579
+ const chunks: Uint8Array[] = [];
580
+ let bytes = 0;
581
+ let expire!: () => void;
582
+ const expired = new Promise<"expired">((resolve) => {
583
+ expire = () => resolve("expired");
584
+ });
585
+ const expiry = setTimeout(expire, timeoutMs);
586
+ try {
587
+ for (;;) {
588
+ const next = await Promise.race([reader.read(), expired]);
589
+ if (next === "expired") {
590
+ await reader.cancel().catch(() => undefined);
591
+ throw new Error("stdin was not closed in time");
592
+ }
593
+ if (next.done) break;
594
+ bytes += next.value.byteLength;
595
+ if (bytes > maxBytes) {
596
+ await reader.cancel().catch(() => undefined);
597
+ throw new Error("stdin exceeds the size limit");
598
+ }
599
+ chunks.push(next.value);
600
+ }
601
+ } finally {
602
+ clearTimeout(expiry);
603
+ }
604
+ const joined = new Uint8Array(bytes);
605
+ let offset = 0;
606
+ for (const chunk of chunks) {
607
+ joined.set(chunk, offset);
608
+ offset += chunk.byteLength;
609
+ }
610
+ return new TextDecoder().decode(joined);
611
+ }