@nifrajs/agent-protocol 4.0.0 → 5.0.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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * A conformance kit for `AgentBackend` implementations: it drives a backend through the scenarios a
3
+ * host relies on and reports each check, so an adapter for a vendor's agent can prove it before a
4
+ * host ships it. It uses no test runner; assert on the returned report in whichever one you use.
5
+ */
6
+ import { type AgentBackend, type AgentSessionSnapshot, agentEventProblem } from "./index.ts"
7
+
8
+ export interface AgentBackendConformanceOptions {
9
+ /** A fresh backend for each scenario. The kit closes every session it opens. */
10
+ readonly backend: () => AgentBackend | PromiseLike<AgentBackend>
11
+ /** The working directory each session is created in. */
12
+ readonly cwd: string
13
+ /** A message the backend answers with a turn that ends by itself. */
14
+ readonly prompt: string
15
+ /** A message that makes the backend ask for an approval. Enables the approval checks. */
16
+ readonly approvalPrompt?: string
17
+ /** A message whose turn keeps running until it is cancelled. Enables the busy and cancel checks. */
18
+ readonly slowPrompt?: string
19
+ /** Longest one scenario may wait for the backend. Default 10 seconds. */
20
+ readonly timeoutMs?: number
21
+ }
22
+
23
+ export type AgentConformanceStatus = "passed" | "failed" | "skipped"
24
+
25
+ export interface AgentConformanceCheck {
26
+ readonly id: string
27
+ readonly status: AgentConformanceStatus
28
+ /** What went wrong, or why the check was skipped. */
29
+ readonly detail?: string
30
+ }
31
+
32
+ export interface AgentBackendConformanceReport {
33
+ readonly backend: string
34
+ /** No check failed. Skipped checks do not count against it. */
35
+ readonly ok: boolean
36
+ readonly checks: readonly AgentConformanceCheck[]
37
+ }
38
+
39
+ type ReadOutcome = "matched" | "ended" | "threw" | "timeout"
40
+
41
+ const TERMINAL: ReadonlySet<unknown> = new Set([
42
+ "session.completed",
43
+ "session.failed",
44
+ "session.stopped",
45
+ ])
46
+ const DEFAULT_TIMEOUT_MS = 10_000
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value)
50
+ }
51
+
52
+ function recordOf(value: unknown): Record<string, unknown> | undefined {
53
+ return isRecord(value) ? value : undefined
54
+ }
55
+
56
+ function typeOf(event: unknown): unknown {
57
+ return recordOf(event)?.type
58
+ }
59
+
60
+ /** Reads a backend's stream one event at a time, never waiting past a deadline. */
61
+ class EventReader {
62
+ readonly events: unknown[] = []
63
+ error: unknown
64
+ private readonly iterator: AsyncIterator<unknown> | undefined
65
+ private pending: Promise<IteratorResult<unknown>> | undefined
66
+ private finished = false
67
+
68
+ constructor(open: () => AsyncIterable<unknown>) {
69
+ try {
70
+ this.iterator = open()[Symbol.asyncIterator]()
71
+ } catch (error) {
72
+ this.error = error
73
+ this.finished = true
74
+ }
75
+ }
76
+
77
+ async readUntil(until: (event: unknown) => boolean, deadline: number): Promise<ReadOutcome> {
78
+ for (;;) {
79
+ if (this.finished) return this.error === undefined ? "ended" : "threw"
80
+ const iterator = this.iterator
81
+ if (iterator === undefined) return "ended"
82
+ this.pending ??= iterator.next()
83
+ const result = await settleBefore(this.pending, deadline)
84
+ if (result === TIMED_OUT) return "timeout"
85
+ this.pending = undefined
86
+ if (result.kind === "error") {
87
+ this.error = result.error ?? new Error("stream failed")
88
+ this.finished = true
89
+ return "threw"
90
+ }
91
+ if (result.value.done === true) {
92
+ this.finished = true
93
+ return "ended"
94
+ }
95
+ this.events.push(result.value.value)
96
+ if (until(result.value.value)) return "matched"
97
+ }
98
+ }
99
+
100
+ readToEnd(deadline: number): Promise<ReadOutcome> {
101
+ return this.readUntil(() => false, deadline)
102
+ }
103
+
104
+ async stop(): Promise<void> {
105
+ if (this.finished) return
106
+ this.finished = true
107
+ const closing = Promise.resolve()
108
+ .then(() => this.iterator?.return?.())
109
+ .catch(() => undefined)
110
+ // A generator parked in an await cannot return until it resumes, so do not wait on that one.
111
+ if (this.pending === undefined) await settleBefore(closing, Date.now() + 1_000)
112
+ }
113
+ }
114
+
115
+ const TIMED_OUT: unique symbol = Symbol("timed out")
116
+
117
+ type Settled<T> =
118
+ | { readonly kind: "value"; readonly value: T }
119
+ | { readonly kind: "error"; readonly error: unknown }
120
+
121
+ function settleBefore<T>(
122
+ promise: Promise<T>,
123
+ deadline: number,
124
+ ): Promise<Settled<T> | typeof TIMED_OUT> {
125
+ let timer: ReturnType<typeof setTimeout> | undefined
126
+ const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
127
+ timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, deadline - Date.now()))
128
+ })
129
+ const settled = promise.then(
130
+ (value): Settled<T> => ({ kind: "value", value }),
131
+ (error: unknown): Settled<T> => ({ kind: "error", error }),
132
+ )
133
+ return Promise.race([settled, timeout]).finally(() => clearTimeout(timer))
134
+ }
135
+
136
+ function describe(error: unknown): string {
137
+ if (error instanceof Error) return error.message
138
+ const message = recordOf(error)?.message
139
+ return typeof message === "string" ? message : String(error)
140
+ }
141
+
142
+ /** The first way the events break the protocol, or undefined when they are a valid turn. */
143
+ function turnProblem(
144
+ events: readonly unknown[],
145
+ sessionId: string,
146
+ expectEnd: boolean,
147
+ ): string | undefined {
148
+ let lastSeq = -1
149
+ let turnId: unknown
150
+ for (const [index, event] of events.entries()) {
151
+ const problem = agentEventProblem(event)
152
+ if (problem !== undefined || !isRecord(event))
153
+ return `event ${index}: ${problem ?? "not an object"}`
154
+ const record = event
155
+ if (record.sessionId !== sessionId) return `event ${index} names another session`
156
+ const seq = typeof record.seq === "number" ? record.seq : -1
157
+ if (seq <= lastSeq) return `event ${index} has seq ${seq} after ${lastSeq}`
158
+ lastSeq = seq
159
+ if (record.type === "turn.started") {
160
+ if (turnId !== undefined) return `event ${index} starts a second turn`
161
+ turnId = record.turnId
162
+ } else if (record.turnId !== undefined && record.turnId !== turnId) {
163
+ return turnId === undefined
164
+ ? `event ${index} (${String(record.type)}) comes before turn.started`
165
+ : `event ${index} (${String(record.type)}) names another turn`
166
+ }
167
+ if (TERMINAL.has(record.type) && index !== events.length - 1)
168
+ return `event ${index} (${String(record.type)}) ends the turn but more events follow`
169
+ }
170
+ if (turnId === undefined) return "no turn.started event"
171
+ if (expectEnd && !TERMINAL.has(typeOf(events.at(-1))))
172
+ return "the stream ended without session.completed, session.failed, or session.stopped"
173
+ return undefined
174
+ }
175
+
176
+ /**
177
+ * Drive a backend through the scenarios a host depends on: a session's snapshot, a whole turn's
178
+ * events (valid, in order, one turn, one terminal event), a refused second message while busy,
179
+ * cancellation, approvals granted, denied, unknown and cancelled, and a closed session. Scenarios
180
+ * whose prompt is not given are reported as skipped.
181
+ */
182
+ export async function checkAgentBackend(
183
+ options: AgentBackendConformanceOptions,
184
+ ): Promise<AgentBackendConformanceReport> {
185
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
186
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
187
+ throw new RangeError("checkAgentBackend: timeoutMs must be a positive integer")
188
+ const checks: AgentConformanceCheck[] = []
189
+ let backendName = "unknown"
190
+ const pass = (id: string): void => {
191
+ checks.push({ id, status: "passed" })
192
+ }
193
+ const fail = (id: string, detail: string): void => {
194
+ checks.push({ id, status: "failed", detail })
195
+ }
196
+ const skip = (id: string, detail: string): void => {
197
+ checks.push({ id, status: "skipped", detail })
198
+ }
199
+
200
+ const scenario = async (
201
+ ids: readonly string[],
202
+ run: (backend: AgentBackend, session: AgentSessionSnapshot, deadline: number) => Promise<void>,
203
+ ): Promise<void> => {
204
+ let backend: AgentBackend | undefined
205
+ let session: AgentSessionSnapshot | undefined
206
+ const before = checks.length
207
+ try {
208
+ backend = await options.backend()
209
+ backendName = backend.info.name
210
+ session = await backend.createSession({ cwd: options.cwd })
211
+ await run(backend, session, Date.now() + timeoutMs)
212
+ } catch (error) {
213
+ const reported = new Set(checks.slice(before).map((check) => check.id))
214
+ for (const id of ids)
215
+ if (!reported.has(id)) fail(id, `the scenario threw: ${describe(error)}`)
216
+ } finally {
217
+ if (backend !== undefined && session !== undefined)
218
+ await settleBefore(backend.close(session.id), Date.now() + timeoutMs)
219
+ }
220
+ }
221
+
222
+ await scenario(
223
+ ["session.create", "turn.events", "turn.completes", "turn.snapshot"],
224
+ async (backend, session, deadline) => {
225
+ const problem = agentEventProblem({
226
+ version: 1,
227
+ sessionId: session.id,
228
+ seq: 0,
229
+ at: 0,
230
+ type: "session.started",
231
+ snapshot: session,
232
+ })
233
+ if (problem !== undefined) fail("session.create", problem.replace("session.started.", ""))
234
+ else if (session.cwd !== options.cwd)
235
+ fail("session.create", "snapshot.cwd is not the cwd asked for")
236
+ else if (session.status === "running")
237
+ fail("session.create", "a new session is already running")
238
+ else pass("session.create")
239
+
240
+ const reader = new EventReader(() =>
241
+ backend.send({ sessionId: session.id, message: options.prompt }),
242
+ )
243
+ const outcome = await reader.readToEnd(deadline)
244
+ if (outcome === "timeout") {
245
+ await reader.stop()
246
+ fail("turn.events", `the turn did not end within ${timeoutMs} ms`)
247
+ fail("turn.completes", "the turn did not end")
248
+ skip("turn.snapshot", "the turn did not end")
249
+ return
250
+ }
251
+ if (outcome === "threw") {
252
+ fail("turn.events", `the stream threw: ${describe(reader.error)}`)
253
+ fail("turn.completes", "the stream threw")
254
+ skip("turn.snapshot", "the turn did not end")
255
+ return
256
+ }
257
+ const eventsProblem = turnProblem(reader.events, session.id, true)
258
+ if (eventsProblem === undefined) pass("turn.events")
259
+ else fail("turn.events", eventsProblem)
260
+ const last = typeOf(reader.events.at(-1))
261
+ if (last === "session.completed") pass("turn.completes")
262
+ else
263
+ fail(
264
+ "turn.completes",
265
+ `the turn ended with ${String(last ?? "no event")}, not session.completed`,
266
+ )
267
+ const after = await backend.snapshot(session.id)
268
+ if (after.id !== session.id) fail("turn.snapshot", "snapshot names another session")
269
+ else if (after.status === "running")
270
+ fail("turn.snapshot", "the session is still running after its turn")
271
+ else pass("turn.snapshot")
272
+ },
273
+ )
274
+
275
+ const slowPrompt = options.slowPrompt
276
+ if (slowPrompt === undefined) {
277
+ skip("turn.busy", "no slowPrompt")
278
+ skip("turn.cancel", "no slowPrompt")
279
+ } else {
280
+ await scenario(["turn.busy"], async (backend, session, deadline) => {
281
+ const first = new EventReader(() =>
282
+ backend.send({ sessionId: session.id, message: slowPrompt }),
283
+ )
284
+ if (
285
+ (await first.readUntil((event) => typeOf(event) === "turn.started", deadline)) !== "matched"
286
+ ) {
287
+ await first.stop()
288
+ fail("turn.busy", "the slow turn never started")
289
+ return
290
+ }
291
+ const second = new EventReader(() =>
292
+ backend.send({ sessionId: session.id, message: options.prompt }),
293
+ )
294
+ const outcome = await second.readUntil((event) => TERMINAL.has(typeOf(event)), deadline)
295
+ const refused =
296
+ outcome === "threw" ||
297
+ (outcome === "matched" &&
298
+ second.events.length === 1 &&
299
+ typeOf(second.events[0]) === "session.failed")
300
+ if (!refused)
301
+ fail("turn.busy", "a second message while a turn runs was not refused on its own stream")
302
+ else if ((await backend.snapshot(session.id)).status !== "running")
303
+ fail("turn.busy", "refusing the second message ended the running turn")
304
+ else pass("turn.busy")
305
+ await second.stop()
306
+ await backend.cancel(session.id, "conformance")
307
+ await first.readToEnd(deadline)
308
+ })
309
+
310
+ await scenario(["turn.cancel"], async (backend, session, deadline) => {
311
+ const reader = new EventReader(() =>
312
+ backend.send({ sessionId: session.id, message: slowPrompt }),
313
+ )
314
+ if (
315
+ (await reader.readUntil((event) => typeOf(event) === "turn.started", deadline)) !==
316
+ "matched"
317
+ ) {
318
+ await reader.stop()
319
+ fail("turn.cancel", "the slow turn never started")
320
+ return
321
+ }
322
+ await backend.cancel(session.id, "conformance")
323
+ const outcome = await reader.readToEnd(deadline)
324
+ if (outcome === "timeout") {
325
+ await reader.stop()
326
+ fail("turn.cancel", `the turn did not end within ${timeoutMs} ms of cancel`)
327
+ return
328
+ }
329
+ const problem = turnProblem(reader.events, session.id, true)
330
+ if (problem !== undefined) {
331
+ fail("turn.cancel", problem)
332
+ return
333
+ }
334
+ if (typeOf(reader.events.at(-1)) !== "session.stopped") {
335
+ fail("turn.cancel", `a cancelled turn ended with ${String(typeOf(reader.events.at(-1)))}`)
336
+ return
337
+ }
338
+ const next = new EventReader(() =>
339
+ backend.send({ sessionId: session.id, message: options.prompt }),
340
+ )
341
+ if (
342
+ (await next.readToEnd(deadline)) !== "ended" ||
343
+ typeOf(next.events.at(-1)) !== "session.completed"
344
+ ) {
345
+ await next.stop()
346
+ fail("turn.cancel", "the session did not complete a turn after a cancel")
347
+ return
348
+ }
349
+ pass("turn.cancel")
350
+ })
351
+ }
352
+
353
+ const approvalPrompt = options.approvalPrompt
354
+ if (approvalPrompt === undefined) {
355
+ for (const id of ["approval.allow", "approval.deny", "approval.unknown", "approval.cancel"])
356
+ skip(id, "no approvalPrompt")
357
+ } else {
358
+ const decide = (id: string, approved: boolean) =>
359
+ scenario([id], async (backend, session, deadline) => {
360
+ const reader = new EventReader(() =>
361
+ backend.send({ sessionId: session.id, message: approvalPrompt }),
362
+ )
363
+ const asked = await reader.readUntil(
364
+ (event) => typeOf(event) === "approval.required",
365
+ deadline,
366
+ )
367
+ if (asked !== "matched") {
368
+ await reader.stop()
369
+ fail(id, "the turn never asked for an approval")
370
+ return
371
+ }
372
+ const approvalId = recordOf(reader.events.at(-1))?.approvalId
373
+ if (typeof approvalId !== "string") {
374
+ await reader.stop()
375
+ fail(id, "approval.required carried no approvalId")
376
+ return
377
+ }
378
+ if (backend.resolveApproval === undefined) {
379
+ await reader.stop()
380
+ fail(id, "the backend asks for approvals but has no resolveApproval")
381
+ return
382
+ }
383
+ const accepted = await backend.resolveApproval(
384
+ session.id,
385
+ approvalId,
386
+ approved,
387
+ "conformance",
388
+ )
389
+ if (accepted === undefined) {
390
+ await reader.stop()
391
+ fail(id, "resolveApproval did not know the approval it asked for")
392
+ return
393
+ }
394
+ const outcome = await reader.readToEnd(deadline)
395
+ if (outcome !== "ended") {
396
+ await reader.stop()
397
+ fail(
398
+ id,
399
+ outcome === "timeout" ? "the turn did not end after the answer" : "the stream threw",
400
+ )
401
+ return
402
+ }
403
+ const problem = turnProblem(reader.events, session.id, true)
404
+ // A host records the answer itself, so a backend need not echo it; one that does must agree.
405
+ const contradicted = reader.events.some((event) => {
406
+ const record = recordOf(event)
407
+ return (
408
+ record?.type === "approval.resolved" &&
409
+ record.approvalId === approvalId &&
410
+ record.approved !== approved
411
+ )
412
+ })
413
+ if (problem !== undefined) fail(id, problem)
414
+ else if (contradicted)
415
+ fail(id, `approval.resolved for ${approvalId} contradicts the answer`)
416
+ else pass(id)
417
+ })
418
+ await decide("approval.allow", true)
419
+ await decide("approval.deny", false)
420
+
421
+ await scenario(["approval.unknown"], async (backend, session) => {
422
+ if (backend.resolveApproval === undefined) {
423
+ skip("approval.unknown", "the backend has no resolveApproval")
424
+ return
425
+ }
426
+ const answer = await backend.resolveApproval(session.id, "conformance-unknown-approval", true)
427
+ if (answer !== undefined)
428
+ fail(
429
+ "approval.unknown",
430
+ "an approval nobody asked for took a decision instead of undefined",
431
+ )
432
+ else pass("approval.unknown")
433
+ })
434
+
435
+ await scenario(["approval.cancel"], async (backend, session, deadline) => {
436
+ const reader = new EventReader(() =>
437
+ backend.send({ sessionId: session.id, message: approvalPrompt }),
438
+ )
439
+ if (
440
+ (await reader.readUntil((event) => typeOf(event) === "approval.required", deadline)) !==
441
+ "matched"
442
+ ) {
443
+ await reader.stop()
444
+ fail("approval.cancel", "the turn never asked for an approval")
445
+ return
446
+ }
447
+ const approvalId = recordOf(reader.events.at(-1))?.approvalId
448
+ await backend.cancel(session.id, "conformance")
449
+ if ((await reader.readToEnd(deadline)) !== "ended") {
450
+ await reader.stop()
451
+ fail("approval.cancel", "a turn waiting on an approval did not end after cancel")
452
+ return
453
+ }
454
+ if (typeOf(reader.events.at(-1)) !== "session.stopped") {
455
+ fail(
456
+ "approval.cancel",
457
+ `the cancelled turn ended with ${String(typeOf(reader.events.at(-1)))}`,
458
+ )
459
+ return
460
+ }
461
+ const late =
462
+ typeof approvalId === "string"
463
+ ? await backend.resolveApproval?.(session.id, approvalId, true)
464
+ : undefined
465
+ if (late !== undefined)
466
+ fail("approval.cancel", "an approval stayed open after its turn was cancelled")
467
+ else pass("approval.cancel")
468
+ })
469
+ }
470
+
471
+ await scenario(["session.close"], async (backend, session, deadline) => {
472
+ await backend.close(session.id)
473
+ const reader = new EventReader(() =>
474
+ backend.send({ sessionId: session.id, message: options.prompt }),
475
+ )
476
+ const outcome = await reader.readToEnd(deadline)
477
+ if (outcome === "timeout") {
478
+ await reader.stop()
479
+ fail("session.close", "a message to a closed session neither failed nor ended")
480
+ } else if (
481
+ outcome === "threw" ||
482
+ (reader.events.length > 0 &&
483
+ reader.events.every((event) => typeOf(event) === "session.failed"))
484
+ )
485
+ pass("session.close")
486
+ else fail("session.close", "a closed session ran a turn")
487
+ })
488
+
489
+ return { backend: backendName, ok: checks.every((check) => check.status !== "failed"), checks }
490
+ }