@agentswf/engine 0.0.0-stage → 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +39 -4
  2. package/src/accounting/format.ts +252 -0
  3. package/src/accounting/plans.ts +38 -0
  4. package/src/accounting/prices.ts +98 -0
  5. package/src/accounting/summary.ts +385 -0
  6. package/src/accounting/tokens.ts +34 -0
  7. package/src/agent-launcher.ts +127 -0
  8. package/src/allowance-command.ts +105 -0
  9. package/src/attempt-ending.ts +198 -0
  10. package/src/attempt-output.ts +132 -0
  11. package/src/attempt-view.ts +362 -0
  12. package/src/canonical-json.ts +12 -0
  13. package/src/control-plane.ts +424 -0
  14. package/src/deadlines.ts +106 -0
  15. package/src/decisions/directory.ts +432 -0
  16. package/src/decisions/fake.ts +60 -0
  17. package/src/decisions/openrouter.ts +161 -0
  18. package/src/decisions/seam.ts +70 -0
  19. package/src/display-path.ts +16 -0
  20. package/src/duration.ts +12 -0
  21. package/src/errors.ts +4 -0
  22. package/src/files.ts +78 -0
  23. package/src/herdr-placement.ts +182 -0
  24. package/src/herdr-run-session.ts +530 -0
  25. package/src/herdr-workspace-marks.ts +194 -0
  26. package/src/here.ts +289 -0
  27. package/src/index.ts +5 -0
  28. package/src/jsonl.ts +74 -0
  29. package/src/logs-command.ts +605 -0
  30. package/src/machine.ts +27 -0
  31. package/src/message-operation.ts +245 -0
  32. package/src/messaging/limits.ts +35 -0
  33. package/src/messaging/messaging.ts +1303 -0
  34. package/src/messaging/routes.ts +79 -0
  35. package/src/messaging/text.ts +251 -0
  36. package/src/operation-events.ts +182 -0
  37. package/src/operation-liveness.ts +674 -0
  38. package/src/operator-aliases.ts +10 -0
  39. package/src/operator-cli.ts +569 -0
  40. package/src/operator-runtime.ts +503 -0
  41. package/src/pane-layout.ts +125 -0
  42. package/src/progress-view.ts +568 -0
  43. package/src/result-slots.ts +325 -0
  44. package/src/result-validation.ts +47 -0
  45. package/src/run-command.ts +241 -0
  46. package/src/run-commands.ts +215 -0
  47. package/src/run-event-log.ts +72 -0
  48. package/src/run-prepare.ts +178 -0
  49. package/src/run-progress.ts +137 -0
  50. package/src/run-read.ts +425 -0
  51. package/src/run-state.ts +120 -0
  52. package/src/run-usage.ts +393 -0
  53. package/src/run-view.ts +225 -0
  54. package/src/runs-command.ts +356 -0
  55. package/src/runs.ts +645 -0
  56. package/src/sandbox-homes.ts +211 -0
  57. package/src/sandboxes.ts +516 -0
  58. package/src/skills/fetch.ts +305 -0
  59. package/src/skills/run-skills.ts +181 -0
  60. package/src/skills/sources.ts +87 -0
  61. package/src/skills/tree.ts +60 -0
  62. package/src/stage-ledger.ts +535 -0
  63. package/src/stage-plan.ts +127 -0
  64. package/src/stage-schema.ts +88 -0
  65. package/src/stopped.ts +36 -0
  66. package/src/test-command.ts +110 -0
  67. package/src/testing/operation-events.ts +89 -0
  68. package/src/testing.ts +164 -0
  69. package/src/workflow-loader.ts +113 -0
  70. package/src/workflow-runner.ts +3665 -0
  71. package/src/workflow-test-preload.ts +13 -0
  72. package/src/workflow-testing/decisions.ts +129 -0
  73. package/src/workflow-testing/host.ts +811 -0
  74. package/src/workflow-testing/index.ts +420 -0
  75. package/src/workflow-testing/script.ts +447 -0
  76. package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
  77. package/README.md +0 -3
