@agentswf/engine 0.1.0-beta.4 → 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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/agent-cli/cli.ts +57 -17
  3. package/src/attempt-ending.ts +2 -4
  4. package/src/attempt-output.ts +2 -1
  5. package/src/attempt-view.ts +1 -1
  6. package/src/control-plane.ts +37 -5
  7. package/src/coordinator-socket.ts +15 -6
  8. package/src/deadlines.ts +69 -2
  9. package/src/decisions/directory.ts +9 -11
  10. package/src/deprecations.ts +177 -0
  11. package/src/duration.ts +50 -20
  12. package/src/emit-command.ts +55 -41
  13. package/src/emit.ts +124 -15
  14. package/src/help/emit.txt +12 -4
  15. package/src/help/inbox.txt +2 -2
  16. package/src/help/logs.txt +5 -4
  17. package/src/help/reply.txt +5 -5
  18. package/src/help/run.txt +21 -7
  19. package/src/herdr-run-session.ts +2 -2
  20. package/src/holder-command.ts +36 -31
  21. package/src/inbox-command.ts +7 -10
  22. package/src/init-template/hello/workflow.test.ts.template +3 -3
  23. package/src/init-template/hello/workflow.ts.template +2 -2
  24. package/src/keep-alive.ts +28 -13
  25. package/src/logs-command.ts +12 -12
  26. package/src/mailbox.ts +2 -20
  27. package/src/messaging/limits.ts +4 -4
  28. package/src/messaging/messaging.ts +26 -21
  29. package/src/messaging/routes.ts +1 -1
  30. package/src/messaging/text.ts +7 -5
  31. package/src/operator-cli.ts +23 -9
  32. package/src/operator-runtime.ts +5 -5
  33. package/src/pane-layout.ts +125 -32
  34. package/src/progress-view.ts +8 -8
  35. package/src/run-command.ts +25 -4
  36. package/src/run-commands.ts +1 -1
  37. package/src/run-prepare.ts +46 -8
  38. package/src/run-usage.ts +1 -1
  39. package/src/run-view.ts +9 -4
  40. package/src/runs-command.ts +12 -10
  41. package/src/runs.ts +44 -8
  42. package/src/sources.ts +42 -6
  43. package/src/stage-ledger.ts +42 -12
  44. package/src/stage-plan.ts +37 -1
  45. package/src/test-command.ts +7 -3
  46. package/src/testing.ts +7 -8
  47. package/src/tools.ts +46 -18
  48. package/src/workflow-loader.ts +5 -2
  49. package/src/workflow-runner.ts +553 -235
  50. package/src/workflow-testing/coordinator.typecheck.ts +7 -7
  51. package/src/workflow-testing/coordinators.ts +2 -2
  52. package/src/workflow-testing/host.ts +107 -38
  53. package/src/workflow-testing/index.ts +45 -836
  54. package/src/workflow-testing/run-test.ts +1213 -0
  55. package/src/workflow-testing/script.ts +102 -41
  56. package/src/workflow-testing/workflow-testing.typecheck.ts +11 -8
@@ -2,7 +2,8 @@ import { homedir } from "node:os";
2
2
  import type { ParticipantResultJson } from "@agentswf/contract/records";
3
3
  import { type ParticipantCode, WIRE_VERSION } from "@agentswf/contract/wire";
4
4
  import { liveCoordinatorSocket } from "./coordinator-socket";
5
- import { deliver, eventIdProblem } from "./emit";
5
+ import { deprecationLine } from "./deprecations";
6
+ import { deliverReply, emitToRun, eventIdProblem } from "./emit";
6
7
  import { printEmit } from "./emit-command";
7
8
  import { messageOf } from "./errors";
8
9
  import { isCode } from "./files";
@@ -71,7 +72,7 @@ export async function holderCommand(
71
72
  );
72
73
  return result.code === "delivered" ? 0 : 1;
73
74
  };
