@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.
- package/AGENTS.md +88 -0
- package/FLOW.contract.json +241 -0
- package/FLOW.meta.json +6 -0
- package/FLOW.ts +3 -0
- package/LICENSE +374 -0
- package/README.md +156 -0
- package/THIRD_PARTY_NOTICES +14 -0
- package/contracts/acp-public-updates.json +75 -0
- package/contracts/agent-commands.json +73 -0
- package/contracts/agent-replies.json +156 -0
- package/contracts/finite-acp/contract.json +104 -0
- package/contracts/finite-acp/requests.json +24 -0
- package/contracts/finite-acp/responses.json +102 -0
- package/dist/conversation.d.ts +8 -0
- package/dist/conversation.js +155 -0
- package/dist/flow.d.ts +4 -0
- package/dist/flow.js +3393 -0
- package/dist/transport.d.ts +56 -0
- package/dist/transport.js +209 -0
- package/dist/updates.d.ts +16 -0
- package/dist/updates.js +77 -0
- package/justfile +32 -0
- package/licenses/agent-method.LICENSE +373 -0
- package/licenses/flow.LICENSE +202 -0
- package/package.json +34 -4
- package/src/conversation.ts +192 -0
- package/src/flow.ts +495 -0
- package/src/transport.ts +242 -0
- package/src/updates.ts +76 -0
- package/test/flow.test.ts +982 -0
- package/test/pack.test.ts +76 -0
- package/test/transport.test.ts +135 -0
- package/test/updates.test.ts +94 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
}
|