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

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 (68) hide show
  1. package/package.json +5 -5
  2. package/src/allowance-command.ts +29 -7
  3. package/src/attempt-ending.ts +23 -5
  4. package/src/attempt-output.ts +48 -18
  5. package/src/attempt-view.ts +25 -15
  6. package/src/commands.ts +176 -0
  7. package/src/control-plane.ts +72 -21
  8. package/src/coordinator-port.ts +29 -0
  9. package/src/coordinator-prompt.ts +237 -0
  10. package/src/coordinator-socket.ts +236 -0
  11. package/src/deadlines.ts +47 -10
  12. package/src/decisions/directory.ts +8 -6
  13. package/src/duration.ts +29 -0
  14. package/src/emit-command.ts +130 -0
  15. package/src/emit.ts +246 -0
  16. package/src/files.ts +11 -1
  17. package/src/here.ts +8 -2
  18. package/src/hold-run-lock.fixture.ts +8 -0
  19. package/src/holder-command.ts +297 -0
  20. package/src/inbox-command.ts +727 -0
  21. package/src/init-command.ts +27 -0
  22. package/src/init-template/AGENTS.md.template +1 -1
  23. package/src/init-template/hello/workflow.test.ts.template +2 -2
  24. package/src/init-template/hello/workflow.ts.template +16 -20
  25. package/src/keep-alive.ts +40 -0
  26. package/src/logs-command.ts +104 -20
  27. package/src/machine.ts +12 -1
  28. package/src/mailbox.ts +977 -0
  29. package/src/message-operation.ts +26 -3
  30. package/src/messaging/messaging.ts +458 -65
  31. package/src/messaging/routes.ts +10 -3
  32. package/src/messaging/text.ts +8 -5
  33. package/src/operation-liveness.ts +54 -8
  34. package/src/operator-cli.ts +290 -119
  35. package/src/operator-runtime.ts +5 -2
  36. package/src/progress-view.ts +49 -7
  37. package/src/projects.ts +57 -0
  38. package/src/result-slots.ts +43 -19
  39. package/src/result-validation.ts +18 -27
  40. package/src/run-command.ts +36 -32
  41. package/src/run-commands.ts +13 -2
  42. package/src/run-lock.ts +123 -0
  43. package/src/run-prepare.ts +94 -22
  44. package/src/run-state.ts +125 -6
  45. package/src/run-usage.ts +1 -1
  46. package/src/run-view.ts +0 -9
  47. package/src/runs-command.ts +46 -17
  48. package/src/runs.ts +142 -23
  49. package/src/sandboxes.ts +8 -2
  50. package/src/simple-input.ts +258 -0
  51. package/src/socket-exchange.ts +26 -0
  52. package/src/sources.ts +70 -0
  53. package/src/stage-ledger.ts +88 -13
  54. package/src/stage-plan.ts +23 -0
  55. package/src/stop-crashed.ts +39 -0
  56. package/src/test-command.ts +32 -0
  57. package/src/testing.ts +71 -3
  58. package/src/tick-command.ts +87 -0
  59. package/src/tools.ts +636 -0
  60. package/src/wake.ts +264 -0
  61. package/src/workflow-loader.ts +32 -22
  62. package/src/workflow-runner.ts +1210 -77
  63. package/src/workflow-testing/coordinator.typecheck.ts +55 -0
  64. package/src/workflow-testing/coordinators.ts +137 -0
  65. package/src/workflow-testing/host.ts +61 -1
  66. package/src/workflow-testing/index.ts +488 -33
  67. package/src/workflow-testing/script.ts +72 -2
  68. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -1,13 +1,15 @@
1
1
  import type {
2
2
  MessageLimits,
3
3
  MessagingEvent,
4
+ OutsideVia,
4
5
  RequestEnd,
5
6
  RouteEnds,
6
7
  UndeliveredReason,
7
8
  } from "@agentswf/contract/records";