74
- const refused = (code: ParticipantCode, error: string) => say({ v: 1, code, error }, "");
75
+ const refused = (code: ParticipantCode, error: string) => say({ v: 2, code, error }, "");
75
76
  const usage = (problem: string) => {
76
77
  io.stderr(`awf: ${problem}\n\n${replyUsage}`);
77
78
  return 2;
@@ -122,7 +123,7 @@ export async function holderCommand(
122
123
  }
123
124
  if (!answer)
124
125
  throw new Error(
125
- `${status.ref} did not answer the request; it may run another version of awf`,
126
+ `${status.ref} did not answer, most likely as it runs an older awf than this one, which speaks wire version ${WIRE_VERSION}: reach it with the awf it started with, or let it end and continue it under this one.`,
126
127
  );
127
128
  const result = JSON.parse(answer) as ParticipantResultJson;
128
129
  if (result.code !== "delivered") return say(result, "");
@@ -178,11 +179,12 @@ function parseHolder(command: "reply" | "send", rest: readonly string[]): Option
178
179
  let agent: string | undefined;
179
180
  [ref, agent, text, ...extra] = words;
180
181
  if (author !== undefined) throw new Error("--author goes with --event-id");
181
- if (!ref || !agent)
182
+ if (!ref || !agent?.trim())
182
183
  throw new Error(`${command} needs a run, <workflow>/<id> or <id>, and an agent`);
183
184
  target = { agent, ...(as === undefined ? {} : { as }) };
184
185
  }
185
186
  if (extra.length > 0) throw new Error("the text is one argument: quote it, or pipe it in");
187
+ if (as !== undefined && as.trim() === "") throw new Error("--as needs a participant's key");
186
188
  if (to !== undefined && !/^\d+\.\d+$/.test(to))
187
189
  throw new Error(`--to takes a question's id as awf inbox prints it, such as 1.5, not ${to}`);
