@agentswf/contract 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.
package/src/wire.ts ADDED
@@ -0,0 +1,578 @@
1
+ import { isRecord } from "./schema";
2
+
3
+ /**
4
+ * Version 4 adds messaging: `peers`, `send` and `reply`, which name no call id, and the line every
5
+ * response leads with about what the agent's open operation owes or awaits. The per-agent
6
+ * connection remains the authority.
7
+ */
8
+ export const WIRE_VERSION = 4 as const;
9
+
10
+ export type ResultSubmitRequest = {
11
+ version: typeof WIRE_VERSION;
12
+ command: "result";
13
+ operationId: string;
14
+ raw: string;
15
+ /**
16
+ * The harness's own session id, where the agent's shell names one. Added within version 2: the
17
+ * engine installs the `wf` its agents run, so client and engine always ship together.
18
+ */
19
+ session?: string;
20
+ };
21
+
22
+ export type WaitingRequest = {
23
+ version: typeof WIRE_VERSION;
24
+ command: "waiting";
25
+ operationId: string;
26
+ reason: string;
27
+ timeoutMs?: number;
28
+ session?: string;
29
+ };
30
+
31
+ /** The agent's routes and its peers' states, as `wf peers` shows them. */
32
+ export type PeersRequest = {
33
+ version: typeof WIRE_VERSION;
34
+ command: "peers";
35
+ session?: string;
36
+ };
37
+
38
+ /**
39
+ * A message to `peer`, bound to the agent's open operation when the engine accepts it. `reply`
40
+ * answers the question `peer` asked; with `awaitReply` either one asks.
41
+ */
42
+ export type MessageRequest = {
43
+ version: typeof WIRE_VERSION;
44
+ command: "send" | "reply";
45
+ peer: string;
46
+ text: string;
47
+ awaitReply?: true;
48
+ /** Shortens the wait for the answer; only with `awaitReply`. */
49
+ timeoutMs?: number;
50
+ session?: string;
51
+ };
52
+
53
+ export type ControlRequest = ResultSubmitRequest | WaitingRequest | PeersRequest | MessageRequest;
54
+ export const MAX_WAITING_REASON_BYTES = 2048;
55
+
56
+ export function validWaitingReason(value: unknown): value is string {
57
+ if (!nonEmpty(value)) return false;
58
+ let bytes = 0;
59
+ for (const char of value) {
60
+ const point = char.codePointAt(0)!;
61
+ bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
62
+ if (bytes > MAX_WAITING_REASON_BYTES) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /** Refusals of a request itself, which any command can get. */
68
+ export const ENVELOPE_CODES = [
69
+ "invalid-request",
70
+ "unsupported-version",
71
+ "unsupported-command",
72
+ "request-too-large",
73
+ "internal-error",
74
+ ] as const;
75
+
76
+ /** `wf result`'s and `wf waiting`'s own refusals. */
77
+ export const RESULT_CODES = [
78
+ "unknown-operation",
79
+ "wrong-agent",
80
+ "expired-operation",
81
+ "closed-operation",
82
+ "invalid-result",
83
+ /** While the operation owes an answer. */
84
+ "owes-reply",
85
+ /** Given a message operation's call id. */
86
+ "no-result",
87
+ ] as const;
88
+
89
+ /** A send's refusals, in the order they are checked. */
90
+ export const SEND_CODES = [
91
+ "no-operation",
92
+ "no-route",
93
+ "peer-stopped",
94
+ "closed",
95
+ "too-large",
96
+ "awaiting",
97
+ "unsupported",
98
+ "cycle",
99
+ "limit",
100
+ "inbox-full",
101
+ ] as const;
102
+
103
+ /** A reply's refusals, in the order they are checked; the last four only with `awaitReply`. */
104
+ export const REPLY_CODES = [
105
+ "no-operation",
106
+ "none-owed",
107
+ "expired",
108
+ "closed",
109
+ "too-large",
110
+ "awaiting",
111
+ "unsupported",
112
+ "cycle",
113
+ "limit",
114
+ ] as const;
115
+
116
+ export type EnvelopeCode = (typeof ENVELOPE_CODES)[number];
117
+ export type ResultSubmitCode = (typeof RESULT_CODES)[number] | EnvelopeCode;
118
+ export type SendCode = (typeof SEND_CODES)[number];
119
+ export type ReplyCode = (typeof REPLY_CODES)[number];
120
+ export type ControlCode = ResultSubmitCode | SendCode | ReplyCode;
121
+
122
+ /**
123
+ * Leads every response while the agent's open operation owes or awaits an answer: one line, such as
124
+ * "you owe reviewer a reply (wf reply reviewer).", which brings it back after a compaction.
125
+ */
126
+ export type Owing = { owing?: string };
127
+
128
+ export type Rejection<Code extends ControlCode = ControlCode> = {
129
+ version: typeof WIRE_VERSION;
130
+ kind: "rejected";
131
+ code: Code;
132
+ /** The refusal as `wf` prints it after its code, naming the next action. */
133
+ error: string;
134
+ } & Owing;
135
+
136
+ export type ResultSubmitResponse =
137
+ | ({ version: typeof WIRE_VERSION; kind: "accepted" } & Owing)
138
+ | Rejection<ResultSubmitCode>;
139
+
140
+ export type WaitingResponse =
141
+ | ({ version: typeof WIRE_VERSION; kind: "waiting"; waitUntil: number; deadline: number } & Owing)
142
+ /**
143
+ * `wf waiting` while the operation awaits `peer`'s answer, which it does until `until`: it changes
144
+ * nothing, and a `--timeout` given with it is ignored.
145
+ */
146
+ | ({
147
+ version: typeof WIRE_VERSION;
148
+ kind: "awaiting-reply";
149
+ peer: string;
150
+ until: number;
151
+ /** The acknowledgement as `wf` prints it. */
152
+ notice: string;
153
+ } & Owing)
154
+ | Rejection<ResultSubmitCode>;
155
+
156
+ /** A peer as `wf peers --json` prints it. */
157
+ export type PeerView = {
158
+ peer: string;
159
+ /** `send` on a route from this agent, `reply` on one only to it, `both` either way. */
160
+ can: "send" | "reply" | "both";
161
+ state: "not-started" | "working" | "idle" | "awaiting" | "stopped";
162
+ /** Whose answer it awaits, when `awaiting`. */
163
+ awaits?: string;
164
+ /** Why it stopped, when `stopped`. */
165
+ reason?: string;
166
+ /** This agent's messages it has not been shown yet. */
167
+ queued: number;
168
+ purpose: string;
169
+ };
170
+
171
+ export type PeersResponse =
172
+ | ({
173
+ version: typeof WIRE_VERSION;
174
+ kind: "peers";
175
+ peers: PeerView[];
176
+ /** Who the open operation owes an answer. */
177
+ owes: string[];
178
+ /** Whose answer it awaits, and until when. */
179
+ awaits?: { peer: string; until: number };
180
+ /** The table `wf peers` prints. */
181
+ text: string;
182
+ } & Owing)
183
+ | Rejection<EnvelopeCode>;
184
+
185
+ /** A message admitted: `message` numbers it in the run's record; `until` ends a wait it began. */
186
+ export type MessageResponse =
187
+ | ({
188
+ version: typeof WIRE_VERSION;
189
+ kind: "sent";
190
+ message: number;
191
+ until?: number;
192
+ /** The acknowledgement as `wf` prints it. */
193
+ notice: string;
194
+ } & Owing)
195
+ | Rejection<SendCode | ReplyCode | EnvelopeCode>;
196
+
197
+ export type ControlResponse =
198
+ | ResultSubmitResponse
199
+ | WaitingResponse
200
+ | PeersResponse
201
+ | MessageResponse
202
+ | Rejection;
203
+
204
+ export type WireDecodeResult<T> =
205
+ | { ok: true; value: T }
206
+ | {
207
+ ok: false;
208
+ code: "invalid-request" | "unsupported-version" | "unsupported-command";
209
+ error: string;
210
+ };
211
+
212
+ const REQUEST_FIELDS = ["version", "command", "operationId", "raw", "session"] as const;
213
+ const RESPONSE_CODES = new Set<ControlCode>([
214
+ ...ENVELOPE_CODES,
215
+ ...RESULT_CODES,
216
+ ...SEND_CODES,
217
+ ...REPLY_CODES,
218
+ ]);
219
+
220
+ /** What each command's response may be. */
221
+ export type ResponseFor = {
222
+ result: ResultSubmitResponse;
223
+ waiting: WaitingResponse;
224
+ peers: PeersResponse;
225
+ send: MessageResponse;
226
+ reply: MessageResponse;
227
+ };
228
+
229
+ const ANSWERS: {
230
+ [C in keyof ResponseFor]: { kinds: readonly string[]; codes: readonly ControlCode[] };
231
+ } = {
232
+ result: { kinds: ["accepted"], codes: [...ENVELOPE_CODES, ...RESULT_CODES] },
233
+ waiting: { kinds: ["waiting", "awaiting-reply"], codes: [...ENVELOPE_CODES, ...RESULT_CODES] },
234
+ peers: { kinds: ["peers"], codes: ENVELOPE_CODES },
235
+ send: { kinds: ["sent"], codes: [...ENVELOPE_CODES, ...SEND_CODES] },
236
+ reply: { kinds: ["sent"], codes: [...ENVELOPE_CODES, ...REPLY_CODES] },
237
+ };
238
+
239
+ function checkEnvelope(value: unknown, label: string): WireDecodeResult<Record<string, unknown>> {
240
+ if (!isRecord(value)) return invalid(`${label} must be a JSON object`);
241
+ if (typeof value.version !== "number" || !Number.isSafeInteger(value.version)) {
242
+ return invalid(`${label}.version must be an integer`);
243
+ }
244
+ if (value.version !== WIRE_VERSION) {
245
+ return {
246
+ ok: false,
247
+ code: "unsupported-version",
248
+ error: `unsupported wire version; expected ${WIRE_VERSION}`,
249
+ };
250
+ }
251
+ return { ok: true, value };
252
+ }
253
+
254
+ export function decodeResultSubmitRequest(value: unknown): WireDecodeResult<ResultSubmitRequest> {
255
+ const envelope = checkEnvelope(value, "request");
256
+ if (!envelope.ok) return envelope;
257
+ const request = envelope.value;
258
+ if (request.command !== "result") return invalid("request.command must be result");
259
+ if (extraFields(request, REQUEST_FIELDS)) return invalid("request has unexpected fields");
260
+ if (!nonEmpty(request.operationId)) {
261
+ return invalid("request.operationId must be a non-empty string");
262
+ }
263
+ if (typeof request.raw !== "string" || request.raw.trim() === "") {
264
+ return invalid("request.raw must be a non-empty string");
265
+ }
266
+ if (!optionalNonEmpty(request.session)) {
267
+ return invalid("request.session must be a non-empty string when present");
268
+ }
269
+ return {
270
+ ok: true,
271
+ value: {
272
+ version: WIRE_VERSION,
273
+ command: "result",
274
+ operationId: request.operationId,
275
+ raw: request.raw,
276
+ ...(request.session === undefined ? {} : { session: request.session }),
277
+ },
278
+ };
279
+ }
280
+
281
+ export function decodeControlRequest(value: unknown): WireDecodeResult<ControlRequest> {
282
+ const envelope = checkEnvelope(value, "request");
283
+ if (!envelope.ok) return envelope;
284
+ const request = envelope.value;
285
+ switch (request.command) {
286
+ case "result":
287
+ return decodeResultSubmitRequest(request);
288
+ case "waiting":
289
+ return decodeWaitingRequest(request);
290
+ case "peers":
291
+ return decodePeersRequest(request);
292
+ case "send":
293
+ case "reply":
294
+ return decodeMessageRequest(request, request.command);
295
+ default:
296
+ return {
297
+ ok: false,
298
+ code: "unsupported-command",
299
+ error: "request.command must be result, waiting, peers, send or reply",
300
+ };
301
+ }
302
+ }
303
+
304
+ function decodeWaitingRequest(request: Record<string, unknown>): WireDecodeResult<WaitingRequest> {
305
+ if (extraFields(request, ["version", "command", "operationId", "reason", "timeoutMs", "session"]))
306
+ return invalid("request has unexpected fields");
307
+ if (!nonEmpty(request.operationId))
308
+ return invalid("request.operationId must be a non-empty string");
309
+ if (!validWaitingReason(request.reason))
310
+ return invalid("request.reason must be non-blank and at most 2048 UTF-8 bytes");
311
+ if (request.timeoutMs !== undefined && !positiveInteger(request.timeoutMs))
312
+ return invalid("request.timeoutMs must be a positive safe integer when present");
313
+ if (!optionalNonEmpty(request.session))
314
+ return invalid("request.session must be a non-empty string when present");
315
+ return {
316
+ ok: true,
317
+ value: {
318
+ version: WIRE_VERSION,
319
+ command: "waiting",
320
+ operationId: request.operationId,
321
+ reason: request.reason,
322
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
323
+ ...(request.session === undefined ? {} : { session: request.session }),
324
+ },
325
+ };
326
+ }
327
+
328
+ function decodePeersRequest(request: Record<string, unknown>): WireDecodeResult<PeersRequest> {
329
+ if (extraFields(request, ["version", "command", "session"]))
330
+ return invalid("request has unexpected fields");
331
+ if (!optionalNonEmpty(request.session))
332
+ return invalid("request.session must be a non-empty string when present");
333
+ return {
334
+ ok: true,
335
+ value: {
336
+ version: WIRE_VERSION,
337
+ command: "peers",
338
+ ...(request.session === undefined ? {} : { session: request.session }),
339
+ },
340
+ };
341
+ }
342
+
343
+ function decodeMessageRequest(
344
+ request: Record<string, unknown>,
345
+ command: "send" | "reply",
346
+ ): WireDecodeResult<MessageRequest> {
347
+ if (
348
+ extraFields(request, [
349
+ "version",
350
+ "command",
351
+ "peer",
352
+ "text",
353
+ "awaitReply",
354
+ "timeoutMs",
355
+ "session",
356
+ ])
357
+ )
358
+ return invalid("request has unexpected fields");
359
+ if (!nonEmpty(request.peer) || request.peer !== request.peer.trim())
360
+ return invalid("request.peer must be an agent's key, without surrounding spaces");
361
+ if (!nonEmpty(request.text)) return invalid("request.text must be a non-blank string");
362
+ if (request.awaitReply !== undefined && request.awaitReply !== true)
363
+ return invalid("request.awaitReply must be true when present");
364
+ if (request.timeoutMs !== undefined) {
365
+ if (!positiveInteger(request.timeoutMs))
366
+ return invalid("request.timeoutMs must be a positive safe integer when present");
367
+ if (request.awaitReply !== true) return invalid("request.timeoutMs needs awaitReply");
368
+ }
369
+ if (!optionalNonEmpty(request.session))
370
+ return invalid("request.session must be a non-empty string when present");
371
+ return {
372
+ ok: true,
373
+ value: {
374
+ version: WIRE_VERSION,
375
+ command,
376
+ peer: request.peer,
377
+ text: request.text,
378
+ ...(request.awaitReply ? { awaitReply: true as const } : {}),
379
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
380
+ ...(request.session === undefined ? {} : { session: request.session }),
381
+ },
382
+ };
383
+ }
384
+
385
+ export function decodeControlResponse(value: unknown): WireDecodeResult<ControlResponse> {
386
+ const envelope = checkEnvelope(value, "response");
387
+ if (!envelope.ok) return envelope;
388
+ const response = envelope.value;
389
+ if (!optionalNonEmpty(response.owing))
390
+ return invalid("response.owing must be a non-empty string when present");
391
+ const owing = response.owing === undefined ? {} : { owing: response.owing };
392
+ const version = WIRE_VERSION;
393
+ switch (response.kind) {
394
+ case "accepted":
395
+ if (extraFields(response, ["version", "kind", "owing"]))
396
+ return invalid("accepted response has unexpected fields");
397
+ return { ok: true, value: { version, kind: "accepted", ...owing } };
398
+ case "waiting": {
399
+ if (extraFields(response, ["version", "kind", "waitUntil", "deadline", "owing"]))
400
+ return invalid("waiting response has unexpected fields");
401
+ const { waitUntil, deadline } = response;
402
+ if (!time(waitUntil) || !time(deadline) || deadline < waitUntil)
403
+ return invalid(
404
+ "waiting response requires non-negative integer times with waitUntil <= deadline",
405
+ );
406
+ return { ok: true, value: { version, kind: "waiting", waitUntil, deadline, ...owing } };
407
+ }
408
+ case "awaiting-reply": {
409
+ if (extraFields(response, ["version", "kind", "peer", "until", "notice", "owing"]))
410
+ return invalid("awaiting-reply response has unexpected fields");
411
+ const { peer, until, notice } = response;
412
+ if (!nonEmpty(peer)) return invalid("response.peer must be a non-empty string");
413
+ if (!time(until)) return invalid("response.until must be a non-negative integer time");
414
+ if (!nonEmpty(notice)) return invalid("response.notice must be a non-empty string");
415
+ return {
416
+ ok: true,
417
+ value: { version, kind: "awaiting-reply", peer, until, notice, ...owing },
418
+ };
419
+ }
420
+ case "peers":
421
+ return decodePeersResponse(response, owing);
422
+ case "sent": {
423
+ if (extraFields(response, ["version", "kind", "message", "until", "notice", "owing"]))
424
+ return invalid("sent response has unexpected fields");
425
+ const { message, until, notice } = response;
426
+ if (typeof message !== "number" || !Number.isSafeInteger(message) || message < 1)
427
+ return invalid("response.message must be a positive integer");
428
+ if (until !== undefined && !time(until))
429
+ return invalid("response.until must be a non-negative integer time when present");
430
+ if (!nonEmpty(notice)) return invalid("response.notice must be a non-empty string");
431
+ return {
432
+ ok: true,
433
+ value: {
434
+ version,
435
+ kind: "sent",
436
+ message,
437
+ ...(until === undefined ? {} : { until }),
438
+ notice,
439
+ ...owing,
440
+ },
441
+ };
442
+ }
443
+ case "rejected": {
444
+ if (extraFields(response, ["version", "kind", "code", "error", "owing"]))
445
+ return invalid("rejected response has unexpected fields");
446
+ if (!RESPONSE_CODES.has(response.code as ControlCode))
447
+ return invalid("response.code is not recognized");
448
+ if (!nonEmpty(response.error)) return invalid("response.error must be a non-empty string");
449
+ return {
450
+ ok: true,
451
+ value: {
452
+ version,
453
+ kind: "rejected",
454
+ code: response.code as ControlCode,
455
+ error: response.error,
456
+ ...owing,
457
+ },
458
+ };
459
+ }
460
+ default:
461
+ return invalid(
462
+ "response.kind must be accepted, waiting, awaiting-reply, peers, sent or rejected",
463
+ );
464
+ }
465
+ }
466
+
467
+ const PEER_STATES = new Set(["not-started", "working", "idle", "awaiting", "stopped"]);
468
+ const PEER_ABILITIES = new Set(["send", "reply", "both"]);
469
+
470
+ function decodePeersResponse(
471
+ response: Record<string, unknown>,
472
+ owing: Owing,
473
+ ): WireDecodeResult<ControlResponse> {
474
+ if (extraFields(response, ["version", "kind", "peers", "owes", "awaits", "text", "owing"]))
475
+ return invalid("peers response has unexpected fields");
476
+ const { peers, owes, awaits, text } = response;
477
+ if (!Array.isArray(owes) || !owes.every(nonEmpty))
478
+ return invalid("response.owes must be a list of peers");
479
+ if (
480
+ awaits !== undefined &&
481
+ !(
482
+ isRecord(awaits) &&
483
+ !extraFields(awaits, ["peer", "until"]) &&
484
+ nonEmpty(awaits.peer) &&
485
+ time(awaits.until)
486
+ )
487
+ )
488
+ return invalid("response.awaits must name a peer and a time when present");
489
+ if (typeof text !== "string") return invalid("response.text must be a string");
490
+ if (!Array.isArray(peers)) return invalid("response.peers must be a list");
491
+ const views: PeerView[] = [];
492
+ for (const each of peers) {
493
+ if (
494
+ !isRecord(each) ||
495
+ extraFields(each, ["peer", "can", "state", "awaits", "reason", "queued", "purpose"]) ||
496
+ !nonEmpty(each.peer) ||
497
+ !PEER_ABILITIES.has(each.can as string) ||
498
+ !PEER_STATES.has(each.state as string) ||
499
+ !optionalNonEmpty(each.awaits) ||
500
+ !optionalNonEmpty(each.reason) ||
501
+ typeof each.queued !== "number" ||
502
+ !Number.isSafeInteger(each.queued) ||
503
+ each.queued < 0 ||
504
+ typeof each.purpose !== "string"
505
+ )
506
+ return invalid("response.peers holds a malformed peer");
507
+ views.push({
508
+ peer: each.peer,
509
+ can: each.can as PeerView["can"],
510
+ state: each.state as PeerView["state"],
511
+ ...(each.awaits === undefined ? {} : { awaits: each.awaits }),
512
+ ...(each.reason === undefined ? {} : { reason: each.reason }),
513
+ queued: each.queued,
514
+ purpose: each.purpose,
515
+ });
516
+ }
517
+ return {
518
+ ok: true,
519
+ value: {
520
+ version: WIRE_VERSION,
521
+ kind: "peers",
522
+ peers: views,
523
+ owes: owes as string[],
524
+ ...(isRecord(awaits)
525
+ ? { awaits: { peer: awaits.peer as string, until: awaits.until as number } }
526
+ : {}),
527
+ text,
528
+ ...owing,
529
+ },
530
+ };
531
+ }
532
+
533
+ function invalid(error: string): WireDecodeResult<never> {
534
+ return { ok: false, code: "invalid-request", error };
535
+ }
536
+
537
+ function nonEmpty(value: unknown): value is string {
538
+ return typeof value === "string" && value.trim() !== "";
539
+ }
540
+
541
+ function optionalNonEmpty(value: unknown): value is string | undefined {
542
+ return value === undefined || nonEmpty(value);
543
+ }
544
+
545
+ function positiveInteger(value: unknown): value is number {
546
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
547
+ }
548
+
549
+ function time(value: unknown): value is number {
550
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
551
+ }
552
+
553
+ function extraFields(value: Record<string, unknown>, allowed: readonly string[]): boolean {
554
+ return Object.keys(value).some((key) => !allowed.includes(key));
555
+ }
556
+
557
+ /** A response to `command`: one of its acknowledgements, or a refusal with one of its codes. */
558
+ export function decodeResponseFor<C extends keyof ResponseFor>(
559
+ command: C,
560
+ value: unknown,
561
+ ): WireDecodeResult<ResponseFor[C]> {
562
+ const decoded = decodeControlResponse(value);
563
+ if (!decoded.ok) return decoded;
564
+ const response = decoded.value;
565
+ const { kinds, codes } = ANSWERS[command];
566
+ const fits =
567
+ response.kind === "rejected" ? codes.includes(response.code) : kinds.includes(response.kind);
568
+ if (!fits) {
569
+ const what = response.kind === "rejected" ? `refusal ${response.code}` : response.kind;
570
+ return invalid(`the control plane answered ${command} with ${what}, for a different command`);
571
+ }
572
+ // Checked against the command's own answers above.
573
+ return { ok: true, value: response as ResponseFor[C] };
574
+ }
575
+
576
+ export function decodeResultSubmitResponse(value: unknown): WireDecodeResult<ResultSubmitResponse> {
577
+ return decodeResponseFor("result", value);
578
+ }