@@ -0,0 +1,1303 @@
1
+ import type {
2
+ MessageLimits,
3
+ MessagingEvent,
4
+ RequestEnd,
5
+ RouteEnds,
6
+ UndeliveredReason,
7
+ } from "@agentswf/contract/records";
8
+ import type { PeerView, ReplyCode, SendCode } from "@agentswf/contract/wire";
9
+ import type { AgentKey } from "@agentswf/contract/workflow";
10
+ import type { AuthoredMessage } from "@agentswf/harness/adapter";
11
+ import { MESSAGE_LIMITS } from "./limits";
12
+ import {
13
+ directions,
14
+ pairKey,
15
+ peerOf,
16
+ purposeProblem,
17
+ type Route,
18
+ type Scope,
19
+ sameGrant,
20
+ } from "./routes";
21
+ import {
22
+ clock,
23
+ type Delivered,
24
+ envelope,
25
+ owingLine,
26
+ peersTable,
27
+ type Recipient,
28
+ type RouteLine,
29
+ reminder,
30
+ routeLesson,
31
+ } from "./text";
32
+
33
+ /** An agent as the runner sees it. */
34
+ export type AgentView = {
35
+ state: "not-started" | "working" | "idle" | "stopped";
36
+ /** Why it stopped. */
37
+ reason?: string;
38
+ /** The session the run was started from, which takes no part in messaging yet. */
39
+ caller?: true;
40
+ };
41
+
42
+ /** What the logic asks of the runner, and tells it. */
43
+ export interface MessagingHost {
44
+ /** An agent the workflow opened; undefined for any other key. */
45
+ agent(key: AgentKey): AgentView | undefined;
46
+ /** Whether it may `--await-reply` where it runs. */
47
+ canAwait(key: AgentKey): boolean;
48
+ /** The launcher it runs `wf` through. */
49
+ launcher(key: AgentKey): string;
50
+ /** Something is new for this agent: messages for it, or an end to its wait. */
51
+ changed(key: AgentKey): void;
52
+ schedule(at: number, action: () => void): () => void;
53
+ now(): number;
54
+ /** Written to the run's event log; `message-sent` is awaited before a send is acknowledged. */
55
+ record(event: MessagingEventBody): Promise<void>;
56
+ }
57
+
58
+ type Body<E> = E extends unknown ? Omit<E, "callPath"> : never;
59
+ /** A messaging event as this logic writes it; the runner adds the call path and the log the rest. */
60
+ export type MessagingEventBody = Body<MessagingEvent>;
61
+
62
+ /** An operation as messaging sees it: one at a time on each agent. */
63
+ export type OperationInfo = {
64
+ id: string;
65
+ agent: AgentKey;
66
+ /** A workflow's own `run`, or one a message opened. */
67
+ 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;
72
+ /** The scope it runs in: its route's for a message operation, the stage's for a `run`. */
73
+ scope: Scope;
74
+ };
75
+
76
+ /** How an operation ended, as far as its waits and debts go. */
77
+ export type OperationEnd = "result" | "settled" | "cancelled" | "failed" | "timed-out";
78
+
79
+ export type MessageRequest = {
80
+ peer: AgentKey;
81
+ text: string;
82
+ awaitReply?: true;
83
+ timeoutMs?: number;
84
+ };
85
+
86
+ export type Admission =
87
+ | { kind: "sent"; message: number; until?: number; notice: string }
88
+ | { kind: "refused"; code: SendCode | ReplyCode; error: string };
89
+
90
+ /** A delivery taken for an operation: its prompt text, and the messages as the test host reads them. */
91
+ export type Delivery = {
92
+ text: string;
93
+ messages: AuthoredMessage[];
94
+ /** The record's numbers of the messages in it. */
95
+ numbers: number[];
96
+ /**
97
+ * A message operation's deadline, moved later for the askers whose questions it now carries: the
98
+ * later of its own and theirs, never past the run's. Absent when it did not move.
99
+ */
100
+ deadline?: number;
101
+ };
102
+
103
+ /** What is held for an operation whose turn has ended, without deciding anything. */
104
+ export type Pending = "continues" | "held" | "none";
105
+
106
+ /** What a turn that ended without a result goes on to, in the design's order. */
107
+ export type TurnEnd =
108
+ /** Something continues it now: an answer, a failure, a question, or held messages. */
109
+ | { 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 }
112
+ /** It owes answers: the one reminder, as its next prompt. */
113
+ | { kind: "remind"; text: string }
114
+ /** A workflow's operation with nothing owed or held: 021's check-in. */
115
+ | { kind: "check-in" }
116
+ /** A message operation that is done. */
117
+ | { kind: "settle" };
118
+
119
+ type Message = {
120
+ readonly kind: "message";
121
+ readonly n: number;
122
+ readonly from: AgentKey;
123
+ readonly to: AgentKey;
124
+ readonly text: string;
125
+ readonly bytes: number;
126
+ readonly route: Route;
127
+ /** The question it asks, owed by `to`. */
128
+ asks?: Obligation;
129
+ /** The question it answers. */
130
+ readonly answers?: Obligation;
131
+ shown: boolean;
132
+ /** The operation it was shown in. */
133
+ shownIn?: Operation;
134
+ };
135
+
136
+ type Obligation = {
137
+ readonly request: Message;
138
+ readonly asker: AgentKey;
139
+ readonly askerOperation: Operation;
140
+ readonly owedBy: AgentKey;
141
+ /** The operation it was shown in, which owes it. */
142
+ owingOperation?: Operation;
143
+ /** Its owing operation was reminded of it once; the next turn's end that still owes fails it. */
144
+ reminded?: true;
145
+ /** The asker waits until then: its `--timeout`, or its operation's deadline as that moves. */
146
+ until: number;
147
+ /** It has a `--timeout` of its own, which only that fails it at. */
148
+ readonly timed: boolean;
149
+ state: "open" | RequestEnd;
150
+ /** Its end, set once; a reply after it is refused `expired`. */
151
+ endedAt?: number;
152
+ cancelTimer?: () => void;
153
+ };
154
+
155
+ type Failure = { kind: "no-reply"; for: Operation; from: AgentKey; reason: string };
156
+ type Held = Message | Failure;
157
+
158
+ type Operation = OperationInfo & {
159
+ awaits?: Obligation;
160
+ readonly owes: Set<Obligation>;
161
+ /** Routes it has been told of: those open when it began, and each announced since. */
162
+ readonly told: Set<Route>;
163
+ ended: boolean;
164
+ };
165
+
166
+ /**
167
+ * The messaging logic of one attempt: routes, admission, inboxes and owed answers. It decides every
168
+ * case the design lists and writes the record; it starts nothing. The runner opens and continues
169
+ * operations when it says, and the control plane hands it `wf`'s commands.
170
+ */
171
+ export class Messaging {
172
+ readonly limits: MessageLimits;
173
+ readonly #host: MessagingHost;
174
+ readonly attempt: Scope = { id: 0, state: "open" };
175
+ #scopes = 0;
176
+ #grants = 0;
177
+ #messages = 0;
178
+ /** Every route granted in the attempt, open or closed, by each pair it carries. */
179
+ readonly #routes = new Map<string, Route[]>();
180
+ readonly #inboxes = new Map<AgentKey, Held[]>();
181
+ readonly #current = new Map<AgentKey, Operation>();
182
+ readonly #operations = new Map<string, Operation>();
183
+ /** Every question asked in the attempt, in order. */
184
+ readonly #obligations: Obligation[] = [];
185
+ /** Every message admitted, by its number. */
186
+ readonly #sentBy = new Map<number, Message>();
187
+ #closed = false;
188
+
189
+ constructor(host: MessagingHost, limits: MessageLimits = MESSAGE_LIMITS) {
190
+ this.#host = host;
191
+ this.limits = limits;
192
+ }
193
+
194
+ scope(stage: string): Scope {
195
+ return { id: ++this.#scopes, stage, state: "open" };
196
+ }
197
+
198
+ // Routes
199
+
200
+ /**
201
+ * Grants a route in `scope`, atomically: anything invalid rejects with nothing granted. Granting
202
+ * the same route again does nothing; another route over an open pair rejects.
203
+ */
204
+ grant(ends: RouteEnds, options: { purpose: unknown; maxMessages?: unknown; scope: Scope }): void {
205
+ const { scope } = options;
206
+ if (this.#closed || scope.state !== "open")
207
+ throw new Error("routes are granted only while a scope is open");
208
+ const problem = purposeProblem(options.purpose);
209
+ if (problem) throw new Error(problem);
210
+ const purpose = options.purpose as string;
211
+ const requested = options.maxMessages;
212
+ if (
213
+ requested !== undefined &&
214
+ (typeof requested !== "number" || !Number.isSafeInteger(requested) || requested < 1)
215
+ )
216
+ throw new Error("a route's maxMessages is a positive whole number");
217
+ const pairs = directions(ends);
218
+ for (const [from, to] of pairs) {
219
+ if (from === to) throw new Error(`a route from ${from} to itself is not allowed`);
220
+ for (const key of [from, to]) {
221
+ const agent = this.#host.agent(key);
222
+ if (!agent) throw new Error(`${key} is not an agent this workflow opened`);
223
+ if (agent.caller)
224
+ throw new Error(`${key} is the calling session, which cannot take part in messaging yet`);
225
+ if (agent.state === "stopped") throw new Error(`${key} has stopped`);
226
+ }
227
+ }
228
+ const open = pairs.map(([from, to]) => this.#open(from, to));
229
+ if (open.some(Boolean)) {
230
+ if (
231
+ open.every(
232
+ (route) =>
233
+ route && sameGrant(route, ends, purpose, requested as number | undefined, scope),
234
+ ) &&
235
+ new Set(open).size === 1
236
+ )
237
+ return;
238
+ const [from, to] = pairs[open.findIndex(Boolean)]!;
239
+ throw new Error(
240
+ `${from} and ${to} already have an open route; grant the same route, or one after it closes`,
241
+ );
242
+ }
243
+ const asked = requested as number | undefined;
244
+ const maxMessages = Math.min(asked ?? this.limits.perRoute, this.limits.perRoute);
245
+ const route: Route = {
246
+ ends,
247
+ purpose,
248
+ maxMessages,
249
+ ...(asked !== undefined && asked > maxMessages ? { asked } : {}),
250
+ ...(asked === undefined ? {} : { requested: asked }),
251
+ scope,
252
+ count: 0,
253
+ open: true,
254
+ order: ++this.#grants,
255
+ };
256
+ for (const [from, to] of pairs) {
257
+ const key = pairKey(from, to);
258
+ this.#routes.set(key, [...(this.#routes.get(key) ?? []), route]);
259
+ }
260
+ void this.#record({
261
+ kind: "route-opened",
262
+ ...ends,
263
+ purpose,
264
+ maxMessages,
265
+ ...(route.asked === undefined ? {} : { asked: route.asked }),
266
+ ...(scope.stage === undefined ? {} : { stage: scope.stage }),
267
+ });
268
+ for (const agent of new Set(pairs.flat())) this.#host.changed(agent);
269
+ }
270
+
271
+ /**
272
+ * The stage's function returned: its routes take no new messages, though an answer still goes. A
273
+ * message held for an agent busy outside the stage is undelivered: it would reach it only after
274
+ * the stage had ended.
275
+ */
276
+ finishing(scope: Scope): void {
277
+ if (scope.state !== "open") return;
278
+ scope.state = "finishing";
279
+ for (const [agent, held] of this.#inboxes) {
280
+ // Working on something of another scope, a compaction or a queued run included.
281
+ if (this.#host.agent(agent)?.state !== "working") continue;
282
+ if (this.#current.get(agent)?.scope === scope) continue;
283
+ this.#drop(
284
+ agent,
285
+ held.filter(
286
+ (item): item is Message =>
287
+ item.kind === "message" && item.route.scope === scope && !item.answers,
288
+ ),
289
+ `its stage ${scope.stage ?? ""} finished before ${agent} saw the question`.replace(
290
+ " ",
291
+ " ",
292
+ ),
293
+ );
294
+ }
295
+ }
296
+
297
+ /** The stage has ended: its routes close, and what they still held is undelivered. */
298
+ closeScope(scope: Scope): void {
299
+ scope.state = "closed";
300
+ for (const route of this.#openRoutes()) {
301
+ if (route.scope === scope) this.#closeRoute(route, "stage-ended");
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Whether anything on `scope`'s routes is still in flight: a question not yet answered, or a
307
+ * message held for an agent. A stage waits for this, as well as for the operations it owns.
308
+ */
309
+ busy(scope: Scope): boolean {
310
+ const onScope = (message: Message) => message.route.scope === scope;
311
+ if (this.#obligations.some((each) => each.state === "open" && onScope(each.request)))
312
+ return true;
313
+ for (const held of this.#inboxes.values()) {
314
+ if (held.some((item) => item.kind !== "no-reply" && onScope(item))) return true;
315
+ }
316
+ return false;
317
+ }
318
+
319
+ // Operations
320
+
321
+ /** An operation began on its agent; its routes so far are the ones its prompt teaches. */
322
+ opened(info: OperationInfo): void {
323
+ const operation: Operation = {
324
+ ...info,
325
+ owes: new Set(),
326
+ told: new Set(this.#routesOf(info.agent)),
327
+ ended: false,
328
+ };
329
+ this.#operations.set(info.id, operation);
330
+ this.#current.set(info.agent, operation);
331
+ }
332
+
333
+ /** A message operation's deadline moved later, and with it any wait it has no `--timeout` for. */
334
+ extended(id: string, deadline: number): void {
335
+ const operation = this.#operations.get(id);
336
+ if (!operation || deadline <= operation.deadline) return;
337
+ operation.deadline = deadline;
338
+ const waited = operation.awaits;
339
+ if (waited?.state === "open" && !waited.timed) waited.until = deadline;
340
+ }
341
+
342
+ /**
343
+ * An operation ended: its wait is dropped and what it still owed fails. A result while it awaited
344
+ * an answer drops the wait, as a cancellation does.
345
+ */
346
+ ended(id: string, how: OperationEnd): void {
347
+ const operation = this.#operations.get(id);
348
+ if (!operation || operation.ended) return;
349
+ operation.ended = true;
350
+ if (this.#current.get(operation.agent) === operation) this.#current.delete(operation.agent);
351
+ const waited = operation.awaits;
352
+ operation.awaits = undefined;
353
+ if (waited?.state === "open") {
354
+ this.#end(
355
+ waited,
356
+ waited.request.shown ? "released" : "withdrawn",
357
+ `${operation.agent} finished first`,
358
+ );
359
+ }
360
+ // An answer accepted but not yet shown is withdrawn with it.
361
+ this.#withdrawHeldFor(operation);
362
+ for (const owed of [...operation.owes]) {
363
+ this.#end(
364
+ owed,
365
+ "failed",
366
+ how === "settled" || how === "result"
367
+ ? "it did not reply"
368
+ : `its operation ${how === "cancelled" ? "was cancelled" : how === "timed-out" ? "timed out" : "failed"} before it replied`,
369
+ );
370
+ }
371
+ }
372
+
373
+ /** The answer this operation awaits, while it does. */
374
+ awaiting(id: string): { peer: AgentKey; until: number } | undefined {
375
+ const waited = this.#operations.get(id)?.awaits;
376
+ return waited?.state === "open" ? { peer: waited.owedBy, until: waited.until } : undefined;
377
+ }
378
+
379
+ /** The operation open on this agent, if any. */
380
+ current(agent: AgentKey): OperationInfo | undefined {
381
+ return this.#current.get(agent);
382
+ }
383
+
384
+ /**
385
+ * A refusal of `wf result` or `wf waiting` given this call id, or the acknowledgement of a
386
+ * `wf waiting` while an answer is awaited; null when the command goes on as before.
387
+ */
388
+ checkFinish(
389
+ agent: AgentKey,
390
+ id: string,
391
+ command: "result" | "waiting",
392
+ ):
393
+ | { kind: "refused"; code: "owes-reply" | "no-result"; error: string }
394
+ | { kind: "awaiting"; peer: AgentKey; until: number; notice: string }
395
+ | null {
396
+ const operation = this.#operations.get(id);
397
+ if (!operation || operation.agent !== agent) return null;
398
+ const wf = this.#host.launcher(agent);
399
+ // A message operation's call takes no result, ended or not.
400
+ if (operation.kind === "message") {
401
+ return {
402
+ kind: "refused",
403
+ code: "no-result",
404
+ error: `call ${id} is a message, not a task: it takes no result and cannot wait. ${this.#owes(operation).length ? "Reply, then end your turn." : "End your turn."}`,
405
+ };
406
+ }
407
+ if (operation.ended) return null;
408
+ const owes = this.#owes(operation);
409
+ if (owes.length > 0) {
410
+ const peer = owes[0]!;
411
+ return {
412
+ kind: "refused",
413
+ code: "owes-reply",
414
+ error:
415
+ command === "result"
416
+ ? `you owe ${peer} a reply. Send it with ${wf} reply ${peer}, then run ${wf} result ${id} again.`
417
+ : `you owe ${peer} a reply; send it with ${wf} reply ${peer} first.`,
418
+ };
419
+ }
420
+ const waited = operation.awaits;
421
+ if (command === "waiting" && waited?.state === "open") {
422
+ return {
423
+ kind: "awaiting",
424
+ 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}.`,
427
+ };
428
+ }
429
+ return null;
430
+ }
431
+
432
+ // Sending
433
+
434
+ async send(from: AgentKey, request: MessageRequest): Promise<Admission> {
435
+ const checked = this.#checkSend(from, request);
436
+ if (checked.kind === "refused") return this.#refused(from, request, "send", checked);
437
+ const { operation, route, bytes } = checked;
438
+ const message = this.#admit(from, request.peer, request.text, bytes, route);
439
+ const until = request.awaitReply ? this.#ask(message, operation, request.timeoutMs) : undefined;
440
+ // Admitted whatever the record says: its recipient is woken even if the write fails.
441
+ try {
442
+ await this.#sent(message, operation);
443
+ } finally {
444
+ this.#host.changed(request.peer);
445
+ }
446
+ return {
447
+ kind: "sent",
448
+ message: message.n,
449
+ ...(until === undefined ? {} : { until }),
450
+ notice: this.#sentNotice(request.peer, operation, until),
451
+ };
452
+ }
453
+
454
+ async reply(from: AgentKey, request: MessageRequest): Promise<Admission> {
455
+ const checked = this.#checkReply(from, request);
456
+ if (checked.kind === "refused") return this.#refused(from, request, "reply", checked);
457
+ const { operation, owed, bytes } = checked;
458
+ // Accepting it settles the question: a timeout after this changes nothing.
459
+ owed.state = "replied";
460
+ owed.endedAt = this.#host.now();
461
+ owed.cancelTimer?.();
462
+ owed.owingOperation?.owes.delete(owed);
463
+ if (owed.askerOperation.awaits === owed) owed.askerOperation.awaits = undefined;
464
+ 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;
466
+ // Its line first, then the question's end, in the log's order; the end is written either way.
467
+ const sent = this.#sent(message, operation);
468
+ void this.#record({
469
+ kind: "request-ended",
470
+ message: owed.request.n,
471
+ owedBy: owed.owedBy,
472
+ asker: owed.asker,
473
+ end: "replied",
474
+ reply: message.n,
475
+ });
476
+ try {
477
+ await sent;
478
+ } finally {
479
+ this.#host.changed(owed.asker);
480
+ }
481
+ const asked = until === undefined ? "" : ` ${this.#awaitNotice(operation, until)}`;
482
+ return {
483
+ kind: "sent",
484
+ message: message.n,
485
+ ...(until === undefined ? {} : { until }),
486
+ notice: `replied to ${request.peer}.${asked}`,
487
+ };
488
+ }
489
+
490
+ #checkSend(
491
+ from: AgentKey,
492
+ request: MessageRequest,
493
+ ):
494
+ | { kind: "ok"; operation: Operation; route: Route; bytes: number }
495
+ | { kind: "refused"; code: SendCode; error: string } {
496
+ const wf = this.#host.launcher(from);
497
+ const operation = this.#current.get(from);
498
+ if (!operation) return refuse("no-operation", "this agent has no open operation.");
499
+ const { peer } = request;
500
+ // Keyed by direction: every route here is one `from` may start a conversation on.
501
+ const initiating = this.#routes.get(pairKey(from, peer)) ?? [];
502
+ const replyOnly = () => {
503
+ const owes = this.#owes(operation).includes(peer);
504
+ return refuse(
505
+ "no-route",
506
+ owes
507
+ ? `your route with ${peer} only answers its requests. Answer it with ${wf} reply ${peer}.`
508
+ : `your route with ${peer} only answers its requests, and it asked nothing. Nothing to send; end your turn.`,
509
+ );
510
+ };
511
+ if (initiating.length === 0) {
512
+ return this.#open(peer, from)
513
+ ? replyOnly()
514
+ : refuse("no-route", `no route to ${peer}. Your routes: ${wf} peers`);
515
+ }
516
+ const agent = this.#host.agent(peer);
517
+ if (agent?.state === "stopped")
518
+ return refuse(
519
+ "peer-stopped",
520
+ `${peer} has stopped${agent.reason ? ` (${JSON.stringify(agent.reason)})` : ""}.`,
521
+ );
522
+ const route = initiating.find((each) => each.open);
523
+ if (!route) {
524
+ if (this.#open(peer, from)) return replyOnly();
525
+ const last = initiating.at(-1)!;
526
+ return refuse(
527
+ "closed",
528
+ `the route to ${peer} closed${last.scope.stage ? ` when its stage ${last.scope.stage} ended` : ""}.`,
529
+ );
530
+ }
531
+ if (route.scope.state !== "open" || this.#closed) {
532
+ return refuse(
533
+ "closed",
534
+ `${route.scope.stage ? `stage ${route.scope.stage}` : "the workflow"} is finishing; it takes no new messages. ${this.#sayIt(operation, "Say so in your result.")}`,
535
+ );
536
+ }
537
+ const bytes = Buffer.byteLength(request.text);
538
+ if (bytes > this.limits.bytes) {
539
+ return refuse(
540
+ "too-large",
541
+ `${Math.ceil(bytes / 1024)} KB; the limit is ${this.limits.bytes / 1024} KB. Put it in a file and send its path.`,
542
+ );
543
+ }
544
+ if (request.awaitReply) {
545
+ const asking = this.#checkAsk(from, peer, operation);
546
+ if (asking) return asking;
547
+ }
548
+ if (route.count + (request.awaitReply ? 2 : 1) > route.maxMessages) {
549
+ return refuse(
550
+ "limit",
551
+ `${route.maxMessages} messages with ${peer}, this route's limit${request.awaitReply && route.count + 1 <= route.maxMessages ? ", with room for a message but not a question and its reply" : ""}. ${this.#sayIt(operation, "Say so in your result.")}`,
552
+ );
553
+ }
554
+ const unread = this.#unread(peer);
555
+ if (unread >= this.limits.unread) {
556
+ return refuse(
557
+ "inbox-full",
558
+ `${peer} has ${unread} unread messages. Try again after its turn ends, or ${this.#sayIt(operation, "say so in your result.", true)}`,
559
+ );
560
+ }
561
+ return { kind: "ok", operation, route, bytes };
562
+ }
563
+
564
+ #checkReply(
565
+ from: AgentKey,
566
+ request: MessageRequest,
567
+ ):
568
+ | { kind: "ok"; operation: Operation; owed: Obligation; bytes: number }
569
+ | { kind: "refused"; code: ReplyCode; error: string } {
570
+ const wf = this.#host.launcher(from);
571
+ const operation = this.#current.get(from);
572
+ if (!operation) return refuse("no-operation", "this agent has no open operation.");
573
+ const { peer } = request;
574
+ // The latest question from that peer to this agent in the attempt.
575
+ const latest = this.#obligations.findLast(
576
+ (each) => each.asker === peer && each.owedBy === from && each.request.shown,
577
+ );
578
+ if (!latest || latest.state === "replied") {
579
+ return refuse("none-owed", `you owe ${peer} nothing. Send it with ${wf} send ${peer}.`);
580
+ }
581
+ if (latest.state !== "open" || latest.owingOperation !== operation) {
582
+ const agent = this.#host.agent(peer);
583
+ return refuse(
584
+ "expired",
585
+ agent?.state === "stopped"
586
+ ? `${peer} has stopped${agent.reason ? ` (${JSON.stringify(agent.reason)})` : ""}; nothing reaches it now.`
587
+ : `${peer} stopped waiting at ${clock(latest.endedAt ?? this.#host.now(), this.#host.now())}. Send it with ${wf} send ${peer}.`,
588
+ );
589
+ }
590
+ const route = latest.request.route;
591
+ if (!route.open)
592
+ return refuse(
593
+ "closed",
594
+ `the route to ${peer} closed${route.scope.stage ? ` when its stage ${route.scope.stage} ended` : ""}.`,
595
+ );
596
+ if (request.awaitReply && route.scope.state !== "open") {
597
+ return refuse(
598
+ "closed",
599
+ `${route.scope.stage ? `stage ${route.scope.stage}` : "the workflow"} is finishing; reply without --await-reply.`,
600
+ );
601
+ }
602
+ const bytes = Buffer.byteLength(request.text);
603
+ if (bytes > this.limits.bytes) {
604
+ return refuse(
605
+ "too-large",
606
+ `${Math.ceil(bytes / 1024)} KB; the limit is ${this.limits.bytes / 1024} KB. Put it in a file and send its path.`,
607
+ );
608
+ }
609
+ if (request.awaitReply) {
610
+ const asking = this.#checkAsk(from, peer, operation, latest);
611
+ if (asking) return asking;
612
+ // The reply's own room was reserved with its question; asking back needs room for an answer.
613
+ if (route.count + 1 > route.maxMessages) {
614
+ return refuse(
615
+ "limit",
616
+ `${route.maxMessages} messages with ${peer}, this route's limit: room for your reply, not for asking back. Reply without --await-reply.`,
617
+ );
618
+ }
619
+ }
620
+ return { kind: "ok", operation, owed: latest, bytes };
621
+ }
622
+
623
+ /** `awaiting`, `unsupported` and `cycle`, checked for any question. */
624
+ #checkAsk(
625
+ from: AgentKey,
626
+ peer: AgentKey,
627
+ operation: Operation,
628
+ /** The question a reply answers, which counts as answered here. */
629
+ answering?: Obligation,
630
+ ): { kind: "refused"; code: "awaiting" | "unsupported" | "cycle"; error: string } | undefined {
631
+ const wf = this.#host.launcher(from);
632
+ if (operation.awaits?.state === "open") {
633
+ return refuse(
634
+ "awaiting",
635
+ `you already await ${operation.awaits.owedBy}. End your turn, or send without --await-reply.`,
636
+ );
637
+ }
638
+ if (!this.#host.canAwait(from)) {
639
+ return refuse(
640
+ "unsupported",
641
+ "this agent cannot await a reply here. Send without --await-reply and say where the answer should go, such as a file.",
642
+ );
643
+ }
644
+ // Follow the open waits from the peer: one that leads back here would hold every agent on it
645
+ // until a deadline stopped them.
646
+ const chain: AgentKey[] = [];
647
+ let at: AgentKey | undefined = peer;
648
+ const seen = new Set<AgentKey>();
649
+ while (at !== undefined && !seen.has(at)) {
650
+ seen.add(at);
651
+ const waited: Obligation | undefined = this.#current.get(at)?.awaits;
652
+ if (waited?.state !== "open" || waited === answering) break;
653
+ chain.push(at);
654
+ if (waited.owedBy === from) {
655
+ const last = at;
656
+ const through = chain.slice(1);
657
+ const who =
658
+ through.length === 0
659
+ ? `${peer} already waits on you`
660
+ : `${peer} already waits, through ${through.join(", ")}, on you`;
661
+ const owing = waited.request.shown;
662
+ return refuse(
663
+ "cycle",
664
+ owing
665
+ ? `${who}, and you owe ${last === peer ? "it" : last} a reply. Answer with ${wf} reply ${last}; it may --await-reply.`
666
+ : `${who}. Send without --await-reply and end your turn; ${last}'s request reaches you then.`,
667
+ );
668
+ }
669
+ at = waited.owedBy;
670
+ }
671
+ return undefined;
672
+ }
673
+
674
+ #refused(
675
+ from: AgentKey,
676
+ request: MessageRequest,
677
+ command: "send" | "reply",
678
+ refusal: { code: SendCode | ReplyCode; error: string },
679
+ ): Admission {
680
+ const operation = this.#current.get(from);
681
+ void this.#record({
682
+ kind: "message-refused",
683
+ from,
684
+ to: request.peer,
685
+ ...(operation ? { operationId: operation.id } : {}),
686
+ command,
687
+ code: refusal.code,
688
+ bytes: Buffer.byteLength(request.text),
689
+ ...(request.awaitReply ? { awaitReply: true as const } : {}),
690
+ });
691
+ return { kind: "refused", code: refusal.code, error: refusal.error };
692
+ }
693
+
694
+ #admit(
695
+ from: AgentKey,
696
+ to: AgentKey,
697
+ text: string,
698
+ bytes: number,
699
+ route: Route,
700
+ answers?: Obligation,
701
+ ): Message {
702
+ const message: Message = {
703
+ kind: "message",
704
+ n: ++this.#messages,
705
+ from,
706
+ to,
707
+ text,
708
+ bytes,
709
+ route,
710
+ ...(answers ? { answers } : {}),
711
+ shown: false,
712
+ };
713
+ // An answer's room was reserved when it was asked.
714
+ if (!answers) route.count += 1;
715
+ this.#inbox(to).push(message);
716
+ this.#sentBy.set(message.n, message);
717
+ return message;
718
+ }
719
+
720
+ /** Makes `message` a question its recipient owes an answer to, and its sender's wait. */
721
+ #ask(message: Message, operation: Operation, timeoutMs?: number): number {
722
+ message.route.count += 1;
723
+ const now = this.#host.now();
724
+ const until = Math.min(
725
+ operation.deadline,
726
+ timeoutMs === undefined ? Infinity : now + timeoutMs,
727
+ );
728
+ const obligation: Obligation = {
729
+ request: message,
730
+ asker: message.from,
731
+ askerOperation: operation,
732
+ owedBy: message.to,
733
+ until,
734
+ timed: until < operation.deadline,
735
+ state: "open",
736
+ };
737
+ message.asks = obligation;
738
+ operation.awaits = obligation;
739
+ this.#obligations.push(obligation);
740
+ // 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, () => {
743
+ if (obligation.state === "open") {
744
+ this.#end(
745
+ obligation,
746
+ "timed-out",
747
+ `your --timeout passed at ${clock(until, this.#host.now())}`,
748
+ );
749
+ }
750
+ });
751
+ }
752
+ return until;
753
+ }
754
+
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> {
757
+ await this.#host.record({
758
+ kind: "message-sent",
759
+ message: message.n,
760
+ from: message.from,
761
+ to: message.to,
762
+ operationId: operation.id,
763
+ text: message.text,
764
+ bytes: message.bytes,
765
+ ...(message.asks
766
+ ? { awaitsReply: { until: new Date(message.asks.until).toISOString() } }
767
+ : {}),
768
+ ...(message.answers ? { replyTo: message.answers.request.n } : {}),
769
+ });
770
+ }
771
+
772
+ #sentNotice(peer: AgentKey, operation: Operation, until?: number): string {
773
+ if (until !== undefined) return `sent to ${peer}. ${this.#awaitNotice(operation, until)}`;
774
+ const state = this.#host.agent(peer)?.state;
775
+ const when =
776
+ this.#current.get(peer)?.awaits?.state === "open"
777
+ ? "it reads this with the reply it awaits"
778
+ : state === "not-started"
779
+ ? "it has not run, so this starts its first turn"
780
+ : state === "idle"
781
+ ? "it is idle, so this wakes it"
782
+ : "it reads this when its current turn ends";
783
+ return `sent to ${peer}; ${when}.`;
784
+ }
785
+
786
+ #awaitNotice(operation: Operation, until: number): string {
787
+ const wf = this.#host.launcher(operation.agent);
788
+ return [
789
+ `Its reply starts your next turn (deadline ${clock(until, this.#host.now())}).`,
790
+ "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
+ ...(operation.kind === "workflow"
792
+ ? [
793
+ `If you find the answer first, or decide not to wait, finish with ${wf} result ${operation.id}; the wait is dropped.`,
794
+ ]
795
+ : []),
796
+ ].join("\n");
797
+ }
798
+
799
+ // Delivering
800
+
801
+ /**
802
+ * Whether a message is held that would open a message operation on this idle agent, and the
803
+ * route it came by, whose sender is the operation's cause and whose scope owns it.
804
+ */
805
+ opening(agent: AgentKey): { from: AgentKey; scope: Scope } | undefined {
806
+ const first = this.#inbox(agent).find((item): item is Message => item.kind !== "no-reply");
807
+ return first && { from: first.from, scope: first.route.scope };
808
+ }
809
+
810
+ /**
811
+ * Takes what is held for this operation, marks it shown and returns its text. `opens`, a new
812
+ * message operation, takes the first route's messages alone; continuing an operation or joining a
813
+ * workflow's `run` takes everything held. Questions shown become this operation's to answer.
814
+ * Undefined when nothing is held to take.
815
+ */
816
+ take(id: string, how: "opens" | "continues" | "joins"): Delivery | undefined {
817
+ const operation = this.#operations.get(id);
818
+ if (!operation || operation.ended) return undefined;
819
+ const inbox = this.#inbox(operation.agent);
820
+ const first = inbox.find((item): item is Message => item.kind !== "no-reply");
821
+ const taken = inbox.filter((item) =>
822
+ item.kind === "no-reply"
823
+ ? item.for === operation
824
+ : how !== "opens" || item.route === first?.route,
825
+ );
826
+ if (taken.length === 0) return undefined;
827
+ this.#inboxes.set(
828
+ operation.agent,
829
+ inbox.filter((item) => !taken.includes(item)),
830
+ );
831
+ const delivered: Delivered[] = [];
832
+ const messages: AuthoredMessage[] = [];
833
+ const numbers: number[] = [];
834
+ for (const item of taken) {
835
+ if (item.kind === "no-reply") {
836
+ delivered.push(item);
837
+ messages.push({ kind: "no-reply", from: item.from, reason: item.reason });
838
+ continue;
839
+ }
840
+ item.shown = true;
841
+ item.shownIn = operation;
842
+ numbers.push(item.n);
843
+ const asks = item.asks?.state === "open" ? item.asks : undefined;
844
+ if (asks) {
845
+ asks.owingOperation = operation;
846
+ operation.owes.add(asks);
847
+ }
848
+ const answers = item.answers?.askerOperation === operation;
849
+ delivered.push({
850
+ kind: "message",
851
+ from: item.from,
852
+ text: item.text,
853
+ purpose: item.route.purpose,
854
+ ...(answers ? { answers: true as const } : {}),
855
+ ...(asks ? { asks: true as const } : {}),
856
+ });
857
+ messages.push({
858
+ kind: "message",
859
+ from: item.from,
860
+ text: item.text,
861
+ ...(answers ? { answers: true as const } : {}),
862
+ ...(asks ? { asks: true as const } : {}),
863
+ });
864
+ void this.#record({
865
+ kind: "message-delivery",
866
+ message: item.n,
867
+ from: item.from,
868
+ to: item.to,
869
+ operationId: operation.id,
870
+ state: "offered",
871
+ });
872
+ }
873
+ const announced = this.#routesOf(operation.agent).filter((route) => !operation.told.has(route));
874
+ for (const route of announced) operation.told.add(route);
875
+ const text = envelope(delivered, this.#recipient(operation), {
876
+ ...(how === "opens" ? { opens: true } : {}),
877
+ ...(how === "opens"
878
+ ? {}
879
+ : { announced: announced.map((route) => this.#line(route, operation.agent)) }),
880
+ });
881
+ // A message operation opens on its routes' lesson, as a workflow's `run` does.
882
+ const lesson = how === "opens" ? this.lesson(operation.agent) : "";
883
+ const deadline = this.#deadlineFor(operation);
884
+ if (deadline !== undefined) this.extended(operation.id, deadline);
885
+ return {
886
+ text: lesson ? `${lesson}\n\n${text}` : text,
887
+ messages,
888
+ numbers,
889
+ ...(deadline === undefined ? {} : { deadline }),
890
+ };
891
+ }
892
+
893
+ /**
894
+ * A message operation's deadline for the questions it carries: the latest of its askers', if
895
+ * later than its own, never past its ceiling.
896
+ */
897
+ #deadlineFor(operation: Operation): number | undefined {
898
+ if (operation.kind !== "message") return undefined;
899
+ const wanted = Math.min(
900
+ operation.ceiling,
901
+ Math.max(
902
+ operation.deadline,
903
+ ...[...operation.owes].map((owed) => owed.askerOperation.deadline),
904
+ ),
905
+ );
906
+ return wanted > operation.deadline ? wanted : undefined;
907
+ }
908
+
909
+ /** What the harness confirmed of a delivery: the model read it, or nothing says it did. */
910
+ confirmed(id: string, numbers: readonly number[], state: "observed" | "unconfirmed"): void {
911
+ const operation = this.#operations.get(id);
912
+ if (!operation) return;
913
+ for (const n of numbers) {
914
+ const sent = this.#sentBy.get(n);
915
+ if (sent?.shownIn !== operation) continue;
916
+ void this.#record({
917
+ kind: "message-delivery",
918
+ message: n,
919
+ from: sent.from,
920
+ to: sent.to,
921
+ operationId: id,
922
+ state,
923
+ });
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Where a turn that ended without a result goes, in the design's order: what continues it, a
929
+ * wait, the one reminder, then the check-in or the end of a message operation. A reminder's turn
930
+ * ending still owing fails what it owes.
931
+ */
932
+ turnEnded(id: string): TurnEnd {
933
+ const operation = this.#operations.get(id);
934
+ if (!operation || operation.ended)
935
+ return { kind: operation?.kind === "message" ? "settle" : "check-in" };
936
+ if (this.#pending(operation) === "continues") return { kind: "continue" };
937
+ const waited = operation.awaits;
938
+ if (waited?.state === "open") return { kind: "wait", peer: waited.owedBy, until: waited.until };
939
+ // Each question gets one reminder; a turn that ends still owing one it was reminded of fails it.
940
+ for (const owed of [...operation.owes]) {
941
+ if (owed.reminded) this.#end(owed, "failed", "it did not reply after a reminder");
942
+ }
943
+ const unreminded = [...operation.owes].filter((owed) => owed.state === "open");
944
+ if (unreminded.length > 0) {
945
+ for (const owed of unreminded) owed.reminded = true;
946
+ return {
947
+ kind: "remind",
948
+ text: reminder(
949
+ unreminded.map((owed) => owed.asker),
950
+ this.#recipient(operation),
951
+ ),
952
+ };
953
+ }
954
+ return { kind: operation.kind === "workflow" ? "check-in" : "settle" };
955
+ }
956
+
957
+ /**
958
+ * The reminder's response window passed with answers still owed: they fail now, and their askers
959
+ * are told, though the turn may go on.
960
+ */
961
+ reminderExpired(id: string): void {
962
+ const operation = this.#operations.get(id);
963
+ if (!operation) return;
964
+ for (const owed of [...operation.owes]) {
965
+ if (owed.reminded) this.#end(owed, "failed", "it did not reply after a reminder");
966
+ }
967
+ }
968
+
969
+ /**
970
+ * What is held for an operation whose turn has ended, deciding nothing: `continues` when its turn
971
+ * end would go on with it, `held` when messages wait for a later operation or to cut a check-in's
972
+ * `wf waiting` short, `none` otherwise.
973
+ */
974
+ pending(id: string): Pending {
975
+ const operation = this.#operations.get(id);
976
+ return operation && !operation.ended ? this.#pending(operation) : "none";
977
+ }
978
+
979
+ #pending(operation: Operation): Pending {
980
+ const held = this.#inbox(operation.agent);
981
+ if (held.length === 0) return "none";
982
+ const its = held.some(
983
+ (item) =>
984
+ (item.kind === "no-reply" && item.for === operation) ||
985
+ (item.kind === "message" && item.answers?.askerOperation === operation),
986
+ );
987
+ if (its) return "continues";
988
+ const messages = held.some((item) => item.kind === "message");
989
+ // A waiting operation is continued by a question as well; a workflow's by anything held. A
990
+ // message operation that waits on nothing ends, and what is held opens the next one.
991
+ if (operation.awaits?.state === "open") {
992
+ return held.some((item) => item.kind === "message" && item.asks?.state === "open")
993
+ ? "continues"
994
+ : messages
995
+ ? "held"
996
+ : "none";
997
+ }
998
+ if (operation.kind === "workflow" && messages) return "continues";
999
+ return messages ? "held" : "none";
1000
+ }
1001
+
1002
+ /** The route lesson for an operation's prompt: empty for an agent with no routes. */
1003
+ lesson(agent: AgentKey): string {
1004
+ return routeLesson(
1005
+ this.#routesOf(agent).map((route) => this.#line(route, agent)),
1006
+ this.#host.launcher(agent),
1007
+ );
1008
+ }
1009
+
1010
+ // Seeing
1011
+
1012
+ peers(agent: AgentKey): {
1013
+ peers: PeerView[];
1014
+ owes: AgentKey[];
1015
+ awaits?: { peer: AgentKey; until: number };
1016
+ text: string;
1017
+ } {
1018
+ const peers = this.#routesOf(agent, true).map((route): PeerView => {
1019
+ const peer = peerOf(route, agent);
1020
+ const view = this.#host.agent(peer);
1021
+ const waited = this.#current.get(peer)?.awaits;
1022
+ const awaiting = waited?.state === "open" ? waited.owedBy : undefined;
1023
+ const queued = this.#inbox(peer).filter(
1024
+ (item) => item.kind !== "no-reply" && item.from === agent,
1025
+ ).length;
1026
+ return {
1027
+ peer,
1028
+ can: this.#can(route, agent),
1029
+ state:
1030
+ view?.state === "stopped" ? "stopped" : awaiting ? "awaiting" : (view?.state ?? "idle"),
1031
+ ...(awaiting && view?.state !== "stopped" ? { awaits: awaiting } : {}),
1032
+ ...(view?.state === "stopped" && view.reason ? { reason: view.reason } : {}),
1033
+ queued,
1034
+ purpose: route.purpose,
1035
+ };
1036
+ });
1037
+ const { owes, awaits } = this.#standing(agent);
1038
+ return {
1039
+ peers,
1040
+ owes,
1041
+ ...(awaits ? { awaits } : {}),
1042
+ text: peersTable(peers, awaits, owes, this.#host.now(), agent),
1043
+ };
1044
+ }
1045
+
1046
+ /** The line `wf` leads with while this agent's operation owes or awaits an answer. */
1047
+ owing(agent: AgentKey): string | undefined {
1048
+ const { owes, awaits } = this.#standing(agent);
1049
+ return owingLine(owes, awaits, this.#host.launcher(agent), this.#host.now());
1050
+ }
1051
+
1052
+ // Ending
1053
+
1054
+ /**
1055
+ * The agent stopped: what it asked is dropped, what it owed fails, and its routes close. Its own
1056
+ * operation's end, if the runner records one too, changes nothing after this.
1057
+ */
1058
+ agentStopped(agent: AgentKey, reason: string): void {
1059
+ for (const owed of this.#obligations) {
1060
+ if (owed.asker === agent && owed.state === "open") {
1061
+ this.#end(owed, owed.request.shown ? "released" : "withdrawn", `${agent} stopped`);
1062
+ }
1063
+ if (owed.owedBy === agent && owed.state === "open") {
1064
+ this.#end(owed, "failed", `it stopped (${JSON.stringify(reason)}) before replying`);
1065
+ }
1066
+ }
1067
+ for (const route of this.#openRoutes()) {
1068
+ if (directions(route.ends).some((pair) => pair.includes(agent))) {
1069
+ this.#closeRoute(route, "agent-stopped");
1070
+ }
1071
+ }
1072
+ for (const item of this.#inbox(agent)) {
1073
+ if (item.kind !== "no-reply") this.#undelivered(item, "route-closed");
1074
+ }
1075
+ this.#inboxes.delete(agent);
1076
+ }
1077
+
1078
+ /** The attempt ended: nothing more is delivered, and what was held is recorded undelivered. */
1079
+ close(): void {
1080
+ if (this.#closed) return;
1081
+ this.#closed = true;
1082
+ for (const owed of this.#obligations) {
1083
+ if (owed.state === "open")
1084
+ this.#end(owed, "failed", "the attempt ended", {
1085
+ undelivered: "attempt-ended",
1086
+ tell: false,
1087
+ });
1088
+ }
1089
+ for (const [agent, held] of this.#inboxes) {
1090
+ for (const item of held)
1091
+ if (item.kind !== "no-reply") this.#undelivered(item, "attempt-ended");
1092
+ this.#inboxes.delete(agent);
1093
+ }
1094
+ for (const route of this.#openRoutes()) this.#closeRoute(route, "attempt-ended");
1095
+ }
1096
+
1097
+ // Internals
1098
+
1099
+ #inbox(agent: AgentKey): Held[] {
1100
+ const held = this.#inboxes.get(agent);
1101
+ if (held) return held;
1102
+ const fresh: Held[] = [];
1103
+ this.#inboxes.set(agent, fresh);
1104
+ return fresh;
1105
+ }
1106
+
1107
+ #open(from: AgentKey, to: AgentKey): Route | undefined {
1108
+ return this.#routes.get(pairKey(from, to))?.find((route) => route.open);
1109
+ }
1110
+
1111
+ #openRoutes(): Route[] {
1112
+ return [...new Set([...this.#routes.values()].flat())].filter((route) => route.open);
1113
+ }
1114
+
1115
+ /**
1116
+ * This agent's open routes, either end, in the order granted; with `stopped`, also those a peer's
1117
+ * stop closed, so `wf peers` can say it stopped.
1118
+ */
1119
+ #routesOf(agent: AgentKey, stopped = false): Route[] {
1120
+ return [...new Set([...this.#routes.values()].flat())]
1121
+ .filter(
1122
+ (route) =>
1123
+ (route.open || (stopped && route.closed === "agent-stopped")) &&
1124
+ directions(route.ends).some((pair) => pair.includes(agent)),
1125
+ )
1126
+ .sort((a, b) => a.order - b.order);
1127
+ }
1128
+
1129
+ #can(route: Route, agent: AgentKey): PeerView["can"] {
1130
+ return route.ends.between ? "both" : route.ends.from === agent ? "send" : "reply";
1131
+ }
1132
+
1133
+ #line(route: Route, agent: AgentKey): RouteLine {
1134
+ return { peer: peerOf(route, agent), can: this.#can(route, agent), purpose: route.purpose };
1135
+ }
1136
+
1137
+ #recipient(operation: Operation): Recipient {
1138
+ return {
1139
+ launcher: this.#host.launcher(operation.agent),
1140
+ callId: operation.id,
1141
+ kind: operation.kind,
1142
+ };
1143
+ }
1144
+
1145
+ #owes(operation: Operation): AgentKey[] {
1146
+ return [...operation.owes].filter((each) => each.state === "open").map((each) => each.asker);
1147
+ }
1148
+
1149
+ #standing(agent: AgentKey): { owes: AgentKey[]; awaits?: { peer: AgentKey; until: number } } {
1150
+ const operation = this.#current.get(agent);
1151
+ if (!operation) return { owes: [] };
1152
+ const waited = operation.awaits?.state === "open" ? operation.awaits : undefined;
1153
+ return {
1154
+ owes: this.#owes(operation),
1155
+ ...(waited ? { awaits: { peer: waited.owedBy, until: waited.until } } : {}),
1156
+ };
1157
+ }
1158
+
1159
+ /** Messages held for an agent and not yet shown to it, answers included. */
1160
+ held(agent: AgentKey): number {
1161
+ return this.#inbox(agent).filter((item) => item.kind !== "no-reply").length;
1162
+ }
1163
+
1164
+ /** Messages held for an agent that count against its unread limit: answers do not. */
1165
+ #unread(agent: AgentKey): number {
1166
+ return this.#inbox(agent).filter((item) => item.kind !== "no-reply" && !item.answers).length;
1167
+ }
1168
+
1169
+ /**
1170
+ * "Say so in your result" where a workflow's operation would, "end your turn" or "say so in your
1171
+ * reply" in a message operation, which takes no result.
1172
+ */
1173
+ #sayIt(operation: Operation, workflowText: string, lower = false): string {
1174
+ if (operation.kind === "workflow") return workflowText;
1175
+ const text = this.#owes(operation).length ? "Say so in your reply." : "End your turn.";
1176
+ return lower ? text.charAt(0).toLowerCase() + text.slice(1) : text;
1177
+ }
1178
+
1179
+ /**
1180
+ * An owed answer ends without one: its asker is told, if it still waits on it. A question never
1181
+ * shown is undelivered with it, unless it is an answer that asked back: that answer still goes,
1182
+ * its question dropped. `gone`: the caller has already taken the question out and recorded it.
1183
+ */
1184
+ #end(
1185
+ owed: Obligation,
1186
+ end: Exclude<RequestEnd, "replied">,
1187
+ reason: string,
1188
+ options: { gone?: true; undelivered?: UndeliveredReason; tell?: false } = {},
1189
+ ): void {
1190
+ if (owed.state !== "open") return;
1191
+ owed.state = end;
1192
+ owed.endedAt = this.#host.now();
1193
+ owed.cancelTimer?.();
1194
+ owed.owingOperation?.owes.delete(owed);
1195
+ const { request } = owed;
1196
+ // Its reserved room goes; a question never shown no longer counts either. An answer's own room
1197
+ // was its question's.
1198
+ request.route.count -= request.shown || request.answers ? 1 : 2;
1199
+ if (!request.shown && !options.gone) {
1200
+ if (request.answers) request.asks = undefined;
1201
+ else {
1202
+ this.#inboxes.set(
1203
+ owed.owedBy,
1204
+ this.#inbox(owed.owedBy).filter((item) => item !== request),
1205
+ );
1206
+ this.#undelivered(
1207
+ request,
1208
+ options.undelivered ?? (end === "failed" ? "route-closed" : "withdrawn"),
1209
+ );
1210
+ }
1211
+ }
1212
+ void this.#record({
1213
+ kind: "request-ended",
1214
+ message: owed.request.n,
1215
+ owedBy: owed.owedBy,
1216
+ asker: owed.asker,
1217
+ end,
1218
+ reason,
1219
+ });
1220
+ const operation = owed.askerOperation;
1221
+ if (operation.awaits === owed) operation.awaits = undefined;
1222
+ if (options.tell !== false && !operation.ended && (end === "failed" || end === "timed-out")) {
1223
+ this.#inbox(owed.asker).push({ kind: "no-reply", for: operation, from: owed.owedBy, reason });
1224
+ this.#host.changed(owed.asker);
1225
+ }
1226
+ }
1227
+
1228
+ #withdrawHeldFor(operation: Operation): void {
1229
+ const held = this.#inbox(operation.agent);
1230
+ const withdrawn = held.filter(
1231
+ (item) =>
1232
+ (item.kind === "no-reply" && item.for === operation) ||
1233
+ (item.kind === "message" && item.answers?.askerOperation === operation),
1234
+ );
1235
+ if (withdrawn.length === 0) return;
1236
+ this.#inboxes.set(
1237
+ operation.agent,
1238
+ held.filter((item) => !withdrawn.includes(item)),
1239
+ );
1240
+ for (const item of withdrawn) {
1241
+ if (item.kind !== "message") continue;
1242
+ this.#undelivered(item, "withdrawn");
1243
+ // An answer that asked back takes its question with it: its asker is told.
1244
+ if (item.asks?.state === "open") {
1245
+ this.#end(item.asks, "failed", `${operation.agent} finished before it saw the question`, {
1246
+ gone: true,
1247
+ });
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * Takes held messages out of an agent's inbox, undelivered on a closing route: a question among
1254
+ * them fails, and its asker is told why.
1255
+ */
1256
+ #drop(agent: AgentKey, dropped: readonly Message[], reason: string): void {
1257
+ if (dropped.length === 0) return;
1258
+ this.#inboxes.set(
1259
+ agent,
1260
+ this.#inbox(agent).filter((item) => !dropped.includes(item as Message)),
1261
+ );
1262
+ for (const message of dropped) {
1263
+ this.#undelivered(message, "route-closed");
1264
+ if (message.asks?.state === "open") this.#end(message.asks, "failed", reason, { gone: true });
1265
+ }
1266
+ }
1267
+
1268
+ #closeRoute(route: Route, reason: "stage-ended" | "agent-stopped" | "attempt-ended"): void {
1269
+ if (!route.open) return;
1270
+ route.open = false;
1271
+ route.closed = reason;
1272
+ void this.#record({ kind: "route-closed", ...route.ends, reason });
1273
+ // What it held and had not shown is undelivered; an answer already accepted still goes.
1274
+ for (const [agent, held] of this.#inboxes) {
1275
+ this.#drop(
1276
+ agent,
1277
+ held.filter(
1278
+ (item): item is Message =>
1279
+ item.kind === "message" && item.route === route && !item.answers,
1280
+ ),
1281
+ "its route closed before it saw the question",
1282
+ );
1283
+ }
1284
+ }
1285
+
1286
+ #undelivered(message: Message, reason: UndeliveredReason): void {
1287
+ void this.#record({
1288
+ kind: "message-undelivered",
1289
+ message: message.n,
1290
+ from: message.from,
1291
+ to: message.to,
1292
+ reason,
1293
+ });
1294
+ }
1295
+
1296
+ #record(event: MessagingEventBody): Promise<void> {
1297
+ return this.#host.record(event).catch(() => undefined);
1298
+ }
1299
+ }
1300
+
1301
+ function refuse<C extends SendCode | ReplyCode>(code: C, error: string) {
1302
+ return { kind: "refused" as const, code, error };
1303
+ }