188
190
  return {
@@ -221,9 +223,11 @@ async function awaitedBy(
221
223
  }
222
224
 
223
225
  /**
224
- * `awf send {ref} --as {participant} --event-id {message id} {text}`: a reply on a participant the run
225
- * routed to the code, delivered through the one way in as `participant:reply`, so it continues a
226
- * parked run. The run is found here, or in a project awf started an event run in (Q15).
226
+ * `awf send {ref} --as {participant} --event-id {message id} {text}`, deprecated: `awf emit {ref}
227
+ * {participant}:reply`. It keeps its own refusals and exit codes for the beta: a participant the
228
+ * run never connected is refused; one the run recorded as routed to the code, declared or by the
229
+ * deprecated `to: "code"`, gets the reply as before, the workflow's file not loaded; any other is
230
+ * `awf emit`'s to check against the definition.
227
231
  */
228
232
  async function toTheCode(
229
233
  options: Extract<Options, { id: string }>,
@@ -236,6 +240,7 @@ async function toTheCode(
236
240
  const environment = io.environment ?? process.env;
237
241
  const { id, as } = options;
238
242
  const { refused } = say;
243
+ io.stderr(deprecationLine("send-event-id"));
239
244
  let found: RunStatus;
240
245
  try {
241
246
  found = await findRunByRef(options.ref, place, home);
@@ -243,37 +248,37 @@ async function toTheCode(
243
248
  if (error instanceof RunNotFound) return refused("not-found", error.message);
244
249
  return failed(error, place, io, messageOf(error));
245
250
  }
246
- const { run } = found;
247
- const routed = (await readEvents(run.dir)).some(
248
- (line) =>
249
- line.kind === "participant-connected" && line.participant === as && line.to === "code",
251
+ const connected = (await readEvents(found.run.dir)).filter(
252
+ (line) => line.kind === "participant-connected" && line.participant === as,
250
253
  );
251
- if (!run.record.events || !routed)
254
+ // As it always did: only a participant the run has connected; `awf emit` doesn't ask.
255
+ if (!found.run.record.events || connected.length === 0)
252
256
  return refused(
253
257
  "no-participant",
254
- `${found.ref} has no participant ${as} routed to its code; --event-id is for one`,
258
+ `${found.ref} has no participant ${as} connected to its code; --event-id is for one`,
255
259
  );
256
260
  const text = options.text ?? (await io.stdin());
257
261
  if (text.trim() === "") return say.usage("nothing to say: stdin was empty");
262
+ const data = { text, ...(options.author === undefined ? {} : { author: options.author }) };
263
+ const context = { home, environment };
264
+ // Recorded as routed to the code, by either form: delivered as before, the file not loaded.
265
+ if (connected.some((line) => line.kind === "participant-connected" && line.to)) {
266
+ try {
267
+ return printEmit(await deliverReply(found, as, { data, id }, context), json, io);
268
+ } catch (error) {
269
+ return failed(error, place, io, messageOf(error));
270
+ }
271
+ }
272
+ // Connected to agents only, which send has always refused: whatever stops `awf emit`, a file
273
+ // that won't load included, is that refusal, with its reason.
274
+ let result: Awaited<ReturnType<typeof emitToRun>>;
258
275
  try {
259
- const result = await deliver(
260
- {
261
- runRoot: run.root,
262
- workflow: run.record.workflow,
263
- runId: run.record.id,
264
- event: {
265
- id,
266
- name: "participant:reply",
267
- data: { text, ...(options.author === undefined ? {} : { author: options.author }) },
268
- from: as,
269
- at: new Date().toISOString(),
270
- },
271
- deduplicate: true,
272
- },
273
- { home, environment },
274
- );
275
- return printEmit(result, json, io);
276
+ result = await emitToRun(found, { name: `${as}:reply`, data, id }, context);
276
277
  } catch (error) {
277
- return failed(error, place, io, messageOf(error));
278
+ return refused(
279
+ "no-participant",
280
+ `${found.ref} has no participant ${as} whose messages go to its code: ${messageOf(error)}`,
281
+ );
278
282
  }
283
+ return printEmit(result, json, io);
279
284
  }
@@ -11,7 +11,7 @@ import type {
11
11
  import type { InboxCode } from "@agentswf/contract/wire";
12
12
  import type { JsonObject } from "@agentswf/contract/workflow";
13
13
  import { maskCredentials } from "@agentswf/harness";
14
- import { duration } from "./duration";
14
+ import { formatDuration, parseWait } from "./duration";
15
15
  import { messageOf } from "./errors";
16
16
  import { exists } from "./files";
17
17
  import help from "./help/inbox.txt" with { type: "text" };
@@ -100,7 +100,7 @@ export async function inboxCommand(
100
100
  const parsed = parsePlace(argv, shellCwd);
101
101
  place = parsed.place;
102
102
  json = parsed.json;
103
- options = parseInbox(parsed.rest);
103
+ options = parseInbox(parsed.rest, (line) => out.stderr(line));
104
104
  } catch (error) {
105
105
  out.stderr(`awf: ${messageOf(error)}\n\n${inboxUsage}`);
106
106
  return 2;
@@ -198,7 +198,7 @@ export async function inboxCommand(
198
198
  }
199
199
  }
200
200
 
201
- function parseInbox(rest: readonly string[]): Options {
201
+ function parseInbox(rest: readonly string[], deprecated: (line: string) => void): Options {
202
202
  const options: Options = { all: false, open: false, waitSeconds: 0 };
203
203
  for (let index = 0; index < rest.length; index += 1) {
204
204
  const arg = rest[index]!;
@@ -212,12 +212,9 @@ function parseInbox(rest: readonly string[]): Options {
212
212
  else if (arg === "--workflow") options.workflow = value();
213
213
  else if (arg === "--as") options.as = value();
214
214
  else if (arg === "--after") options.after = value();
215
- else if (arg === "--wait") {
216
- const seconds = Number(value());
217
- if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS)
218
- throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
219
- options.waitSeconds = seconds;
220
- } else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
215
+ else if (arg === "--wait")
216
+ options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
217
+ else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
221
218
  else if (options.ref === undefined) options.ref = arg;
222
219
  else throw new Error(`one run at a time: ${options.ref} and ${arg}`);
223
220
  }
@@ -658,7 +655,7 @@ function textOf(item: InboxItemJson, all: boolean): string {
658
655
  ? `${item.agent} → ${item.participant}`
659
656
  : `${item.participant} → ${item.agent}`;
660
657
  return item.state === "opened"
661
- ? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${duration(item.timeoutMs)}`}`
658
+ ? `${head}route ${arrow} opened “${item.purpose}”${item.timeoutMs === undefined ? "" : ` ${formatDuration(item.timeoutMs)}`}`
662
659
  : `${head}route ${arrow} closed ${item.reason}`;
663
660
  }
664
661
  case "message": {
@@ -1,9 +1,9 @@
1
1
  import { expect, test } from "bun:test";
2
- import { answer, reply, testWorkflow } from "agentswf/testing";
2
+ import { answer, noAnswer, testWorkflow } from "agentswf/testing";
3
3
  import hello, { ANSWER } from "./workflow";
4
4
 
5
5
  test("returns the agent's answer", async () => {
6
- const run = await testWorkflow(hello, null, {
6
+ const run = await testWorkflow(hello, {
7
7
  agents: { hello: answer(ANSWER, { answer: 391 }) },
8
8
  });
9
9
  expect(run.value).toEqual({ answer: 391 });
@@ -11,7 +11,7 @@ test("returns the agent's answer", async () => {
11
11
  });
12
12
 
13
13
  test("an agent that never answers is nudged once, then the result says why", async () => {
14
- const run = await testWorkflow(hello, null, { agents: { hello: reply.silent() } });
14
+ const run = await testWorkflow(hello, { agents: { hello: noAnswer.silent() } });
15
15
  expect(run.value).toHaveProperty("unanswered");
16
16
  expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
17
17
  });
@@ -7,7 +7,7 @@ export default defineWorkflow({
7
7
  meta: { name: "hello", description: "Ask one agent a question with a known answer." },
8
8
  async run(wf) {
9
9
  // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
10
- // account has: "claude" in a pane, the default placement, runs on a subscription.
10
+ // account has: "claude" in its TUI, the default placement, runs on a subscription.
11
11
  const agent = await wf.agents.open({
12
12
  key: "hello",
13
13
  runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
@@ -15,7 +15,7 @@ export default defineWorkflow({
15
15
  const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
16
16
  prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
17
17
  schema: ANSWER,
18
- timeoutMs: 3 * 60_000,
18
+ timeout: "3m",
19
19
  });
20
20
  return outcome.answered ? outcome.value : { unanswered: outcome.reason };
21
21
  },
package/src/keep-alive.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
- import type { EventAgentOpenSpec } from "@agentswf/contract/workflow";
2
+ import { type AgentOpenSpec, positiveDurationMilliseconds } from "@agentswf/contract/workflow";
3
3
 
4
- export type KeepAlive = NonNullable<EventAgentOpenSpec["keepAlive"]>;
4
+ /** `keepAlive` as the engine keeps it: its window in milliseconds. */
5
+ export type KeepAlive = {
6
+ windowMs?: number;
7
+ onTimeout?: NonNullable<AgentOpenSpec["keepAlive"]>["onTimeout"];
8
+ };
5
9
 
6
10
  /** Claude's prompt cache lives an hour for the main conversation on a subscription. */
7
11
  export const DEFAULT_WINDOW_MS = 60 * 60_000;
@@ -20,21 +24,32 @@ export function asTimeoutHandler<T>(fn: () => T): T {
20
24
  return inHandler.run(true, fn);
21
25
  }
22
26
 
23
- /** `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can. */
24
- export function checkedKeepAlive(key: string, given: unknown): KeepAlive {
27
+ /**
28
+ * `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can.
29
+ * Its deprecated `timeoutMs` is reported, and refused beside `window`.
30
+ */
31
+ export function checkedKeepAlive(key: string, given: unknown, deprecated: () => void): KeepAlive {
25
32
  if (typeof given !== "object" || given === null)
26
- throw new Error(`agent ${key}: keepAlive is { timeoutMs?, onTimeout? }`);
27
- const { timeoutMs, onTimeout } = given as Record<string, unknown>;
28
- if (
29
- timeoutMs !== undefined &&
30
- (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
31
- )
32
- throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
33
+ throw new Error(`agent ${key}: keepAlive is { window?, onTimeout? }`);
34
+ const { window, timeoutMs, onTimeout } = given as Record<string, unknown>;
35
+ let windowMs: number | undefined;
36
+ if (window != null) {
37
+ if (timeoutMs != null)
38
+ throw new Error(
39
+ `agent ${key}: keepAlive's window is given alone: timeoutMs is its deprecated form`,
40
+ );
41
+ windowMs = positiveDurationMilliseconds(window, `agent ${key}: keepAlive's window`);
42
+ } else if (timeoutMs != null) {
43
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
44
+ throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
45
+ deprecated();
46
+ windowMs = timeoutMs;
47
+ }
33
48
  if (onTimeout !== undefined && typeof onTimeout !== "function")
34
49
  throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
35
50
  // A copy: a later change to the caller's object would skip this check.
36
51
  return {
37
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
52
+ ...(windowMs === undefined ? {} : { windowMs }),
38
53
  ...(onTimeout === undefined ? {} : { onTimeout: onTimeout as KeepAlive["onTimeout"] }),
39
- } as KeepAlive;
54
+ };
40
55
  }
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import type { LogEventJson, LogsJson, RouteEnds, RunLogLine } from "@agentswf/contract/records";
3
3
  import type { JsonObject } from "@agentswf/contract/workflow";
4
- import { duration } from "./duration";
4
+ import { formatDuration, parseWait } from "./duration";
5
5
  import { messageOf } from "./errors";
6
6
  import help from "./help/logs.txt" with { type: "text" };
7
7
  import {
@@ -85,7 +85,7 @@ export async function logsCommand(
85
85
  const parsed = parsePlace(argv, shellCwd);
86
86
  place = parsed.place;
87
87
  json = parsed.json;
88
- options = parseLogs(parsed.rest);
88
+ options = parseLogs(parsed.rest, (line) => out.stderr(line));
89
89
  if (options.follow && json) throw new Error("--follow is for reading; --json takes --wait");
90
90
  } catch (error) {
91
91
  out.stderr(`awf: ${messageOf(error)}\n\n${logsUsage}`);
@@ -122,7 +122,7 @@ export async function logsCommand(
122
122
  }
123
123
  }
124
124
 
125
- function parseLogs(rest: readonly string[]): LogsOptions {
125
+ function parseLogs(rest: readonly string[], deprecated: (line: string) => void): LogsOptions {
126
126
  const options: LogsOptions = {
127
127
  limit: DEFAULT_LIMIT,
128
128
  waitSeconds: 0,
@@ -147,11 +147,7 @@ function parseLogs(rest: readonly string[]): LogsOptions {
147
147
  throw new Error("--limit needs a whole number");
148
148
  options.limit = limit;
149
149
  } else if (arg === "--wait") {
150
- const seconds = Number(value());
151
- if (!Number.isFinite(seconds) || seconds < 0 || seconds > MAX_WAIT_SECONDS) {
152
- throw new Error(`--wait needs seconds from 0 to ${MAX_WAIT_SECONDS}`);
153
- }
154
- options.waitSeconds = seconds;
150
+ options.waitSeconds = parseWait(value(), MAX_WAIT_SECONDS, deprecated);
155
151
  waitGiven = true;
156
152
  } else if (arg === "--follow") options.follow = true;
157
153
  else if (arg === "--full") options.full = true;
@@ -511,7 +507,7 @@ function trailer(
511
507
  return `run interrupted${go ? ` · continue: ${go}` : ""}`;
512
508
  }
513
509
  const { ended, started } = span(status, Date.now());
514
- const took = ended === undefined ? "" : ` in ${duration(ended - started)}`;
510
+ const took = ended === undefined ? "" : ` in ${formatDuration(ended - started)}`;
515
511
  return `run ended: ${status.state}${took} · result: ${shellLine(command(place, "awf", "runs", status.ref))}`;
516
512
  }
517
513
 
@@ -569,7 +565,7 @@ function describeLine(line: RunLogLine, places: Places): string {
569
565
  case "compact": {
570
566
  const took =
571
567
  line.deliveredAt && line.settledAt
572
- ? ` ${duration(Date.parse(line.settledAt) - Date.parse(line.deliveredAt))}`
568
+ ? ` ${formatDuration(Date.parse(line.settledAt) - Date.parse(line.deliveredAt))}`
573
569
  : "";
574
570
  return `${operation(line.kind)} ended ${line.outcome}${took}`;
575
571
  }
@@ -591,7 +587,9 @@ function describeLine(line: RunLogLine, places: Places): string {
591
587
  return `decision ${line.decision} ${line.outcome}${line.error ? ` ${said(line.error)}` : ""}`;
592
588
  case "route-opened":
593
589
  return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages${
594
- line.timeoutMs === undefined ? "" : `, a question closes after ${duration(line.timeoutMs)}`
590
+ line.timeoutMs === undefined
591
+ ? ""
592
+ : `, a question closes after ${formatDuration(line.timeoutMs)}`
595
593
  }`;
596
594
  case "route-closed":
597
595
  return `route ${routeEnds(line)} closed ${line.reason}`;
@@ -640,7 +638,9 @@ function describeLine(line: RunLogLine, places: Places): string {
640
638
  line.tool,
641
639
  places.calls.get(line.call)?.input,
642
640
  // Most calls take under a second; a duration says something only past one.
643
- line.durationMs < 1000 ? line.outcome : `${line.outcome} ${duration(line.durationMs)}`,
641
+ line.durationMs < 1000
642
+ ? line.outcome
643
+ : `${line.outcome} ${formatDuration(line.durationMs)}`,
644
644
  answered,
645
645
  );
646
646
  }
package/src/mailbox.ts CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  AWF_ID_PREFIX,
12
12
  type JsonValue,
13
13
  type OperationRecord,
14
- type ReceiveOptions,
15
14
  type TurnOutcome,
16
15
  type WorkflowEvent,
17
16
  } from "@agentswf/contract/workflow";
@@ -627,12 +626,11 @@ export class RunMailbox {
627
626
  * nothing.
628
627
  */
629
628
  async receive(
630
- options: ReceiveOptions | undefined,
629
+ label: string | undefined,
631
630
  stage?: string,
632
631
  /** Rejects the wait: the run ended, or the scope it waits in was cancelled. */
633
632
  signal?: AbortSignal,
634
633
  ): Promise<WorkflowEvent<string, JsonValue>[]> {
635
- const { label, take } = checked(options);
636
634
  if (stage !== undefined) {
637
635
  const batch = await this.batchOf(stage);
638
636
  if (batch) return batch;
@@ -645,7 +643,7 @@ export class RunMailbox {
645
643
  return this.#wait(
646
644
  {
647
645
  ...(label === undefined ? {} : { label }),
648
- take,
646
+ take: "all",
649
647
  ...(stage === undefined ? {} : { stage }),
650
648
  },
651
649
  signal,
@@ -972,19 +970,3 @@ async function mailboxSize(runDir: string): Promise<number> {
972
970
  () => 0,
973
971
  );
974
972
  }
975
-
976
- /** A wait's options as given, checked: what a typed caller can't get wrong, a plain one can. */
977
- function checked(options: ReceiveOptions | undefined): {
978
- label?: string;
979
- take: "all" | "oldest";
980
- } {
981
- if (options !== undefined && (typeof options !== "object" || options === null))
982
- throw new Error("receive takes { label?, take? }");
983
- const { label, take = "all" } = options ?? {};
984
- if (label !== undefined && typeof label !== "string")
985
- throw new Error("receive's label is a string");
986
- if (take !== "all" && take !== "oldest") throw new Error('receive\'s take is "all" or "oldest"');
987
- if ("until" in (options ?? {}))
988
- throw new Error("receive has no until: a timer, wf.timer(name, when), wakes it");
989
- return { ...(label === undefined ? {} : { label }), take };
990
- }
@@ -12,14 +12,14 @@ export const MESSAGE_LIMITS: MessageLimits = {
12
12
 
13
13
  /**
14
14
  * Where an asker may wait for an answer, by harness and placement: on where M2 passed
15
- * (`docs/findings/messaging-m2.md`), off elsewhere. A pane that is `confirmed` waits only where it
15
+ * (`docs/findings/messaging-m2.md`), off elsewhere. A TUI that is `confirmed` waits only where it
16
16
  * confirms what it was sent, as `wfa waiting` needs. The fake harness always may. cursor is off:
17
17
  * its turns ran past their deadline in M2.
18
18
  */
19
- export const AWAIT_REPLY: Readonly<Record<string, { headless?: true; pane?: true | "confirmed" }>> =
19
+ export const AWAIT_REPLY: Readonly<Record<string, { headless?: true; tui?: true | "confirmed" }>> =
20
20
  {
21
- fake: { headless: true, pane: true },
22
- claude: { headless: true, pane: "confirmed" },
21
+ fake: { headless: true, tui: true },
22
+ claude: { headless: true, tui: "confirmed" },
23
23
  codex: { headless: true },
24
24
  pi: { headless: true },
25
25
  };
@@ -9,7 +9,7 @@ import type {
9
9
  import type { ParticipantCode, PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
10
10
  import type { AgentKey, JsonObject } from "@agentswf/contract/workflow";
11
11
  import type { AuthoredMessage } from "@agentswf/harness/adapter";
12
- import { duration } from "../duration";
12
+ import { formatDuration } from "../duration";
13
13
  import { MESSAGE_LIMITS } from "./limits";
14
14
  import {
15
15
  directions,
@@ -180,7 +180,7 @@ type Obligation = {
180
180
  /** It has a `--timeout` of its own, which only that fails it at. */
181
181
  readonly timed: boolean;
182
182
  /**
183
- * When it closes unanswered, on an outside participant: the route's `timeoutMs` or the asker's
183
+ * When it closes unanswered: the route's `timeoutMs` or the asker's
184
184
  * shorter `--timeout`, and which of the two.
185
185
  */
186
186
  readonly closes?: { at: number; by: "route" | "own"; ms: number };
@@ -279,15 +279,13 @@ export class Messaging {
279
279
  }
280
280
  }
281
281
  const timeoutMs = options.timeoutMs;
282
- if (timeoutMs !== undefined) {
283
- if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
284
- throw new Error("a route's timeoutMs is a positive whole number of milliseconds");
285
- // Only where an agent can ask an outside participant: nothing else needs it yet.
286
- if (!pairs.some(([, to]) => this.#outside.has(to)))
287
- throw new Error(
288
- "timeoutMs is only for a route an agent asks an outside participant over: between them, or to it",
289
- );
290
- }
282
+ if (
283
+ timeoutMs !== undefined &&
284
+ (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
285
+ )
286
+ throw new Error(
287
+ `a route's timeoutMs is a positive whole number of milliseconds: not ${timeoutMs}`,
288
+ );
291
289
  const open = pairs.map(([from, to]) => this.#open(from, to));
292
290
  if (open.some(Boolean)) {
293
291
  if (
@@ -361,7 +359,7 @@ export class Messaging {
361
359
  });
362
360
  }
363
361
 
364
- /** Writes `text` to a participant routed to the code, without a turn: an inbox item for it. */
362
+ /** Writes `text` to a participant, without a turn: an inbox item for it. */
365
363
  async post(key: AgentKey, text: string): Promise<string> {
366
364
  if (this.#closed) throw new Error("the attempt has ended; nothing more is posted");
367
365
  if (!this.#outside.has(key)) throw new Error(`${key} is not connected`);
@@ -506,7 +504,7 @@ export class Messaging {
506
504
  return holderRefusal("no-route", `the workflow allows no route from ${from} to ${agent}.`);
507
505
  if (view.state === "stopped")
508
506
  return holderRefusal(
509
- "stopped",
507
+ "peer-stopped",
510
508
  `${agent} has stopped${view.reason ? ` (${JSON.stringify(view.reason)})` : ""}.`,
511
509
  );
512
510
  const route = initiating.find((each) => each.open);
@@ -942,18 +940,20 @@ export class Messaging {
942
940
  return { kind: "ok", operation, owed: latest, bytes };
943
941
  }
944
942
 
945
- /** `awaiting`, `unsupported` and `cycle`, checked for any question. */
943
+ /** `already-awaiting`, `unsupported` and `cycle`, checked for any question. */
946
944
  #checkAsk(
947
945
  from: AgentKey,
948
946
  peer: AgentKey,
949
947
  operation: Operation,
950
948
  /** The question a reply answers, which counts as answered here. */
951
949
  answering?: Obligation,
952
- ): { kind: "refused"; code: "awaiting" | "unsupported" | "cycle"; error: string } | undefined {
950
+ ):
951
+ | { kind: "refused"; code: "already-awaiting" | "unsupported" | "cycle"; error: string }
952
+ | undefined {
953
953
  const wfa = this.#host.launcher(from);
954
954
  if (operation.awaits?.state === "open") {
955
955
  return refuse(
956
- "awaiting",
956
+ "already-awaiting",
957
957
  `you already await ${operation.awaits.owedBy}. End your turn, or send without --await-reply.`,
958
958
  );
959
959
  }
@@ -1045,7 +1045,7 @@ export class Messaging {
1045
1045
  #ask(message: Message, operation: Operation, timeoutMs?: number): { until?: number } {
1046
1046
  message.route.count += 1;
1047
1047
  const now = this.#host.now();
1048
- const routeMs = this.#outside.has(message.to) ? message.route.timeoutMs : undefined;
1048
+ const routeMs = message.route.timeoutMs;
1049
1049
  // The route's limit, or the asker's own when shorter: the shorter wins.
1050
1050
  const limit =
1051
1051
  routeMs !== undefined && (timeoutMs === undefined || routeMs <= timeoutMs)
@@ -1069,7 +1069,7 @@ export class Messaging {
1069
1069
  owedBy: message.to,
1070
1070
  ...(until === undefined ? {} : { until }),
1071
1071
  timed: limitAt < (outside ? Infinity : (operation.deadline ?? Infinity)),
1072
- ...(limit && outside ? { closes: { ...limit, at: limitAt } } : {}),
1072
+ ...(limit ? { closes: { ...limit, at: limitAt } } : {}),
1073
1073
  state: "open",
1074
1074
  };
1075
1075
  message.asks = obligation;
@@ -1085,7 +1085,7 @@ export class Messaging {
1085
1085
  obligation,
1086
1086
  "timed-out",
1087
1087
  obligation.closes?.by === "route"
1088
- ? `the question closed unanswered after ${duration(obligation.closes.ms)}`
1088
+ ? `the question closed unanswered after ${formatDuration(obligation.closes.ms)}`
1089
1089
  : `your --timeout passed at ${clock(closesAt, this.#host.now())}`,
1090
1090
  );
1091
1091
  }
@@ -1375,7 +1375,7 @@ export class Messaging {
1375
1375
  return {
1376
1376
  peer,
1377
1377
  can: this.#can(route, agent),
1378
- state: "outside",
1378
+ kind: "participant",
1379
1379
  queued: 0,
1380
1380
  purpose: route.purpose,
1381
1381
  };
@@ -1388,8 +1388,13 @@ export class Messaging {
1388
1388
  return {
1389
1389
  peer,
1390
1390
  can: this.#can(route, agent),
1391
+ kind: "agent",
1391
1392
  state:
1392
- view?.state === "stopped" ? "stopped" : awaiting ? "awaiting" : (view?.state ?? "idle"),
1393
+ view?.state === "stopped"
1394
+ ? "stopped"
1395
+ : awaiting
1396
+ ? "awaiting-reply"
1397
+ : (view?.state ?? "idle"),
1393
1398
  ...(awaiting && view?.state !== "stopped" ? { awaits: awaiting } : {}),
1394
1399
  ...(view?.state === "stopped" && view.reason ? { reason: view.reason } : {}),
1395
1400
  queued,
@@ -21,7 +21,7 @@ export type Route = {
21
21
  readonly asked?: number;
22
22
  /** As the workflow gave it, for telling an identical grant from a conflicting one. */
23
23
  readonly requested?: number;
24
- /** On a route an agent asks an outside participant over: how long a question on it waits. */
24
+ /** How long a question on the route waits, to an agent or an outside participant. */
25
25
  readonly timeoutMs?: number;
26
26
  readonly scope: Scope;
27
27
  /** Messages counted against it, answers reserved for included. */
@@ -228,11 +228,13 @@ export function peersTable(
228
228
  const rows = peers.map((peer) => [
229
229
  peer.peer,
230
230
  peer.can,
231
- peer.state === "awaiting"
232
- ? `awaiting ${peer.awaits === viewer ? "you" : peer.awaits}`
233
- : peer.state === "stopped" && peer.reason
234
- ? `stopped (${peer.reason})`
235
- : peer.state,
231
+ peer.kind === "participant"
232
+ ? "participant"
233
+ : peer.state === "awaiting-reply"
234
+ ? `awaiting-reply from ${peer.awaits === viewer ? "you" : peer.awaits}`
235
+ : peer.state === "stopped" && peer.reason
236
+ ? `stopped (${peer.reason})`
237
+ : peer.state,
236
238
  peer.queued > 0 ? String(peer.queued) : "",
237
239
  quoted(peer.purpose),
238
240
  ]);