@jigging/agent-acp 0.0.0 → 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ import { AgentMethodError, prepareAgent, type AgentInput } from '@jigging/agent-method'
2
+ import {
3
+ OperationError,
4
+ type ChannelReceiver,
5
+ type ChannelSender,
6
+ type JsonObject,
7
+ type RunResult,
8
+ } from '@jigging/flow'
9
+
10
+ export interface ConversationPeer {
11
+ prompt(input: ReturnType<typeof prepareAgent>, turn?: number): Promise<RunResult>
12
+ interrupt(): Promise<boolean>
13
+ }
14
+
15
+ /** Application dialogue only. Native dispatch and settlement are enforced by the resource. */
16
+ export async function converse(
17
+ peer: ConversationPeer,
18
+ initial: ReturnType<typeof prepareAgent>,
19
+ commands: ChannelReceiver,
20
+ replies: ChannelSender,
21
+ maxTurns: number,
22
+ signal: AbortSignal,
23
+ ): Promise<RunResult> {
24
+ const reads = new AbortController()
25
+ const readSignal = AbortSignal.any([signal, reads.signal])
26
+ const read = () =>
27
+ commands.next({ signal: readSignal }).then(
28
+ (item) => ({ kind: 'command' as const, item }),
29
+ (error) => ({ kind: 'read-error' as const, error }),
30
+ )
31
+ const reply = async (value: JsonObject) => {
32
+ if (new TextEncoder().encode(JSON.stringify(value)).byteLength > 65_536)
33
+ throw new OperationError(
34
+ 'RESOURCE_EXHAUSTED',
35
+ 'A conversation reply exceeds 64 KiB; request a smaller answer',
36
+ )
37
+ const timeout = AbortSignal.timeout(5_000)
38
+ try {
39
+ await replies.send(value, { signal: AbortSignal.any([signal, timeout]) })
40
+ } catch (error) {
41
+ signal.throwIfAborted()
42
+ if (timeout.aborted)
43
+ throw new OperationError(
44
+ 'DEADLINE_EXCEEDED',
45
+ 'Conversation reply delivery did not settle within five seconds',
46
+ )
47
+ throw error
48
+ }
49
+ }
50
+ let turn = 0
51
+ let settledTurns = 0
52
+ let interrupted = false
53
+ let receivedCommands = 0
54
+ const start = (input: ReturnType<typeof prepareAgent>) =>
55
+ peer.prompt(input, turn).then(
56
+ (result) => ({ kind: 'turn' as const, result }),
57
+ (error) => ({ kind: 'turn-error' as const, error }),
58
+ )
59
+ let active: ReturnType<typeof start> | undefined = start(initial)
60
+ let command = read()
61
+ try {
62
+ for (;;) {
63
+ const next = await Promise.race(active ? [active, command] : [command])
64
+ signal.throwIfAborted()
65
+ if (next.kind === 'read-error') throw next.error
66
+ if (next.kind === 'turn' || next.kind === 'turn-error') {
67
+ active = undefined
68
+ settledTurns += 1
69
+ if (next.kind === 'turn') {
70
+ await reply({ type: 'result', turn, result: next.result as unknown as JsonObject })
71
+ } else if (next.error instanceof OperationError && next.error.code === 'CANCELLED') {
72
+ await reply({ type: 'cancelled', turn })
73
+ } else if (next.error instanceof AgentMethodError && next.error.code === 'INVALID_RESULT') {
74
+ await reply({
75
+ type: 'error',
76
+ turn,
77
+ code: 'INVALID_RESULT',
78
+ message: 'The settled Agent answer does not match the requested result',
79
+ })
80
+ } else throw next.error
81
+ continue
82
+ }
83
+ if (next.item.done)
84
+ throw new OperationError(
85
+ 'DISCONNECTED',
86
+ 'Conversation commands ended without an accepted close',
87
+ )
88
+ if (++receivedCommands > 64)
89
+ throw new OperationError('RESOURCE_EXHAUSTED', 'Conversation control limit is 64 commands')
90
+ command = read()
91
+ const received = next.item.value
92
+ if (received === null || typeof received !== 'object' || Array.isArray(received))
93
+ throw new OperationError('INVALID_INPUT', 'Expected a conversation command')
94
+ const value = received as JsonObject
95
+ const type = value.type
96
+ if (
97
+ !['prompt', 'interrupt', 'close'].includes(type as string) ||
98
+ typeof value.turn !== 'number' ||
99
+ !Number.isSafeInteger(value.turn) ||
100
+ value.turn < 0 ||
101
+ Object.keys(value).some(
102
+ (key) => !['type', 'turn', ...(type === 'prompt' ? ['input'] : [])].includes(key),
103
+ )
104
+ )
105
+ throw new OperationError('INVALID_INPUT', 'Invalid conversation command')
106
+ const reject = (code: string) =>
107
+ reply({ type: 'rejected', command: type!, turn: value.turn!, code })
108
+ if (type === 'prompt') {
109
+ if (active) {
110
+ await reject('BUSY')
111
+ continue
112
+ }
113
+ if (value.turn !== turn + 1) {
114
+ await reject('STALE_TURN')
115
+ continue
116
+ }
117
+ if (settledTurns >= maxTurns) {
118
+ await reject('TURN_LIMIT')
119
+ continue
120
+ }
121
+ let prepared: ReturnType<typeof prepareAgent>
122
+ try {
123
+ // Initial context remains in the native conversation; these are new turn instructions.
124
+ prepared = prepareAgent(value.input as unknown as AgentInput)
125
+ if (prepared.session !== undefined)
126
+ throw new AgentMethodError(
127
+ 'INVALID_INPUT',
128
+ 'Session requests belong to the initial invocation',
129
+ )
130
+ } catch (error) {
131
+ if (!(error instanceof AgentMethodError)) throw error
132
+ await reject('INVALID_INPUT')
133
+ continue
134
+ }
135
+ turn += 1
136
+ interrupted = false
137
+ active = start(prepared)
138
+ } else if (type === 'interrupt') {
139
+ if (value.turn !== turn) {
140
+ await reject('STALE_TURN')
141
+ continue
142
+ }
143
+ if (!active || interrupted) {
144
+ await reject('NOT_RUNNING')
145
+ continue
146
+ }
147
+ if (!(await peer.interrupt())) {
148
+ await reject('NOT_RUNNING')
149
+ continue
150
+ }
151
+ interrupted = true
152
+ } else {
153
+ if (active) {
154
+ await reject('BUSY')
155
+ continue
156
+ }
157
+ if (value.turn !== turn) {
158
+ await reject('STALE_TURN')
159
+ continue
160
+ }
161
+ await reply({ type: 'accepted', command: 'close', turn })
162
+ // Let the caller seal its writer before releasing this receiver. Merely
163
+ // cancelling our pending read would make a racing clean writer close fail.
164
+ const timeout = setTimeout(() => reads.abort(), 5_000)
165
+ try {
166
+ const end = await command
167
+ signal.throwIfAborted()
168
+ if (reads.signal.aborted)
169
+ throw new OperationError(
170
+ 'DEADLINE_EXCEEDED',
171
+ 'Conversation command closure did not settle within five seconds',
172
+ )
173
+ if (end.kind === 'read-error') throw end.error
174
+ if (!end.item.done)
175
+ throw new OperationError(
176
+ 'INVALID_INPUT',
177
+ 'Conversation commands continued after accepted close',
178
+ )
179
+ } finally {
180
+ clearTimeout(timeout)
181
+ }
182
+ return { outcome: 'done', output: { turns: settledTurns } }
183
+ }
184
+ await reply({ type: 'accepted', command: type!, turn })
185
+ }
186
+ } finally {
187
+ reads.abort()
188
+ await command
189
+ // Every native prompt promise has a rejection handler. The enclosing owner
190
+ // cancels and settles the resource before disposing its endpoints on error.
191
+ }
192
+ }
package/src/flow.ts ADDED
@@ -0,0 +1,495 @@
1
+ import {
2
+ AgentMethodError,
3
+ finishAgent,
4
+ prepareAgent,
5
+ type AgentInput,
6
+ type AgentSessionReceipt,
7
+ type SkillText,
8
+ } from '@jigging/agent-method'
9
+ import {
10
+ handle,
11
+ OperationError,
12
+ type ChannelPair,
13
+ type ChannelReceiver,
14
+ type ChannelSender,
15
+ type JsonObject,
16
+ type JsonValue,
17
+ type RunContext,
18
+ type RunResult,
19
+ } from '@jigging/flow'
20
+ import {
21
+ FiniteAcpFrames,
22
+ FiniteAcpTransportError,
23
+ fragmentFiniteAcpFrame,
24
+ readFiniteAcpReady,
25
+ } from './transport.js'
26
+ import { OptionalUpdates } from './updates.js'
27
+ import { converse } from './conversation.js'
28
+
29
+ const encoder = new TextEncoder()
30
+ const REQUESTS = './contracts/finite-acp/requests.json'
31
+ const RESPONSES = './contracts/finite-acp/responses.json'
32
+ const MAX_TEXT_BYTES = 8_388_608
33
+
34
+ type Settlement = { result: RunResult } | { error: unknown }
35
+
36
+ /** One replaceable method; process, credentials and reviewed policy stay outside. */
37
+ export async function agentAcpFlow(run: RunContext): Promise<RunResult> {
38
+ let requests: ChannelPair | undefined
39
+ let responses: ChannelPair | undefined
40
+ let work: Promise<Settlement> | undefined
41
+ let updates: OptionalUpdates | undefined
42
+ let failed = false
43
+ const owned = new AbortController()
44
+ const signal = AbortSignal.any([run.signal, owned.signal])
45
+ try {
46
+ if (
47
+ run.input === null ||
48
+ typeof run.input !== 'object' ||
49
+ Array.isArray(run.input) ||
50
+ Object.keys(run.input).some(
51
+ (key) =>
52
+ ![
53
+ 'instructions',
54
+ 'guidance',
55
+ 'skills',
56
+ 'responseSchema',
57
+ 'conversation',
58
+ 'session',
59
+ ].includes(key),
60
+ )
61
+ )
62
+ throw new OperationError(
63
+ 'INVALID_INPUT',
64
+ 'Supply Agent instructions and optional explicit guidance, Skills or responseSchema',
65
+ )
66
+ const input = run.input as JsonObject
67
+ if (Object.hasOwn(input, 'conversation') && input.conversation !== true)
68
+ throw new OperationError('INVALID_INPUT', 'conversation must be true when supplied')
69
+ const conversational = input.conversation === true
70
+ const commands = run.channels.commands
71
+ const replies = run.channels.replies
72
+ if (
73
+ conversational
74
+ ? !commands ||
75
+ commands.direction !== 'receive' ||
76
+ commands.delivery !== 'direct' ||
77
+ !replies ||
78
+ replies.direction !== 'send' ||
79
+ replies.delivery !== 'direct'
80
+ : commands !== undefined || replies !== undefined
81
+ )
82
+ throw new OperationError(
83
+ 'INVALID_INPUT',
84
+ 'Conversational calls require paired direct commands and replies channels',
85
+ )
86
+ if (
87
+ Object.keys(run.settings).length ||
88
+ Object.keys(run.attachments).length ||
89
+ Object.keys(run.channels).some((key) => !['events', 'commands', 'replies'].includes(key))
90
+ )
91
+ throw new OperationError(
92
+ 'INVALID_INPUT',
93
+ 'The ACP Agent accepts no settings, attachments, or undeclared channels',
94
+ )
95
+ const events = run.channels.events
96
+ if (events !== undefined && events.direction !== 'send')
97
+ throw new OperationError('INVALID_INPUT', 'Agent events require a send endpoint')
98
+ const { skills, conversation: _, ...methodInput } = input
99
+ const prepared = prepareAgent(
100
+ methodInput as unknown as AgentInput,
101
+ (skills === undefined ? [] : skills) as unknown as readonly SkillText[],
102
+ )
103
+ requests = await run.channel({ contract: REQUESTS }, { signal })
104
+ responses = await run.channel({ contract: RESPONSES }, { signal })
105
+ updates = new OptionalUpdates(events as ChannelSender | undefined, run.signal)
106
+ work = run
107
+ .call(
108
+ {
109
+ operationId: 'native',
110
+ slot: 'native',
111
+ input: prepared.session === undefined ? null : { session: { ...prepared.session } },
112
+ channels: { requests: requests.receive, responses: responses.send },
113
+ },
114
+ { signal },
115
+ )
116
+ .then(
117
+ (result): Settlement => ({ result }),
118
+ (error): Settlement => {
119
+ owned.abort()
120
+ return { error }
121
+ },
122
+ )
123
+ const first = await essential(responses.receive.next({ signal }))
124
+ if (first.done) failure('Native ACP transport omitted its ready record')
125
+ const ready = readFiniteAcpReady(first.value)
126
+ const restoring = prepared.session !== undefined && 'restore' in prepared.session
127
+ if (restoring !== (ready.restoreSessionId !== undefined))
128
+ failure('Native ACP ready record does not match the session request')
129
+ const peer = new FinitePeer(requests.send, responses.receive, signal, updates)
130
+ const initialized = await peer.request('initialize', {
131
+ protocolVersion: ready.protocolVersion,
132
+ clientCapabilities: {},
133
+ clientInfo: { name: 'flow-agent-acp', version: '1' },
134
+ })
135
+ if (initialized.protocolVersion !== 1)
136
+ failure('Native ACP transport selected an unsupported version')
137
+ if (restoring) {
138
+ const capabilities = object(object(initialized.agentCapabilities).sessionCapabilities)
139
+ if (!Object.hasOwn(capabilities, 'resume'))
140
+ failure('Native ACP transport does not advertise session resume')
141
+ object(capabilities.resume)
142
+ peer.sessionId = ready.restoreSessionId!
143
+ const resumed = await peer.request('session/resume', {
144
+ sessionId: peer.sessionId,
145
+ cwd: ready.cwd,
146
+ mcpServers: [],
147
+ })
148
+ keys(resumed, [])
149
+ } else {
150
+ const created = await peer.request('session/new', { cwd: ready.cwd, mcpServers: [] })
151
+ keys(created, ['sessionId'])
152
+ peer.sessionId = identifier(created.sessionId)
153
+ }
154
+ for (const configuration of ready.configuration) {
155
+ const result = await peer.request('session/set_config_option', {
156
+ sessionId: peer.sessionId,
157
+ ...configuration,
158
+ })
159
+ const options = result.configOptions
160
+ if (!Array.isArray(options)) failure('Native ACP transport omitted reviewed configuration')
161
+ const matching = options.filter((item) => object(item).id === configuration.configId)
162
+ if (matching.length !== 1 || object(matching[0]).currentValue !== configuration.value)
163
+ failure('Native ACP transport did not confirm reviewed configuration')
164
+ }
165
+ if (ready.modeId !== undefined)
166
+ await peer.request('session/set_mode', { sessionId: peer.sessionId, modeId: ready.modeId })
167
+ const answer = conversational
168
+ ? await converse(
169
+ peer,
170
+ prepared,
171
+ commands as ChannelReceiver,
172
+ replies as ChannelSender,
173
+ ready.maxTurns,
174
+ signal,
175
+ )
176
+ : await peer.prompt(prepared)
177
+ // Prompt settlement plus request EOF delegates bounded process closure to
178
+ // its owner. An optional ACP close response must not hold up that cleanup.
179
+ await essential(requests.send.close())
180
+ await peer.end()
181
+ const settled = await work
182
+ if ('error' in settled) throw settled.error
183
+ const session = checkSettlement(settled.result, prepared.session !== undefined)
184
+ signal.throwIfAborted()
185
+ if (conversational) await (replies as ChannelSender).close()
186
+ return session === undefined
187
+ ? answer
188
+ : {
189
+ ...answer,
190
+ output: { ...object(answer.output), session: { ...session } },
191
+ }
192
+ } catch (error) {
193
+ failed = true
194
+ const resourceFailed = owned.signal.aborted
195
+ const transportFailed = error instanceof EssentialTransportFailure
196
+ // The owner can close its failed transport before returning its independently
197
+ // settled execution error. Cancelling that call now would replace UNCERTAIN
198
+ // with a cancelled SDK wait. Root cancellation/deadline still bounds the call.
199
+ if (!transportFailed) owned.abort()
200
+ const settled = await work
201
+ if (settled && 'error' in settled && (resourceFailed || transportFailed)) throw settled.error
202
+ if (transportFailed) throw error.cause
203
+ if (error instanceof AgentMethodError) throw new OperationError(error.code, error.message)
204
+ if (error instanceof FiniteAcpTransportError)
205
+ throw new OperationError('INVALID_RESULT', error.message)
206
+ throw error
207
+ } finally {
208
+ // Transferred endpoints belong to the resource; dispose only our own halves.
209
+ // If allocation failed before the call, all four halves remain local.
210
+ await updates?.finish()
211
+ const closures: Promise<void>[] = []
212
+ if (responses) closures.push(responses.receive.close())
213
+ if (requests) closures.push(requests.send.close())
214
+ if (!work) {
215
+ if (responses) closures.push(responses.send.close())
216
+ if (requests) closures.push(requests.receive.close())
217
+ }
218
+ const settled = await Promise.allSettled(closures)
219
+ // Ordinary channel disposal can expose a late essential transport failure.
220
+ for (const item of settled) if (!failed && item.status === 'rejected') throw item.reason
221
+ run.signal.throwIfAborted()
222
+ }
223
+ }
224
+
225
+ class EssentialTransportFailure extends Error {
226
+ constructor(override readonly cause: OperationError) {
227
+ super('Essential ACP channel terminated')
228
+ }
229
+ }
230
+
231
+ /** Only actual terminal channel operations select resource-first arbitration. */
232
+ async function essential<T>(operation: Promise<T>): Promise<T> {
233
+ try {
234
+ return await operation
235
+ } catch (error) {
236
+ if (
237
+ error instanceof OperationError &&
238
+ ['DISCONNECTED', 'LAGGED', 'CHANNEL_LOST', 'PROTOCOL_ERROR', 'OWNER_CLOSED'].includes(
239
+ error.code,
240
+ )
241
+ )
242
+ throw new EssentialTransportFailure(error)
243
+ throw error
244
+ }
245
+ }
246
+
247
+ class FinitePeer {
248
+ sessionId = ''
249
+ text = ''
250
+ private textBytes = 0
251
+ private operation = 0
252
+ private running = false
253
+ private turn: number | undefined
254
+ private writes: Promise<void> = Promise.resolve()
255
+ private readonly frames = new FiniteAcpFrames('responses')
256
+
257
+ constructor(
258
+ private readonly send: ChannelSender,
259
+ private readonly receive: ChannelReceiver,
260
+ private readonly signal: AbortSignal,
261
+ private readonly updates: OptionalUpdates,
262
+ ) {}
263
+
264
+ async request(method: string, params: JsonObject): Promise<JsonObject> {
265
+ const id = ++this.operation
266
+ await this.write({ jsonrpc: '2.0', id, method, params })
267
+ for (;;) {
268
+ const frame = await this.next()
269
+ if (frame === undefined) failure('Native ACP response stream ended before its reply')
270
+ if (frame.method !== undefined) {
271
+ this.update(frame)
272
+ continue
273
+ }
274
+ keys(frame, ['jsonrpc', 'id'], ['result', 'error'])
275
+ if (frame.id !== id || Object.hasOwn(frame, 'result') === Object.hasOwn(frame, 'error'))
276
+ failure('Native ACP response does not match its request')
277
+ if (frame.error !== undefined)
278
+ throw new OperationError('EXECUTION_FAILED', 'Native ACP request failed')
279
+ return object(frame.result)
280
+ }
281
+ }
282
+
283
+ async prompt(prepared: ReturnType<typeof prepareAgent>, turn?: number): Promise<RunResult> {
284
+ if (this.running) failure('A native turn is already running')
285
+ this.running = true
286
+ this.text = ''
287
+ this.turn = turn
288
+ let completed: JsonObject
289
+ try {
290
+ completed = await this.request('session/prompt', {
291
+ sessionId: this.sessionId,
292
+ prompt: [{ type: 'text', text: prepared.request.prompt }],
293
+ })
294
+ } finally {
295
+ this.running = false
296
+ }
297
+ keys(completed, ['stopReason'])
298
+ const stop =
299
+ completed.stopReason === 'end_turn'
300
+ ? 'end-turn'
301
+ : completed.stopReason === 'refusal'
302
+ ? 'refusal'
303
+ : ['max_tokens', 'max_turn_requests'].includes(completed.stopReason as string)
304
+ ? 'limit'
305
+ : undefined
306
+ if (stop === undefined)
307
+ throw new OperationError(
308
+ completed.stopReason === 'cancelled' ? 'CANCELLED' : 'INVALID_RESULT',
309
+ 'Native ACP turn did not produce a completed response',
310
+ )
311
+ const result = finishAgent(prepared, { outcome: 'done', output: { text: this.text, stop } })
312
+ return { outcome: result.outcome, output: { ...result.output } }
313
+ }
314
+
315
+ async interrupt(): Promise<boolean> {
316
+ if (!this.running) return false
317
+ await this.write({
318
+ jsonrpc: '2.0',
319
+ method: 'session/cancel',
320
+ params: { sessionId: this.sessionId },
321
+ })
322
+ return true
323
+ }
324
+
325
+ private write(frame: JsonObject): Promise<void> {
326
+ const work = this.writes.then(async () => {
327
+ for (const fragment of fragmentFiniteAcpFrame(JSON.stringify(frame)))
328
+ await essential(this.send.send({ ...fragment }, { signal: this.signal }))
329
+ })
330
+ this.writes = work.catch(() => undefined)
331
+ return work
332
+ }
333
+
334
+ async end(): Promise<void> {
335
+ // No late update may change the text already completed by session/prompt.
336
+ for (;;) {
337
+ const frame = await this.next()
338
+ if (frame === undefined) return
339
+ this.update(frame)
340
+ }
341
+ }
342
+
343
+ private async next(): Promise<JsonObject | undefined> {
344
+ for (;;) {
345
+ const item = await essential(this.receive.next({ signal: this.signal }))
346
+ if (item.done) {
347
+ this.frames.finish()
348
+ return undefined
349
+ }
350
+ const text = this.frames.accept(item.value)
351
+ if (text === undefined) continue
352
+ let frame: JsonObject
353
+ try {
354
+ frame = object(JSON.parse(text))
355
+ } catch {
356
+ failure('Native ACP transport returned malformed JSON')
357
+ }
358
+ if (frame.jsonrpc !== '2.0') failure('Native ACP transport returned a non-ACP frame')
359
+ return frame
360
+ }
361
+ }
362
+
363
+ private update(frame: JsonObject): void {
364
+ keys(frame, ['jsonrpc', 'method', 'params'])
365
+ if (frame.method !== 'session/update' || !this.sessionId)
366
+ failure('Unexpected native ACP notification')
367
+ const params = object(frame.params)
368
+ keys(params, ['sessionId', 'update'])
369
+ if (params.sessionId !== this.sessionId) failure('Native ACP update belongs to another session')
370
+ const update = object(params.update)
371
+ if (update.sessionUpdate === 'session_info_update') {
372
+ keys(update, ['sessionUpdate', '_meta'])
373
+ const metadata = object(update._meta)
374
+ keys(metadata, ['notice'])
375
+ const notice = object(metadata.notice)
376
+ keys(notice, ['code'])
377
+ if (notice.code !== 'NATIVE_WARNING') failure('Unknown native ACP diagnostic')
378
+ console.warn(
379
+ 'Native Agent reported a warning. Private details were withheld; check the operator’s native-client configuration if behavior is unexpected.',
380
+ )
381
+ return
382
+ } else if (update.sessionUpdate === 'agent_message_chunk') {
383
+ keys(update, ['sessionUpdate', 'content'], ['messageId'])
384
+ const content = object(update.content)
385
+ keys(content, ['type', 'text'])
386
+ if (content.type !== 'text' || typeof content.text !== 'string')
387
+ failure('Native ACP update is not public text')
388
+ if (update.messageId !== undefined) identifier(update.messageId)
389
+ this.textBytes += encoder.encode(content.text).byteLength
390
+ if (this.textBytes > MAX_TEXT_BYTES)
391
+ throw new OperationError('RESOURCE_EXHAUSTED', 'Native ACP text exceeds 8 MiB')
392
+ this.text += content.text
393
+ } else if (update.sessionUpdate === 'plan') {
394
+ keys(update, ['sessionUpdate', 'entries'])
395
+ if (!Array.isArray(update.entries)) failure('Native ACP plan is invalid')
396
+ for (const entry of update.entries) {
397
+ const item = object(entry)
398
+ keys(item, ['content', 'priority', 'status'])
399
+ if (
400
+ typeof item.content !== 'string' ||
401
+ !['high', 'medium', 'low'].includes(item.priority as string) ||
402
+ !['pending', 'in_progress', 'completed'].includes(item.status as string)
403
+ )
404
+ failure('Native ACP plan is invalid')
405
+ }
406
+ } else failure('Native ACP transport returned a private update')
407
+ if (!this.running) failure('Public ACP update arrived outside a turn')
408
+ this.updates.offer(this.turn === undefined ? update : { ...update, turn: this.turn })
409
+ }
410
+ }
411
+
412
+ function checkSettlement(
413
+ result: RunResult,
414
+ requestedSession: boolean,
415
+ ): AgentSessionReceipt | undefined {
416
+ const value = object(result)
417
+ keys(value, ['outcome', 'output'])
418
+ const output = object(value.output)
419
+ keys(output, [
420
+ 'stopReason',
421
+ 'exitCode',
422
+ 'signal',
423
+ 'cleanup',
424
+ ...(requestedSession ? ['session'] : []),
425
+ ])
426
+ if (
427
+ value.outcome !== 'done' ||
428
+ output.cleanup !== 'complete' ||
429
+ !['exited', 'closed'].includes(output.stopReason as string) ||
430
+ (output.exitCode !== null &&
431
+ (typeof output.exitCode !== 'number' || !Number.isSafeInteger(output.exitCode))) ||
432
+ (output.signal !== null && typeof output.signal !== 'string')
433
+ )
434
+ failure('Native ACP resource did not supply complete settlement')
435
+ if (output.stopReason === 'exited' && (output.exitCode !== 0 || output.signal !== null))
436
+ throw new OperationError('EXECUTION_FAILED', 'Native ACP process exited unsuccessfully')
437
+ if (!requestedSession) return undefined
438
+ const session = object(output.session)
439
+ if (session.status === 'unavailable') {
440
+ keys(session, ['status', 'reason'])
441
+ if (
442
+ !['not-cleanly-closed', 'missing-history', 'unsupported-history', 'capacity'].includes(
443
+ session.reason as string,
444
+ )
445
+ )
446
+ failure('Native ACP resource returned an invalid retention reason')
447
+ return session as unknown as AgentSessionReceipt
448
+ }
449
+ keys(session, ['status', 'reference'])
450
+ if (
451
+ session.status !== 'retained' ||
452
+ typeof session.reference !== 'string' ||
453
+ session.reference.length !== 36 ||
454
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(session.reference) ||
455
+ output.stopReason !== 'exited' ||
456
+ output.exitCode !== 0 ||
457
+ output.signal !== null
458
+ )
459
+ failure('Native ACP resource returned an invalid session receipt')
460
+ return { status: 'retained', reference: session.reference }
461
+ }
462
+
463
+ function object(value: unknown): JsonObject {
464
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
465
+ failure('Expected a native ACP data object')
466
+ return value as JsonObject
467
+ }
468
+ function keys(
469
+ value: JsonObject,
470
+ required: readonly string[],
471
+ optional: readonly string[] = [],
472
+ ): void {
473
+ if (
474
+ required.some((key) => !Object.hasOwn(value, key)) ||
475
+ Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))
476
+ )
477
+ failure('Unexpected native ACP fields')
478
+ }
479
+ function identifier(value: unknown): string {
480
+ if (
481
+ typeof value !== 'string' ||
482
+ !value ||
483
+ value.includes('\0') ||
484
+ encoder.encode(value).byteLength > 1_024
485
+ )
486
+ failure('Invalid native ACP identity')
487
+ return value
488
+ }
489
+ function failure(message: string): never {
490
+ throw new OperationError('INVALID_RESULT', message)
491
+ }
492
+
493
+ export async function runAgentAcpFlow(): Promise<void> {
494
+ await handle(agentAcpFlow)
495
+ }