8
- import type { PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
9
- import type { AgentKey } from "@agentswf/contract/workflow";
9
+ import type { ParticipantCode, PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
10
+ import type { AgentKey, JsonObject } from "@agentswf/contract/workflow";
10
11
  import type { AuthoredMessage } from "@agentswf/harness/adapter";
12
+ import { duration } from "../duration";
11
13
  import { MESSAGE_LIMITS } from "./limits";
12
14
  import {
13
15
  directions,
@@ -65,10 +67,10 @@ export type OperationInfo = {
65
67
  agent: AgentKey;
66
68
  /** A workflow's own `run`, or one a message opened. */
67
69
  kind: "workflow" | "message";
68
- /** Unix milliseconds; a message operation's may move later. */
69
- deadline: number;
70
- /** The latest its deadline may move to: the run's. */
71
- ceiling: number;
70
+ /** Unix milliseconds, absent when nothing bounds it; a message operation's may move later. */
71
+ deadline?: number;
72
+ /** The latest its deadline may move to: the deadlines around it, absent when there are none. */
73
+ ceiling?: number;
72
74
  /** The scope it runs in: its route's for a message operation, the stage's for a `run`. */
73
75
  scope: Scope;
74
76
  };
@@ -100,6 +102,31 @@ export type Delivery = {
100
102
  deadline?: number;
101
103
  };
102
104
 
105
+ /**
106
+ * What a holder asks for, as `awf reply` and `awf send` do: `coordinator` is `--as`, `to` the
107
+ * question's number within the attempt.
108
+ */
109
+ export type HolderRequest = {
110
+ coordinator?: AgentKey;
111
+ agent: AgentKey;
112
+ to?: number;
113
+ text: string;
114
+ via: OutsideVia;
115
+ };
116
+
117
+ /** A holder's result: a reply's `message` is the question's number, a send's the new message's. */
118
+ export type HolderResult =
119
+ | { code: "delivered"; message: number }
120
+ | { code: Exclude<ParticipantCode, "not-found">; error: string; choices?: string[] };
121
+
122
+ /** A question open to an outside participant, and when it closes unanswered if it has a limit. */
123
+ export type OpenQuestion = {
124
+ coordinator: AgentKey;
125
+ message: number;
126
+ asker: AgentKey;
127
+ closesAt?: number;
128
+ };
129
+
103
130
  /** What is held for an operation whose turn has ended, without deciding anything. */
104
131
  export type Pending = "continues" | "held" | "none";
105
132
 
@@ -107,8 +134,11 @@ export type Pending = "continues" | "held" | "none";
107
134
  export type TurnEnd =
108
135
  /** Something continues it now: an answer, a failure, a question, or held messages. */
109
136
  | { kind: "continue" }
110
- /** It awaits `peer`'s answer until `until`: nothing runs, a pane stays, a headless process exits. */
111
- | { kind: "wait"; peer: AgentKey; until: number }
137
+ /**
138
+ * It awaits `peer`'s answer, until `until` if the wait has a limit: nothing runs, a pane stays, a
139
+ * headless process exits.
140
+ */
141
+ | { kind: "wait"; peer: AgentKey; until?: number }
112
142
  /** It owes answers: the one reminder, as its next prompt. */
113
143
  | { kind: "remind"; text: string }
114
144
  /** A workflow's operation with nothing owed or held: 021's check-in. */
@@ -142,10 +172,18 @@ type Obligation = {
142
172
  owingOperation?: Operation;
143
173
  /** Its owing operation was reminded of it once; the next turn's end that still owes fails it. */
144
174
  reminded?: true;
145
- /** The asker waits until then: its `--timeout`, or its operation's deadline as that moves. */
146
- until: number;
175
+ /**
176
+ * The asker waits until then: its `--timeout`, or its operation's deadline as that moves; absent
177
+ * when nothing limits it. On an outside participant, only its limit: the deadline does not count.
178
+ */
179
+ until?: number;
147
180
  /** It has a `--timeout` of its own, which only that fails it at. */
148
181
  readonly timed: boolean;
182
+ /**
183
+ * When it closes unanswered, on an outside participant: the route's `timeoutMs` or the asker's
184
+ * shorter `--timeout`, and which of the two.
185
+ */
186
+ readonly closes?: { at: number; by: "route" | "own"; ms: number };
149
187
  state: "open" | RequestEnd;
150
188
  /** Its end, set once; a reply after it is refused `expired`. */
151
189
  endedAt?: number;
@@ -155,6 +193,9 @@ type Obligation = {
155
193
  type Failure = { kind: "no-reply"; for: Operation; from: AgentKey; reason: string };
156
194
  type Held = Message | Failure;
157
195
 
196
+ /** A participant outside the run, which the workflow connected: it has no operation and no inbox. */
197
+ type Outside = { readonly key: AgentKey; released: boolean };
198
+
158
199
  type Operation = OperationInfo & {
159
200
  awaits?: Obligation;
160
201
  readonly owes: Set<Obligation>;
@@ -184,6 +225,8 @@ export class Messaging {
184
225
  readonly #obligations: Obligation[] = [];
185
226
  /** Every message admitted, by its number. */
186
227
  readonly #sentBy = new Map<number, Message>();
228
+ /** Outside participants connected in the attempt, released ones included, whose keys stay taken. */
229
+ readonly #outside = new Map<AgentKey, Outside>();
187
230
  #closed = false;
188
231
 
189
232
  constructor(host: MessagingHost, limits: MessageLimits = MESSAGE_LIMITS) {
@@ -201,7 +244,10 @@ export class Messaging {
201
244
  * Grants a route in `scope`, atomically: anything invalid rejects with nothing granted. Granting
202
245
  * the same route again does nothing; another route over an open pair rejects.
203
246
  */
204
- grant(ends: RouteEnds, options: { purpose: unknown; maxMessages?: unknown; scope: Scope }): void {
247
+ grant(
248
+ ends: RouteEnds,
249
+ options: { purpose: unknown; maxMessages?: unknown; timeoutMs?: unknown; scope: Scope },
250
+ ): void {
205
251
  const { scope } = options;
206
252
  if (this.#closed || scope.state !== "open")
207
253
  throw new Error("routes are granted only while a scope is open");
@@ -217,7 +263,14 @@ export class Messaging {
217
263
  const pairs = directions(ends);
218
264
  for (const [from, to] of pairs) {
219
265
  if (from === to) throw new Error(`a route from ${from} to itself is not allowed`);
266
+ if (this.#outside.has(from) && this.#outside.has(to))
267
+ throw new Error(`${from} and ${to} are both outside the run: nothing would run`);
220
268
  for (const key of [from, to]) {
269
+ const outside = this.#outside.get(key);
270
+ if (outside) {
271
+ if (outside.released) throw new Error(`${key} has been released`);
272
+ continue;
273
+ }
221
274
  const agent = this.#host.agent(key);
222
275
  if (!agent) throw new Error(`${key} is not an agent this workflow opened`);
223
276
  if (agent.caller)
@@ -225,12 +278,30 @@ export class Messaging {
225
278
  if (agent.state === "stopped") throw new Error(`${key} has stopped`);
226
279
  }
227
280
  }
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
+ }
228
291
  const open = pairs.map(([from, to]) => this.#open(from, to));
229
292
  if (open.some(Boolean)) {
230
293
  if (
231
294
  open.every(
232
295
  (route) =>
233
- route && sameGrant(route, ends, purpose, requested as number | undefined, scope),
296
+ route &&
297
+ sameGrant(
298
+ route,
299
+ ends,
300
+ purpose,
301
+ requested as number | undefined,
302
+ timeoutMs as number | undefined,
303
+ scope,
304
+ ),
234
305
  ) &&
235
306
  new Set(open).size === 1
236
307
  )
@@ -248,6 +319,7 @@ export class Messaging {
248
319
  maxMessages,
249
320
  ...(asked !== undefined && asked > maxMessages ? { asked } : {}),
250
321
  ...(asked === undefined ? {} : { requested: asked }),
322
+ ...(timeoutMs === undefined ? {} : { timeoutMs: timeoutMs as number }),
251
323
  scope,
252
324
  count: 0,
253
325
  open: true,
@@ -263,9 +335,242 @@ export class Messaging {
263
335
  purpose,
264
336
  maxMessages,
265
337
  ...(route.asked === undefined ? {} : { asked: route.asked }),
338
+ ...(route.timeoutMs === undefined ? {} : { timeoutMs: route.timeoutMs }),
266
339
  ...(scope.stage === undefined ? {} : { stage: scope.stage }),
267
340
  });
268
- for (const agent of new Set(pairs.flat())) this.#host.changed(agent);
341
+ for (const agent of new Set(pairs.flat())) {
342
+ if (!this.#outside.has(agent)) this.#host.changed(agent);
343
+ }
344
+ }
345
+
346
+ // Outside participants
347
+
348
+ /**
349
+ * Connects a participant outside the run under `key`, which the runner has checked no agent
350
+ * holds and is not reserved. A key connected before in the attempt, released or not, rejects.
351
+ */
352
+ connect(key: AgentKey, labels?: JsonObject, to?: "code"): void {
353
+ if (this.#closed) throw new Error("participants connect only while the attempt runs");
354
+ if (this.#outside.has(key)) throw new Error(`${key} is already connected`);
355
+ this.#outside.set(key, { key, released: false });
356
+ void this.#record({
357
+ kind: "participant-connected",
358
+ participant: key,
359
+ ...(labels === undefined ? {} : { labels }),
360
+ ...(to === undefined ? {} : { to }),
361
+ });
362
+ }
363
+
364
+ /** Writes `text` to a participant routed to the code, without a turn: an inbox item for it. */
365
+ async post(key: AgentKey, text: string): Promise<string> {
366
+ if (this.#closed) throw new Error("the attempt has ended; nothing more is posted");
367
+ if (!this.#outside.has(key)) throw new Error(`${key} is not connected`);
368
+ if (this.#outside.get(key)?.released)
369
+ throw new Error(`${key} was released; nothing is posted to it`);
370
+ // Numbered within the attempt; `awf inbox` puts the attempt before it.
371
+ this.#posts += 1;
372
+ const id = `p${this.#posts}`;
373
+ await this.#host.record({ kind: "participant-post", participant: key, id, text });
374
+ return id;
375
+ }
376
+ #posts = 0;
377
+
378
+ /** Whether `key` is an outside participant's, connected in this attempt. */
379
+ isOutside(key: AgentKey): boolean {
380
+ return this.#outside.has(key);
381
+ }
382
+
383
+ /** Outside participants connected and not released, in the order connected. */
384
+ connected(): AgentKey[] {
385
+ return [...this.#outside.values()].filter((each) => !each.released).map((each) => each.key);
386
+ }
387
+
388
+ /**
389
+ * Releases an outside participant: its routes close and every question open to it ends
390
+ * `released`, its asker told. False when it was released already.
391
+ */
392
+ release(key: AgentKey, reason?: string): boolean {
393
+ const outside = this.#outside.get(key);
394
+ if (!outside || outside.released) return false;
395
+ outside.released = true;
396
+ const why = reason ? ` (${JSON.stringify(reason)})` : "";
397
+ for (const owed of this.#obligations) {
398
+ if (owed.owedBy === key && owed.state === "open") {
399
+ this.#end(owed, "released", `it was released${why} before replying`, { tell: true });
400
+ }
401
+ }
402
+ for (const route of this.#openRoutes()) {
403
+ if (directions(route.ends).some((pair) => pair.includes(key)))
404
+ this.#closeRoute(route, "released");
405
+ }
406
+ return true;
407
+ }
408
+
409
+ /** Questions open to outside participants now, oldest first. */
410
+ openQuestions(): OpenQuestion[] {
411
+ return this.#obligations
412
+ .filter((owed) => owed.state === "open" && this.#outside.has(owed.owedBy))
413
+ .map((owed) => ({
414
+ coordinator: owed.owedBy,
415
+ message: owed.request.n,
416
+ asker: owed.asker,
417
+ ...(owed.closes ? { closesAt: owed.closes.at } : {}),
418
+ }));
419
+ }
420
+
421
+ /**
422
+ * A holder answers a question, as `awf reply` does: the first answer wins. Refusals come in
423
+ * `PARTICIPANT_CODES`' order, a question's own state before its route's.
424
+ */
425
+ async holderReply(request: HolderRequest): Promise<HolderResult> {
426
+ if (this.#closed) return holderRefusal("ended", "the run has ended.");
427
+ const named =
428
+ request.to === undefined || request.coordinator !== undefined
429
+ ? undefined
430
+ : this.#obligations.find(
431
+ (owed) => owed.request.n === request.to && this.#outside.has(owed.owedBy),
432
+ )?.owedBy;
433
+ const as = this.#holderAs(request.coordinator ?? named);
434
+ if (as.kind === "refused") return as.result;
435
+ const { agent } = request;
436
+ if (!this.#host.agent(agent)) return unknownAgent(agent);
437
+ const asked = (coordinator: AgentKey) =>
438
+ this.#obligations.filter(
439
+ (owed) => owed.asker === agent && owed.owedBy === coordinator && owed.state === "open",
440
+ );
441
+ let owed: Obligation | undefined;
442
+ if (request.to === undefined) {
443
+ if (as.key === undefined) return as.ambiguous();
444
+ const open = asked(as.key);
445
+ if (open.length > 1)
446
+ return holderRefusal(
447
+ "ambiguous",
448
+ `${agent} has ${open.length} questions open to ${as.key}; name one with --to.`,
449
+ open.map((each) => String(each.request.n)),
450
+ );
451
+ owed = open[0];
452
+ if (!owed) return holderRefusal("expired", `${agent} has no question open to ${as.key}.`);
453
+ } else {
454
+ owed = this.#obligations.find(
455
+ (each) =>
456
+ each.request.n === request.to &&
457
+ each.asker === agent &&
458
+ each.state === "open" &&
459
+ (as.key === undefined ? this.#outside.has(each.owedBy) : each.owedBy === as.key),
460
+ );
461
+ if (!owed)
462
+ return holderRefusal(
463
+ "expired",
464
+ `${request.to} is not a question open from ${agent}${as.key === undefined ? "" : ` to ${as.key}`}.`,
465
+ );
466
+ }
467
+ const route = owed.request.route;
468
+ if (!route.open) return holderRefusal("closed", `the route with ${agent} has closed.`);
469
+ const bytes = Buffer.byteLength(request.text);
470
+ if (bytes > this.limits.bytes) return tooLarge(bytes, this.limits.bytes);
471
+ owed.state = "replied";
472
+ owed.endedAt = this.#host.now();
473
+ owed.cancelTimer?.();
474
+ if (owed.askerOperation.awaits === owed) owed.askerOperation.awaits = undefined;
475
+ const message = this.#admit(owed.owedBy, agent, request.text, bytes, route, owed);
476
+ const sent = this.#sent(message, request.via);
477
+ void this.#record({
478
+ kind: "request-ended",
479
+ message: owed.request.n,
480
+ owedBy: owed.owedBy,
481
+ asker: owed.asker,
482
+ end: "replied",
483
+ reply: message.n,
484
+ });
485
+ try {
486
+ await sent;
487
+ } finally {
488
+ this.#host.changed(agent);
489
+ }
490
+ return { code: "delivered", message: owed.request.n };
491
+ }
492
+
493
+ /** A holder messages an agent first, as `awf send` does, over a route the workflow allowed. */
494
+ async holderSend(request: HolderRequest): Promise<HolderResult> {
495
+ if (this.#closed || this.attempt.state !== "open")
496
+ return holderRefusal("ended", "the workflow has returned; it takes no new messages.");
497
+ const as = this.#holderAs(request.coordinator);
498
+ if (as.kind === "refused") return as.result;
499
+ const { agent } = request;
500
+ const view = this.#host.agent(agent);
501
+ if (!view) return unknownAgent(agent);
502
+ if (as.key === undefined) return as.ambiguous();
503
+ const from = as.key;
504
+ const initiating = this.#routes.get(pairKey(from, agent)) ?? [];
505
+ if (initiating.length === 0)
506
+ return holderRefusal("no-route", `the workflow allows no route from ${from} to ${agent}.`);
507
+ if (view.state === "stopped")
508
+ return holderRefusal(
509
+ "stopped",
510
+ `${agent} has stopped${view.reason ? ` (${JSON.stringify(view.reason)})` : ""}.`,
511
+ );
512
+ const route = initiating.find((each) => each.open);
513
+ if (route?.scope.state !== "open")
514
+ return holderRefusal(
515
+ "closed",
516
+ `the route to ${agent} has closed${route?.scope.stage ? `: its stage ${route.scope.stage} is finishing` : ""}.`,
517
+ );
518
+ const bytes = Buffer.byteLength(request.text);
519
+ if (bytes > this.limits.bytes) return tooLarge(bytes, this.limits.bytes);
520
+ if (route.count + 1 > route.maxMessages)
521
+ return holderRefusal(
522
+ "limit",
523
+ `${route.maxMessages} messages with ${agent}, this route's limit for the attempt.`,
524
+ );
525
+ const unread = this.#unread(agent);
526
+ if (unread >= this.limits.unread)
527
+ return holderRefusal(
528
+ "inbox-full",
529
+ `${agent} has ${unread} unread messages; try again later.`,
530
+ );
531
+ const message = this.#admit(from, agent, request.text, bytes, route);
532
+ try {
533
+ await this.#sent(message, request.via);
534
+ } finally {
535
+ this.#host.changed(agent);
536
+ }
537
+ return { code: "delivered", message: message.n };
538
+ }
539
+
540
+ /**
541
+ * The outside participant a holder speaks as: `--as`, or the only one connected. Undefined when
542
+ * several are: `ambiguous` once the agent is known, unless a `--to` names the question.
543
+ */
544
+ #holderAs(
545
+ key: AgentKey | undefined,
546
+ ):
547
+ | { kind: "refused"; result: HolderResult }
548
+ | { kind: "ok"; key: AgentKey | undefined; ambiguous: () => HolderResult } {
549
+ const connected = this.connected();
550
+ const ambiguous = () =>
551
+ holderRefusal(
552
+ "ambiguous",
553
+ `the workflow connects ${connected.length} coordinators; say which with --as.`,
554
+ connected,
555
+ );
556
+ if (key !== undefined && !connected.includes(key))
557
+ return {
558
+ kind: "refused",
559
+ result: holderRefusal(
560
+ "no-participant",
561
+ `no coordinator ${key} is connected${this.#outside.has(key) ? ": it was released" : ""}.`,
562
+ ),
563
+ };
564
+ if (connected.length === 0)
565
+ return {
566
+ kind: "refused",
567
+ result: holderRefusal("no-participant", "the run has no coordinator connected."),
568
+ };
569
+ return {
570
+ kind: "ok",
571
+ key: key ?? (connected.length === 1 ? connected[0] : undefined),
572
+ ambiguous,
573
+ };
269
574
  }
270
575
 
271
576
  /**
@@ -330,13 +635,15 @@ export class Messaging {
330
635
  this.#current.set(info.agent, operation);
331
636
  }
332
637
 
333
- /** A message operation's deadline moved later, and with it any wait it has no `--timeout` for. */
638
+ /** An operation's deadline moved later, and with it any wait on an agent it has no `--timeout` for. */
334
639
  extended(id: string, deadline: number): void {
335
640
  const operation = this.#operations.get(id);
336
- if (!operation || deadline <= operation.deadline) return;
641
+ if (!operation || deadline <= (operation.deadline ?? Infinity)) return;
337
642
  operation.deadline = deadline;
338
643
  const waited = operation.awaits;
339
- if (waited?.state === "open" && !waited.timed) waited.until = deadline;
644
+ // A wait on an outside participant is not bounded by the deadline at all.
645
+ if (waited?.state === "open" && !waited.timed && !this.#outside.has(waited.owedBy))
646
+ waited.until = deadline;
340
647
  }
341
648
 
342
649
  /**
@@ -351,10 +658,15 @@ export class Messaging {
351
658
  const waited = operation.awaits;
352
659
  operation.awaits = undefined;
353
660
  if (waited?.state === "open") {
661
+ // On an outside participant, the asker timing out or being cancelled is `cancelled`; an
662
+ // attempt's end closes it `attempt-ended` before its operations stop.
663
+ const cut = this.#outside.has(waited.owedBy) && (how === "timed-out" || how === "cancelled");
354
664
  this.#end(
355
665
  waited,
356
- waited.request.shown ? "released" : "withdrawn",
357
- `${operation.agent} finished first`,
666
+ cut ? "cancelled" : waited.request.shown ? "released" : "withdrawn",
667
+ cut
668
+ ? `${operation.agent}'s operation ${how === "timed-out" ? "timed out" : "was cancelled"}`
669
+ : `${operation.agent} finished first`,
358
670
  );
359
671
  }
360
672
  // An answer accepted but not yet shown is withdrawn with it.
@@ -370,10 +682,18 @@ export class Messaging {
370
682
  }
371
683
  }
372
684
 
373
- /** The answer this operation awaits, while it does. */
374
- awaiting(id: string): { peer: AgentKey; until: number } | undefined {
685
+ /**
686
+ * The answer this operation awaits, while it does; `outside` when an outside participant owes it,
687
+ * whose wait its deadline does not count.
688
+ */
689
+ awaiting(id: string): { peer: AgentKey; until?: number; outside?: true } | undefined {
375
690
  const waited = this.#operations.get(id)?.awaits;
376
- return waited?.state === "open" ? { peer: waited.owedBy, until: waited.until } : undefined;
691
+ if (waited?.state !== "open") return undefined;
692
+ return {
693
+ peer: waited.owedBy,
694
+ ...(waited.until === undefined ? {} : { until: waited.until }),
695
+ ...(this.#outside.has(waited.owedBy) ? { outside: true as const } : {}),
696
+ };
377
697
  }
378
698
 
379
699
  /** The operation open on this agent, if any. */
@@ -391,7 +711,7 @@ export class Messaging {
391
711
  command: "result" | "waiting",
392
712
  ):
393
713
  | { kind: "refused"; code: "owes-reply" | "no-result"; error: string }
394
- | { kind: "awaiting"; peer: AgentKey; until: number; notice: string }
714
+ | { kind: "awaiting"; peer: AgentKey; until?: number; notice: string }
395
715
  | null {
396
716
  const operation = this.#operations.get(id);
397
717
  if (!operation || operation.agent !== agent) return null;
@@ -422,8 +742,8 @@ export class Messaging {
422
742
  return {
423
743
  kind: "awaiting",
424
744
  peer: waited.owedBy,
425
- until: waited.until,
426
- notice: `you already await ${waited.owedBy}'s reply, until ${clock(waited.until, this.#host.now())}. End your turn, or finish with ${wf} result ${id}.`,
745
+ ...(waited.until === undefined ? {} : { until: waited.until }),
746
+ notice: `you already await ${waited.owedBy}'s reply${waited.until === undefined ? "" : `, until ${clock(waited.until, this.#host.now())}`}. End your turn, or finish with ${wf} result ${id}.`,
427
747
  };
428
748
  }
429
749
  return null;
@@ -436,7 +756,7 @@ export class Messaging {
436
756
  if (checked.kind === "refused") return this.#refused(from, request, "send", checked);
437
757
  const { operation, route, bytes } = checked;
438
758
  const message = this.#admit(from, request.peer, request.text, bytes, route);
439
- const until = request.awaitReply ? this.#ask(message, operation, request.timeoutMs) : undefined;
759
+ const asked = request.awaitReply ? this.#ask(message, operation, request.timeoutMs) : undefined;
440
760
  // Admitted whatever the record says: its recipient is woken even if the write fails.
441
761
  try {
442
762
  await this.#sent(message, operation);
@@ -446,8 +766,8 @@ export class Messaging {
446
766
  return {
447
767
  kind: "sent",
448
768
  message: message.n,
449
- ...(until === undefined ? {} : { until }),
450
- notice: this.#sentNotice(request.peer, operation, until),
769
+ ...(asked?.until === undefined ? {} : { until: asked.until }),
770
+ notice: this.#sentNotice(request.peer, operation, asked),
451
771
  };
452
772
  }
453
773
 
@@ -462,7 +782,9 @@ export class Messaging {
462
782
  owed.owingOperation?.owes.delete(owed);
463
783
  if (owed.askerOperation.awaits === owed) owed.askerOperation.awaits = undefined;
464
784
  const message = this.#admit(from, owed.asker, request.text, bytes, owed.request.route, owed);
465
- const until = request.awaitReply ? this.#ask(message, operation, request.timeoutMs) : undefined;
785
+ const asking = request.awaitReply
786
+ ? this.#ask(message, operation, request.timeoutMs)
787
+ : undefined;
466
788
  // Its line first, then the question's end, in the log's order; the end is written either way.
467
789
  const sent = this.#sent(message, operation);
468
790
  void this.#record({
@@ -478,11 +800,11 @@ export class Messaging {
478
800
  } finally {
479
801
  this.#host.changed(owed.asker);
480
802
  }
481
- const asked = until === undefined ? "" : ` ${this.#awaitNotice(operation, until)}`;
803
+ const asked = asking ? ` ${this.#awaitNotice(operation, asking)}` : "";
482
804
  return {
483
805
  kind: "sent",
484
806
  message: message.n,
485
- ...(until === undefined ? {} : { until }),
807
+ ...(asking?.until === undefined ? {} : { until: asking.until }),
486
808
  notice: `replied to ${request.peer}.${asked}`,
487
809
  };
488
810
  }
@@ -513,7 +835,7 @@ export class Messaging {
513
835
  ? replyOnly()
514
836
  : refuse("no-route", `no route to ${peer}. Your routes: ${wf} peers`);
515
837
  }
516
- const agent = this.#host.agent(peer);
838
+ const agent = this.#outside.has(peer) ? undefined : this.#host.agent(peer);
517
839
  if (agent?.state === "stopped")
518
840
  return refuse(
519
841
  "peer-stopped",
@@ -712,65 +1034,93 @@ export class Messaging {
712
1034
  };
713
1035
  // An answer's room was reserved when it was asked.
714
1036
  if (!answers) route.count += 1;
715
- this.#inbox(to).push(message);
1037
+ // An outside participant reads the run log: a message to it is shown once recorded.
1038
+ if (this.#outside.has(to)) message.shown = true;
1039
+ else this.#inbox(to).push(message);
716
1040
  this.#sentBy.set(message.n, message);
717
1041
  return message;
718
1042
  }
719
1043
 
720
1044
  /** Makes `message` a question its recipient owes an answer to, and its sender's wait. */
721
- #ask(message: Message, operation: Operation, timeoutMs?: number): number {
1045
+ #ask(message: Message, operation: Operation, timeoutMs?: number): { until?: number } {
722
1046
  message.route.count += 1;
723
1047
  const now = this.#host.now();
724
- const until = Math.min(
725
- operation.deadline,
726
- timeoutMs === undefined ? Infinity : now + timeoutMs,
1048
+ const routeMs = this.#outside.has(message.to) ? message.route.timeoutMs : undefined;
1049
+ // The route's limit, or the asker's own when shorter: the shorter wins.
1050
+ const limit =
1051
+ routeMs !== undefined && (timeoutMs === undefined || routeMs <= timeoutMs)
1052
+ ? { by: "route" as const, ms: routeMs }
1053
+ : timeoutMs === undefined
1054
+ ? undefined
1055
+ : { by: "own" as const, ms: timeoutMs };
1056
+ const outside = this.#outside.has(message.to);
1057
+ const limitAt = limit ? now + limit.ms : Infinity;
1058
+ // Waiting on an outside participant is not work: its deadline does not cut the wait, though
1059
+ // the deadlines around it do.
1060
+ const waitEnd = Math.min(
1061
+ outside ? (operation.ceiling ?? Infinity) : (operation.deadline ?? Infinity),
1062
+ limitAt,
727
1063
  );
1064
+ const until = Number.isFinite(waitEnd) ? waitEnd : undefined;
728
1065
  const obligation: Obligation = {
729
1066
  request: message,
730
1067
  asker: message.from,
731
1068
  askerOperation: operation,
732
1069
  owedBy: message.to,
733
- until,
734
- timed: until < operation.deadline,
1070
+ ...(until === undefined ? {} : { until }),
1071
+ timed: limitAt < (outside ? Infinity : (operation.deadline ?? Infinity)),
1072
+ ...(limit && outside ? { closes: { ...limit, at: limitAt } } : {}),
735
1073
  state: "open",
736
1074
  };
737
1075
  message.asks = obligation;
738
1076
  operation.awaits = obligation;
739
1077
  this.#obligations.push(obligation);
740
1078
  // Only `--timeout` fails a question: reaching the operation's own deadline times it out.
741
- if (obligation.timed) {
742
- obligation.cancelTimer = this.#host.schedule(until, () => {
1079
+ // Only its limit closes it; a deadline ends the operation, and the wait with it.
1080
+ const closesAt = obligation.timed ? limitAt : undefined;
1081
+ if (closesAt !== undefined) {
1082
+ obligation.cancelTimer = this.#host.schedule(closesAt, () => {
743
1083
  if (obligation.state === "open") {
744
1084
  this.#end(
745
1085
  obligation,
746
1086
  "timed-out",
747
- `your --timeout passed at ${clock(until, this.#host.now())}`,
1087
+ obligation.closes?.by === "route"
1088
+ ? `the question closed unanswered after ${duration(obligation.closes.ms)}`
1089
+ : `your --timeout passed at ${clock(closesAt, this.#host.now())}`,
748
1090
  );
749
1091
  }
750
1092
  });
751
1093
  }
752
- return until;
1094
+ return until === undefined ? {} : { until };
753
1095
  }
754
1096
 
755
- /** Durable before `wf send` returns: a failed write rejects, and `wf` says it may have been queued. */
756
- async #sent(message: Message, operation: Operation): Promise<void> {
1097
+ /**
1098
+ * Durable before `wf send` returns: a failed write rejects, and `wf` says it may have been
1099
+ * queued. An outside participant's message says what it came through instead of an operation.
1100
+ * A question to one carries when it closes unanswered, if it has a limit.
1101
+ */
1102
+ async #sent(message: Message, by: Operation | OutsideVia): Promise<void> {
1103
+ const asks = message.asks;
1104
+ // To an outside participant, when it closes unanswered: its limit, not the asker's wait.
1105
+ const until = asks && this.#outside.has(asks.owedBy) ? asks.closes?.at : asks?.until;
757
1106
  await this.#host.record({
758
1107
  kind: "message-sent",
759
1108
  message: message.n,
760
1109
  from: message.from,
761
1110
  to: message.to,
762
- operationId: operation.id,
1111
+ ...(typeof by === "string" ? { via: by } : { operationId: by.id }),
763
1112
  text: message.text,
764
1113
  bytes: message.bytes,
765
- ...(message.asks
766
- ? { awaitsReply: { until: new Date(message.asks.until).toISOString() } }
1114
+ ...(asks
1115
+ ? { awaitsReply: until === undefined ? {} : { until: new Date(until).toISOString() } }
767
1116
  : {}),
768
1117
  ...(message.answers ? { replyTo: message.answers.request.n } : {}),
769
1118
  });
770
1119
  }
771
1120
 
772
- #sentNotice(peer: AgentKey, operation: Operation, until?: number): string {
773
- if (until !== undefined) return `sent to ${peer}. ${this.#awaitNotice(operation, until)}`;
1121
+ #sentNotice(peer: AgentKey, operation: Operation, asked?: { until?: number }): string {
1122
+ if (asked) return `sent to ${peer}. ${this.#awaitNotice(operation, asked)}`;
1123
+ if (this.#outside.has(peer)) return `sent to ${peer}, outside the run; it reads it from there.`;
774
1124
  const state = this.#host.agent(peer)?.state;
775
1125
  const when =
776
1126
  this.#current.get(peer)?.awaits?.state === "open"
@@ -783,10 +1133,10 @@ export class Messaging {
783
1133
  return `sent to ${peer}; ${when}.`;
784
1134
  }
785
1135
 
786
- #awaitNotice(operation: Operation, until: number): string {
1136
+ #awaitNotice(operation: Operation, { until }: { until?: number }): string {
787
1137
  const wf = this.#host.launcher(operation.agent);
788
1138
  return [
789
- `Its reply starts your next turn (deadline ${clock(until, this.#host.now())}).`,
1139
+ `Its reply starts your next turn (${until === undefined ? "no deadline" : `deadline ${clock(until, this.#host.now())}`}).`,
790
1140
  "Go on with work that does not need the answer, then end your turn with one line saying what you await; do not poll or sleep.",
791
1141
  ...(operation.kind === "workflow"
792
1142
  ? [
@@ -896,14 +1246,13 @@ export class Messaging {
896
1246
  */
897
1247
  #deadlineFor(operation: Operation): number | undefined {
898
1248
  if (operation.kind !== "message") return undefined;
1249
+ const own = operation.deadline ?? Infinity;
1250
+ // An asker with no deadline adds none.
899
1251
  const wanted = Math.min(
900
- operation.ceiling,
901
- Math.max(
902
- operation.deadline,
903
- ...[...operation.owes].map((owed) => owed.askerOperation.deadline),
904
- ),
1252
+ operation.ceiling ?? Infinity,
1253
+ Math.max(own, ...[...operation.owes].map((owed) => owed.askerOperation.deadline ?? own)),
905
1254
  );
906
- return wanted > operation.deadline ? wanted : undefined;
1255
+ return wanted > own && Number.isFinite(wanted) ? wanted : undefined;
907
1256
  }
908
1257
 
909
1258
  /** What the harness confirmed of a delivery: the model read it, or nothing says it did. */
@@ -935,7 +1284,12 @@ export class Messaging {
935
1284
  return { kind: operation?.kind === "message" ? "settle" : "check-in" };
936
1285
  if (this.#pending(operation) === "continues") return { kind: "continue" };
937
1286
  const waited = operation.awaits;
938
- if (waited?.state === "open") return { kind: "wait", peer: waited.owedBy, until: waited.until };
1287
+ if (waited?.state === "open")
1288
+ return {
1289
+ kind: "wait",
1290
+ peer: waited.owedBy,
1291
+ ...(waited.until === undefined ? {} : { until: waited.until }),
1292
+ };
939
1293
  // Each question gets one reminder; a turn that ends still owing one it was reminded of fails it.
940
1294
  for (const owed of [...operation.owes]) {
941
1295
  if (owed.reminded) this.#end(owed, "failed", "it did not reply after a reminder");
@@ -1012,11 +1366,19 @@ export class Messaging {
1012
1366
  peers(agent: AgentKey): {
1013
1367
  peers: PeerView[];
1014
1368
  owes: AgentKey[];
1015
- awaits?: { peer: AgentKey; until: number };
1369
+ awaits?: { peer: AgentKey; until?: number };
1016
1370
  text: string;
1017
1371
  } {
1018
1372
  const peers = this.#routesOf(agent, true).map((route): PeerView => {
1019
1373
  const peer = peerOf(route, agent);
1374
+ if (this.#outside.has(peer))
1375
+ return {
1376
+ peer,
1377
+ can: this.#can(route, agent),
1378
+ state: "outside",
1379
+ queued: 0,
1380
+ purpose: route.purpose,
1381
+ };
1020
1382
  const view = this.#host.agent(peer);
1021
1383
  const waited = this.#current.get(peer)?.awaits;
1022
1384
  const awaiting = waited?.state === "open" ? waited.owedBy : undefined;
@@ -1081,7 +1443,7 @@ export class Messaging {
1081
1443
  this.#closed = true;
1082
1444
  for (const owed of this.#obligations) {
1083
1445
  if (owed.state === "open")
1084
- this.#end(owed, "failed", "the attempt ended", {
1446
+ this.#end(owed, "attempt-ended", "the attempt ended", {
1085
1447
  undelivered: "attempt-ended",
1086
1448
  tell: false,
1087
1449
  });
@@ -1146,13 +1508,20 @@ export class Messaging {
1146
1508
  return [...operation.owes].filter((each) => each.state === "open").map((each) => each.asker);
1147
1509
  }
1148
1510
 
1149
- #standing(agent: AgentKey): { owes: AgentKey[]; awaits?: { peer: AgentKey; until: number } } {
1511
+ #standing(agent: AgentKey): { owes: AgentKey[]; awaits?: { peer: AgentKey; until?: number } } {
1150
1512
  const operation = this.#current.get(agent);
1151
1513
  if (!operation) return { owes: [] };
1152
1514
  const waited = operation.awaits?.state === "open" ? operation.awaits : undefined;
1153
1515
  return {
1154
1516
  owes: this.#owes(operation),
1155
- ...(waited ? { awaits: { peer: waited.owedBy, until: waited.until } } : {}),
1517
+ ...(waited
1518
+ ? {
1519
+ awaits: {
1520
+ peer: waited.owedBy,
1521
+ ...(waited.until === undefined ? {} : { until: waited.until }),
1522
+ },
1523
+ }
1524
+ : {}),
1156
1525
  };
1157
1526
  }
1158
1527
 
@@ -1185,7 +1554,8 @@ export class Messaging {
1185
1554
  owed: Obligation,
1186
1555
  end: Exclude<RequestEnd, "replied">,
1187
1556
  reason: string,
1188
- options: { gone?: true; undelivered?: UndeliveredReason; tell?: false } = {},
1557
+ /** `tell`: whether its asker is told, beside a failure or a timeout, which always are. */
1558
+ options: { gone?: true; undelivered?: UndeliveredReason; tell?: boolean } = {},
1189
1559
  ): void {
1190
1560
  if (owed.state !== "open") return;
1191
1561
  owed.state = end;
@@ -1219,7 +1589,8 @@ export class Messaging {
1219
1589
  });
1220
1590
  const operation = owed.askerOperation;
1221
1591
  if (operation.awaits === owed) operation.awaits = undefined;
1222
- if (options.tell !== false && !operation.ended && (end === "failed" || end === "timed-out")) {
1592
+ const told = options.tell ?? (end === "failed" || end === "timed-out");
1593
+ if (told && !operation.ended) {
1223
1594
  this.#inbox(owed.asker).push({ kind: "no-reply", for: operation, from: owed.owedBy, reason });
1224
1595
  this.#host.changed(owed.asker);
1225
1596
  }
@@ -1265,7 +1636,10 @@ export class Messaging {
1265
1636
  }
1266
1637
  }
1267
1638
 
1268
- #closeRoute(route: Route, reason: "stage-ended" | "agent-stopped" | "attempt-ended"): void {
1639
+ #closeRoute(
1640
+ route: Route,
1641
+ reason: "stage-ended" | "agent-stopped" | "released" | "attempt-ended",
1642
+ ): void {
1269
1643
  if (!route.open) return;
1270
1644
  route.open = false;
1271
1645
  route.closed = reason;
@@ -1301,3 +1675,22 @@ export class Messaging {
1301
1675
  function refuse<C extends SendCode | ReplyCode>(code: C, error: string) {
1302
1676
  return { kind: "refused" as const, code, error };
1303
1677
  }
1678
+
1679
+ function holderRefusal(
1680
+ code: Exclude<ParticipantCode, "not-found">,
1681
+ error: string,
1682
+ choices?: string[],
1683
+ ): HolderResult {
1684
+ return { code, error, ...(choices ? { choices } : {}) };
1685
+ }
1686
+
1687
+ function unknownAgent(agent: AgentKey): HolderResult {
1688
+ return holderRefusal("unknown-agent", `the run has no agent ${agent}.`);
1689
+ }
1690
+
1691
+ function tooLarge(bytes: number, limit: number): HolderResult {
1692
+ return holderRefusal(
1693
+ "too-large",
1694
+ `${Math.ceil(bytes / 1024)} KB; the limit is ${limit / 1024} KB. Put it in a file and send its path.`,
1695
+ );
1696
+ }