@ai-sdk/harness-acp 0.0.0 → 1.0.1
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/CHANGELOG.md +24 -0
- package/LICENSE +13 -0
- package/README.md +69 -3
- package/dist/bridge/host-tool-mcp.mjs +302 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +3317 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +816 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +2089 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -10
- package/src/acp-auth.ts +254 -0
- package/src/acp-bridge-protocol.ts +13 -0
- package/src/acp-harness.ts +107 -0
- package/src/index.ts +19 -0
- package/src/v1/acp-v1-bridge-protocol.ts +151 -0
- package/src/v1/acp-v1-harness.ts +1461 -0
- package/src/v1/acp-v1-lifecycle.ts +115 -0
- package/src/v1/acp-v1-prompt.ts +122 -0
- package/src/v1/acp-v1-settings.ts +97 -0
- package/src/v1/acp-v1-skills.ts +228 -0
- package/src/v1/acp-v1-turn-start-config.ts +93 -0
- package/src/v1/bridge/acp-diagnostics.ts +83 -0
- package/src/v1/bridge/acp-stream-capture.ts +152 -0
- package/src/v1/bridge/acp-v1-bridge-environment.ts +95 -0
- package/src/v1/bridge/agent-stderr-monitor.ts +52 -0
- package/src/v1/bridge/create-emit-stream-event.ts +97 -0
- package/src/v1/bridge/host-tool-correlation.ts +640 -0
- package/src/v1/bridge/host-tool-mcp-server.ts +180 -0
- package/src/v1/bridge/host-tool-mcp.ts +185 -0
- package/src/v1/bridge/host-tool-relay-client.ts +53 -0
- package/src/v1/bridge/host-tool-relay.ts +510 -0
- package/src/v1/bridge/index.ts +714 -0
- package/src/v1/bridge/package.json +12 -0
- package/src/v1/bridge/permission-controller.ts +152 -0
- package/src/v1/bridge/permission-mode.ts +163 -0
- package/src/v1/bridge/pnpm-lock.yaml +816 -0
- package/src/v1/bridge/profile-values.ts +97 -0
- package/src/v1/bridge/protocol-configuration.ts +146 -0
- package/src/v1/bridge/recovered-session.ts +145 -0
- package/src/v1/bridge/refresh-host-tool-catalog.ts +33 -0
- package/src/v1/bridge/session-lifecycle.ts +83 -0
- package/src/v1/bridge/stream-translator.ts +901 -0
- package/src/v1/implementation.ts +308 -0
- package/src/v1/index.ts +16 -0
- package/src/version.ts +6 -0
- package/index.js +0 -1
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HarnessBridgeCapabilityUnsupportedError,
|
|
3
|
+
runBridge,
|
|
4
|
+
type BridgeEvent,
|
|
5
|
+
type BridgeTurn,
|
|
6
|
+
} from '@ai-sdk/harness/bridge';
|
|
7
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
8
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { Readable, Writable } from 'node:stream';
|
|
12
|
+
import { argv, env as processEnv } from 'node:process';
|
|
13
|
+
import {
|
|
14
|
+
ACP_BRIDGE_CONFIGURATION_ENV,
|
|
15
|
+
readACPBridgeEnvironment,
|
|
16
|
+
} from './acp-v1-bridge-environment';
|
|
17
|
+
import type { ACPClientApp } from '../../acp-auth';
|
|
18
|
+
import type { StartMessage } from '../acp-v1-bridge-protocol';
|
|
19
|
+
import type { ACPAuthentication } from '../acp-v1-settings';
|
|
20
|
+
import type { ACPGatewayValues } from './profile-values';
|
|
21
|
+
import {
|
|
22
|
+
assertACPAuthenticationMethod,
|
|
23
|
+
createACPInitializeRequest,
|
|
24
|
+
resolveACPLaunchEnvironment,
|
|
25
|
+
validateACPProtocolVersion,
|
|
26
|
+
type ACPInitializeResult,
|
|
27
|
+
} from './protocol-configuration';
|
|
28
|
+
import { captureACPStream, type ACPStreamCapture } from './acp-stream-capture';
|
|
29
|
+
import {
|
|
30
|
+
createACPBridgeError,
|
|
31
|
+
createACPInitializationDiagnostic,
|
|
32
|
+
} from './acp-diagnostics';
|
|
33
|
+
import { monitorACPAgentStderr } from './agent-stderr-monitor';
|
|
34
|
+
import { createEmitStreamEvent } from './create-emit-stream-event';
|
|
35
|
+
import {
|
|
36
|
+
startHostToolRelay,
|
|
37
|
+
type HostToolRelay,
|
|
38
|
+
type HostToolRelayTurn,
|
|
39
|
+
} from './host-tool-relay';
|
|
40
|
+
import { refreshHostToolCatalog } from './refresh-host-tool-catalog';
|
|
41
|
+
import { createACPPermissionController } from './permission-controller';
|
|
42
|
+
import { configureACPPermissionMode } from './permission-mode';
|
|
43
|
+
import {
|
|
44
|
+
assertACPResumeCapability,
|
|
45
|
+
createACPRecoveredSession,
|
|
46
|
+
createACPRecoveredSessionUpdates,
|
|
47
|
+
type ACPActiveSession,
|
|
48
|
+
} from './recovered-session';
|
|
49
|
+
import {
|
|
50
|
+
restoreACPBridgeSession,
|
|
51
|
+
type ACPSessionRestorationMethod,
|
|
52
|
+
} from './session-lifecycle';
|
|
53
|
+
|
|
54
|
+
type ImplementationDescriptor = {
|
|
55
|
+
readonly executable: string;
|
|
56
|
+
readonly args: ReadonlyArray<string>;
|
|
57
|
+
readonly envKeys: ReadonlyArray<string>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const HOST_TOOL_MCP_SERVER_NAME = 'ai-sdk-harness-tools';
|
|
61
|
+
const CATALOG_REFRESH_TIMEOUT_MS = 10_000;
|
|
62
|
+
|
|
63
|
+
const args = parseArgs({ args: argv.slice(2) });
|
|
64
|
+
const workDir = requireArg({ value: args.workDir, name: '--workdir' });
|
|
65
|
+
const bridgeStateDir = requireArg({
|
|
66
|
+
value: args.bridgeStateDir,
|
|
67
|
+
name: '--bridge-state-dir',
|
|
68
|
+
});
|
|
69
|
+
const implementationDir = requireArg({
|
|
70
|
+
value: args.implementationDir,
|
|
71
|
+
name: '--implementation-dir',
|
|
72
|
+
});
|
|
73
|
+
const bridgeType = requireArg({
|
|
74
|
+
value: args.bridgeType,
|
|
75
|
+
name: '--bridge-type',
|
|
76
|
+
});
|
|
77
|
+
const implementation = await readImplementationDescriptor({
|
|
78
|
+
path: `${implementationDir}/implementation.json`,
|
|
79
|
+
});
|
|
80
|
+
const bridgeConfiguration = await readACPBridgeEnvironment({ env: processEnv });
|
|
81
|
+
|
|
82
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
83
|
+
let agentResponseStreamFailure: Promise<never> | undefined;
|
|
84
|
+
let connection: acp.ClientConnection | undefined;
|
|
85
|
+
let session: ACPActiveSession | undefined;
|
|
86
|
+
let recoveredSessionUpdates:
|
|
87
|
+
| ReturnType<typeof createACPRecoveredSessionUpdates>
|
|
88
|
+
| undefined;
|
|
89
|
+
let recoveredSessionId: string | undefined;
|
|
90
|
+
let sessionConfigurationFingerprint: string | undefined;
|
|
91
|
+
let streamCapture: ACPStreamCapture | undefined;
|
|
92
|
+
let initializationDiagnostic: Record<string, unknown> | undefined;
|
|
93
|
+
let hostToolRelay: HostToolRelay | undefined;
|
|
94
|
+
let catalogRefreshError: HarnessBridgeCapabilityUnsupportedError | undefined;
|
|
95
|
+
let sessionConfigurationFailure: { readonly error: unknown } | undefined;
|
|
96
|
+
let historicalUpdatesSuppressed = false;
|
|
97
|
+
let coldRestorationMethod: ACPSessionRestorationMethod | undefined;
|
|
98
|
+
let activePermissionController:
|
|
99
|
+
| ReturnType<typeof createACPPermissionController>
|
|
100
|
+
| undefined;
|
|
101
|
+
|
|
102
|
+
await runBridge<StartMessage>({
|
|
103
|
+
bridgeType,
|
|
104
|
+
bridgeStateDir,
|
|
105
|
+
onStart: runTurn,
|
|
106
|
+
onStop: () => (session == null ? {} : { sessionId: session.sessionId }),
|
|
107
|
+
onExit: () => {
|
|
108
|
+
let exited = false;
|
|
109
|
+
const finish = () => {
|
|
110
|
+
if (exited) return;
|
|
111
|
+
exited = true;
|
|
112
|
+
session?.dispose();
|
|
113
|
+
connection?.close();
|
|
114
|
+
child?.kill();
|
|
115
|
+
process.exit(0);
|
|
116
|
+
};
|
|
117
|
+
if (hostToolRelay == null) {
|
|
118
|
+
finish();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const timer = setTimeout(finish, 1000);
|
|
122
|
+
timer.unref();
|
|
123
|
+
void hostToolRelay.close().finally(() => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
finish();
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
131
|
+
try {
|
|
132
|
+
await ensureSession({ start, turn });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (HarnessBridgeCapabilityUnsupportedError.isInstance(error)) throw error;
|
|
135
|
+
throw createACPBridgeError({
|
|
136
|
+
stage: 'session initialization',
|
|
137
|
+
cause: error,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const activeSession = session;
|
|
141
|
+
if (activeSession == null) {
|
|
142
|
+
throw new Error('ACP session initialization did not produce a session.');
|
|
143
|
+
}
|
|
144
|
+
const activeAgentResponseStreamFailure = agentResponseStreamFailure;
|
|
145
|
+
if (activeAgentResponseStreamFailure == null) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'ACP session initialization did not start stderr monitoring.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (start.recoveryMode?.type === 'lossy-rerun') {
|
|
151
|
+
const marker = {
|
|
152
|
+
type: 'acp-recovery',
|
|
153
|
+
mode: 'lossy-rerun',
|
|
154
|
+
reason: start.recoveryMode.reason,
|
|
155
|
+
} as const;
|
|
156
|
+
turn.emit({ type: 'raw', rawValue: marker });
|
|
157
|
+
turn.bridgeLog({
|
|
158
|
+
level: 'warn',
|
|
159
|
+
subsystem: 'acp.recovery',
|
|
160
|
+
message:
|
|
161
|
+
'The ACP process was replaced; the original prompt is being rerun against the resumed ACP session.',
|
|
162
|
+
attrs: marker,
|
|
163
|
+
});
|
|
164
|
+
turn.emitWarning({
|
|
165
|
+
message:
|
|
166
|
+
'ACP process-loss recovery is rerunning the interrupted prompt and may repeat work.',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
turn.emit({
|
|
170
|
+
type: 'bridge-thread',
|
|
171
|
+
threadId: activeSession.sessionId,
|
|
172
|
+
});
|
|
173
|
+
if (initializationDiagnostic != null) {
|
|
174
|
+
turn.bridgeLog({
|
|
175
|
+
level: 'info',
|
|
176
|
+
subsystem: 'acp.protocol',
|
|
177
|
+
message: 'ACP session initialized.',
|
|
178
|
+
attrs: initializationDiagnostic,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (start.recoveryMode?.type === 'cold-restore') {
|
|
182
|
+
const marker = {
|
|
183
|
+
type: 'acp-session-restored',
|
|
184
|
+
method: coldRestorationMethod,
|
|
185
|
+
} as const;
|
|
186
|
+
turn.emit({ type: 'raw', rawValue: marker });
|
|
187
|
+
turn.bridgeLog({
|
|
188
|
+
level: 'info',
|
|
189
|
+
subsystem: 'acp.lifecycle',
|
|
190
|
+
message: 'ACP session restored in a replacement bridge process.',
|
|
191
|
+
attrs: marker,
|
|
192
|
+
});
|
|
193
|
+
turn.emit({
|
|
194
|
+
type: 'finish',
|
|
195
|
+
finishReason: {
|
|
196
|
+
unified: 'stop',
|
|
197
|
+
raw: 'acp-session-restored',
|
|
198
|
+
},
|
|
199
|
+
totalUsage: unknownUsage(),
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let rejectCancellationFailure!: (error: unknown) => void;
|
|
205
|
+
const cancellationFailure = new Promise<never>((_, reject) => {
|
|
206
|
+
rejectCancellationFailure = reject;
|
|
207
|
+
});
|
|
208
|
+
void cancellationFailure.catch(() => {});
|
|
209
|
+
let cancellationRequested = false;
|
|
210
|
+
let cancellationFailureError: Error | undefined;
|
|
211
|
+
const cancel = async () => {
|
|
212
|
+
if (cancellationRequested) return;
|
|
213
|
+
cancellationRequested = true;
|
|
214
|
+
activePermissionController?.cancelAll();
|
|
215
|
+
try {
|
|
216
|
+
if (connection == null) {
|
|
217
|
+
throw new Error('ACP connection closed before cancellation.');
|
|
218
|
+
}
|
|
219
|
+
await connection.agent.notify(acp.methods.agent.session.cancel, {
|
|
220
|
+
sessionId: activeSession.sessionId,
|
|
221
|
+
});
|
|
222
|
+
} catch (error) {
|
|
223
|
+
cancellationFailureError = createACPBridgeError({
|
|
224
|
+
stage: 'session cancellation',
|
|
225
|
+
cause: error,
|
|
226
|
+
});
|
|
227
|
+
rejectCancellationFailure(cancellationFailureError);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
turn.emit({ type: 'stream-start' });
|
|
231
|
+
const emitStreamEvent = createEmitStreamEvent({
|
|
232
|
+
emit: event => turn.emit(event as BridgeEvent),
|
|
233
|
+
builtinTools: start.builtinTools,
|
|
234
|
+
hostToolServerName: HOST_TOOL_MCP_SERVER_NAME,
|
|
235
|
+
hostTools: start.tools ?? [],
|
|
236
|
+
});
|
|
237
|
+
const permissionController = createACPPermissionController({
|
|
238
|
+
turn,
|
|
239
|
+
sessionId: activeSession.sessionId,
|
|
240
|
+
permissionMode: start.permissionMode ?? 'allow-all',
|
|
241
|
+
hasPermissionModeMapping: start.permissionModeMapping != null,
|
|
242
|
+
emitToolCall: emitStreamEvent.permissionToolCall,
|
|
243
|
+
claimHostToolPermission: emitStreamEvent.claimHostToolPermission,
|
|
244
|
+
});
|
|
245
|
+
activePermissionController = permissionController;
|
|
246
|
+
const relayTurn: HostToolRelayTurn = {
|
|
247
|
+
emitToolCall: emitStreamEvent.hostToolCall,
|
|
248
|
+
emitToolResult: emitStreamEvent.hostToolResult,
|
|
249
|
+
requestToolResult: toolCallId => turn.requestToolResult(toolCallId),
|
|
250
|
+
registerCorrelationInvocation:
|
|
251
|
+
emitStreamEvent.registerHostToolCorrelationInvocation,
|
|
252
|
+
removeCorrelationInvocation:
|
|
253
|
+
emitStreamEvent.removeHostToolCorrelationInvocation,
|
|
254
|
+
};
|
|
255
|
+
hostToolRelay?.bindTurn({ turn: relayTurn });
|
|
256
|
+
try {
|
|
257
|
+
void activeSession.prompt(start.prompt);
|
|
258
|
+
if (turn.abortSignal.aborted) {
|
|
259
|
+
await cancel();
|
|
260
|
+
} else {
|
|
261
|
+
turn.abortSignal.addEventListener('abort', () => void cancel(), {
|
|
262
|
+
once: true,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
for (;;) {
|
|
266
|
+
let message: acp.ActiveSessionMessage;
|
|
267
|
+
try {
|
|
268
|
+
message = await Promise.race([
|
|
269
|
+
activeSession.nextUpdate(),
|
|
270
|
+
cancellationFailure,
|
|
271
|
+
activeAgentResponseStreamFailure,
|
|
272
|
+
]);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
for (const rawValue of streamCapture?.drainRawValues() ?? []) {
|
|
275
|
+
emitStreamEvent.raw({ rawValue });
|
|
276
|
+
}
|
|
277
|
+
emitStreamEvent.close();
|
|
278
|
+
if (error === cancellationFailureError) throw error;
|
|
279
|
+
throw createACPBridgeError({
|
|
280
|
+
stage: 'prompt update stream',
|
|
281
|
+
cause: error,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
if (message.kind === 'session_update') {
|
|
285
|
+
const captured = streamCapture?.takeForUpdate({
|
|
286
|
+
update: message.update,
|
|
287
|
+
});
|
|
288
|
+
for (const rawValue of captured?.precedingRawValues ?? []) {
|
|
289
|
+
emitStreamEvent.raw({ rawValue });
|
|
290
|
+
}
|
|
291
|
+
emitStreamEvent.message({
|
|
292
|
+
message,
|
|
293
|
+
rawUpdate: captured?.rawUpdate,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
for (const rawValue of streamCapture?.drainRawValues() ?? []) {
|
|
298
|
+
emitStreamEvent.raw({ rawValue });
|
|
299
|
+
}
|
|
300
|
+
if (emitStreamEvent.message({ message })) return;
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
permissionController.cancelAll();
|
|
304
|
+
activePermissionController = undefined;
|
|
305
|
+
hostToolRelay?.unbindTurn({ turn: relayTurn });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function ensureSession({
|
|
310
|
+
start,
|
|
311
|
+
turn,
|
|
312
|
+
}: {
|
|
313
|
+
start: StartMessage;
|
|
314
|
+
turn: BridgeTurn;
|
|
315
|
+
}): Promise<void> {
|
|
316
|
+
if (sessionConfigurationFailure != null) {
|
|
317
|
+
throw sessionConfigurationFailure.error;
|
|
318
|
+
}
|
|
319
|
+
const fingerprint = JSON.stringify({
|
|
320
|
+
authentication: bridgeConfiguration.authentication,
|
|
321
|
+
providerAuthentication: bridgeConfiguration.providerAuthentication,
|
|
322
|
+
sessionMeta: bridgeConfiguration.sessionMeta,
|
|
323
|
+
permissionMode: start.permissionMode,
|
|
324
|
+
permissionModeMapping: start.permissionModeMapping,
|
|
325
|
+
});
|
|
326
|
+
if (session != null) {
|
|
327
|
+
if (catalogRefreshError != null) throw catalogRefreshError;
|
|
328
|
+
if (sessionConfigurationFingerprint !== fingerprint) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
'ACP authentication and session profile settings cannot change after the ACP session has started.',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const relay = hostToolRelay;
|
|
334
|
+
if (relay == null) {
|
|
335
|
+
throw new Error('The host tool MCP relay is unavailable.');
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
await refreshHostToolCatalog({
|
|
339
|
+
relay,
|
|
340
|
+
tools: start.tools ?? [],
|
|
341
|
+
harnessId: bridgeType,
|
|
342
|
+
timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
|
|
343
|
+
});
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (HarnessBridgeCapabilityUnsupportedError.isInstance(error)) {
|
|
346
|
+
catalogRefreshError = error;
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const clientApp = resolveClientApp();
|
|
354
|
+
const gateway =
|
|
355
|
+
bridgeConfiguration.providerAuthentication?.type === 'ai-gateway'
|
|
356
|
+
? resolveGatewayValues({ clientApp })
|
|
357
|
+
: undefined;
|
|
358
|
+
const authentication = bridgeConfiguration.authentication;
|
|
359
|
+
const launchEnv = resolveACPLaunchEnvironment({
|
|
360
|
+
providerAuthentication: bridgeConfiguration.providerAuthentication,
|
|
361
|
+
gateway,
|
|
362
|
+
});
|
|
363
|
+
child = spawn(
|
|
364
|
+
`${implementationDir}/node_modules/.bin/${implementation.executable}`,
|
|
365
|
+
[...implementation.args],
|
|
366
|
+
{
|
|
367
|
+
cwd: workDir,
|
|
368
|
+
env: createChildEnvironment({ launchEnv }),
|
|
369
|
+
shell: false,
|
|
370
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
agentResponseStreamFailure = monitorACPAgentStderr({
|
|
374
|
+
stderr: child.stderr,
|
|
375
|
+
onStderrLine: line => {
|
|
376
|
+
turn.bridgeLog({
|
|
377
|
+
level: 'warn',
|
|
378
|
+
subsystem: 'acp.agent.stderr',
|
|
379
|
+
message: line,
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const input = Writable.toWeb(child.stdin);
|
|
385
|
+
const output = Readable.toWeb(child.stdout) as ReadableStream<
|
|
386
|
+
Uint8Array<ArrayBufferLike>
|
|
387
|
+
>;
|
|
388
|
+
const capturedStream = captureACPStream({
|
|
389
|
+
stream: acp.ndJsonStream(input, output),
|
|
390
|
+
});
|
|
391
|
+
streamCapture = capturedStream.capture;
|
|
392
|
+
connection = acp
|
|
393
|
+
.client({ name: clientApp.name })
|
|
394
|
+
.onRequest(
|
|
395
|
+
acp.methods.client.session.requestPermission,
|
|
396
|
+
({ params }) =>
|
|
397
|
+
activePermissionController?.requestPermission(params) ?? {
|
|
398
|
+
outcome: { outcome: 'cancelled' },
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
.onNotification(acp.methods.client.session.update, ({ params }) => {
|
|
402
|
+
if (params.sessionId !== recoveredSessionId) return;
|
|
403
|
+
if (historicalUpdatesSuppressed) return;
|
|
404
|
+
recoveredSessionUpdates?.enqueue({
|
|
405
|
+
kind: 'session_update',
|
|
406
|
+
notification: params,
|
|
407
|
+
update: params.update,
|
|
408
|
+
});
|
|
409
|
+
})
|
|
410
|
+
.connect(capturedStream.stream);
|
|
411
|
+
|
|
412
|
+
const initializeRequest = createACPInitializeRequest({
|
|
413
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
414
|
+
clientApp,
|
|
415
|
+
authentication,
|
|
416
|
+
supportsBooleanSessionConfigOptions: Object.values(
|
|
417
|
+
start.permissionModeMapping ?? {},
|
|
418
|
+
).some(
|
|
419
|
+
target =>
|
|
420
|
+
target?.type === 'session-config-option' &&
|
|
421
|
+
typeof target.value === 'boolean',
|
|
422
|
+
),
|
|
423
|
+
});
|
|
424
|
+
const initialization = await connection.agent.request<
|
|
425
|
+
acp.InitializeResponse,
|
|
426
|
+
typeof initializeRequest
|
|
427
|
+
>(acp.methods.agent.initialize, initializeRequest);
|
|
428
|
+
validateACPProtocolVersion({
|
|
429
|
+
requested: acp.PROTOCOL_VERSION,
|
|
430
|
+
initialization,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (authentication != null) {
|
|
434
|
+
await authenticate({
|
|
435
|
+
agent: connection.agent,
|
|
436
|
+
initialization,
|
|
437
|
+
authentication,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const tools = start.tools ?? [];
|
|
441
|
+
const catalogPath = `${bridgeStateDir}/host-tools.json`;
|
|
442
|
+
await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
|
|
443
|
+
hostToolRelay = await startHostToolRelay({
|
|
444
|
+
tools,
|
|
445
|
+
serverName: HOST_TOOL_MCP_SERVER_NAME,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const mcpServers: acp.McpServer[] = [
|
|
449
|
+
{
|
|
450
|
+
name: HOST_TOOL_MCP_SERVER_NAME,
|
|
451
|
+
command: process.execPath,
|
|
452
|
+
args: [fileURLToPath(new URL('./host-tool-mcp.mjs', import.meta.url))],
|
|
453
|
+
env: [
|
|
454
|
+
{
|
|
455
|
+
name: 'AI_SDK_ACP_HOST_TOOLS_FILE',
|
|
456
|
+
value: catalogPath,
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_URL',
|
|
460
|
+
value: hostToolRelay.url,
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: 'AI_SDK_ACP_HOST_TOOL_RELAY_CREDENTIAL',
|
|
464
|
+
value: hostToolRelay.credential,
|
|
465
|
+
},
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
let createdSession: ACPActiveSession;
|
|
470
|
+
if (start.recoveryMode?.type === 'lossy-rerun') {
|
|
471
|
+
assertACPResumeCapability({ initialization, harnessId: bridgeType });
|
|
472
|
+
recoveredSessionId = start.recoveryMode.acpSessionId;
|
|
473
|
+
recoveredSessionUpdates = createACPRecoveredSessionUpdates();
|
|
474
|
+
const resumeResponse = await connection.agent.request<
|
|
475
|
+
acp.ResumeSessionResponse,
|
|
476
|
+
acp.ResumeSessionRequest
|
|
477
|
+
>(acp.methods.agent.session.resume, {
|
|
478
|
+
sessionId: recoveredSessionId,
|
|
479
|
+
cwd: workDir,
|
|
480
|
+
mcpServers,
|
|
481
|
+
...(bridgeConfiguration.sessionMeta == null
|
|
482
|
+
? {}
|
|
483
|
+
: { _meta: bridgeConfiguration.sessionMeta }),
|
|
484
|
+
});
|
|
485
|
+
createdSession = createACPRecoveredSession({
|
|
486
|
+
agent: connection.agent,
|
|
487
|
+
sessionId: recoveredSessionId,
|
|
488
|
+
restorationResponse: resumeResponse,
|
|
489
|
+
updates: recoveredSessionUpdates,
|
|
490
|
+
});
|
|
491
|
+
} else if (start.recoveryMode?.type === 'cold-restore') {
|
|
492
|
+
recoveredSessionId = start.recoveryMode.acpSessionId;
|
|
493
|
+
recoveredSessionUpdates = createACPRecoveredSessionUpdates();
|
|
494
|
+
const restored = await restoreACPBridgeSession({
|
|
495
|
+
agent: connection.agent,
|
|
496
|
+
initialization,
|
|
497
|
+
sessionId: recoveredSessionId,
|
|
498
|
+
cwd: workDir,
|
|
499
|
+
mcpServers,
|
|
500
|
+
meta: bridgeConfiguration.sessionMeta,
|
|
501
|
+
harnessId: bridgeType,
|
|
502
|
+
setHistoricalUpdatesSuppressed: ({ suppressed }) => {
|
|
503
|
+
historicalUpdatesSuppressed = suppressed;
|
|
504
|
+
},
|
|
505
|
+
discardCapturedHistory: () => {
|
|
506
|
+
streamCapture?.drainRawValues();
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
coldRestorationMethod = restored.method;
|
|
510
|
+
createdSession = createACPRecoveredSession({
|
|
511
|
+
agent: connection.agent,
|
|
512
|
+
sessionId: recoveredSessionId,
|
|
513
|
+
restorationResponse: restored.response,
|
|
514
|
+
updates: recoveredSessionUpdates,
|
|
515
|
+
});
|
|
516
|
+
} else {
|
|
517
|
+
createdSession = await connection.agent
|
|
518
|
+
.buildSession({
|
|
519
|
+
cwd: workDir,
|
|
520
|
+
mcpServers,
|
|
521
|
+
...(bridgeConfiguration.sessionMeta == null
|
|
522
|
+
? {}
|
|
523
|
+
: { _meta: bridgeConfiguration.sessionMeta }),
|
|
524
|
+
})
|
|
525
|
+
.start();
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
await refreshHostToolCatalog({
|
|
529
|
+
relay: hostToolRelay,
|
|
530
|
+
tools,
|
|
531
|
+
harnessId: bridgeType,
|
|
532
|
+
timeoutMs: CATALOG_REFRESH_TIMEOUT_MS,
|
|
533
|
+
});
|
|
534
|
+
if (start.permissionModeMapping != null) {
|
|
535
|
+
await configureACPPermissionMode({
|
|
536
|
+
agent: connection.agent,
|
|
537
|
+
sessionId: createdSession.sessionId,
|
|
538
|
+
sessionConfiguration: createdSession.newSessionResponse,
|
|
539
|
+
permissionModeMapping: start.permissionModeMapping,
|
|
540
|
+
permissionMode: start.permissionMode ?? 'allow-all',
|
|
541
|
+
harnessId: bridgeType,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
} catch (error) {
|
|
545
|
+
createdSession.dispose();
|
|
546
|
+
if (HarnessBridgeCapabilityUnsupportedError.isInstance(error)) {
|
|
547
|
+
catalogRefreshError = error;
|
|
548
|
+
}
|
|
549
|
+
sessionConfigurationFailure = { error };
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
session = createdSession;
|
|
553
|
+
initializationDiagnostic = createACPInitializationDiagnostic({
|
|
554
|
+
initialization,
|
|
555
|
+
sessionId: createdSession.sessionId,
|
|
556
|
+
});
|
|
557
|
+
sessionConfigurationFingerprint = fingerprint;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function unknownUsage() {
|
|
561
|
+
return {
|
|
562
|
+
inputTokens: {
|
|
563
|
+
total: undefined,
|
|
564
|
+
noCache: undefined,
|
|
565
|
+
cacheRead: undefined,
|
|
566
|
+
cacheWrite: undefined,
|
|
567
|
+
},
|
|
568
|
+
outputTokens: {
|
|
569
|
+
total: undefined,
|
|
570
|
+
text: undefined,
|
|
571
|
+
reasoning: undefined,
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function authenticate({
|
|
577
|
+
agent,
|
|
578
|
+
initialization,
|
|
579
|
+
authentication,
|
|
580
|
+
}: {
|
|
581
|
+
agent: acp.ClientContext;
|
|
582
|
+
initialization: ACPInitializeResult;
|
|
583
|
+
authentication: ACPAuthentication;
|
|
584
|
+
}): Promise<void> {
|
|
585
|
+
assertACPAuthenticationMethod({
|
|
586
|
+
initialization,
|
|
587
|
+
methodId: authentication.methodId,
|
|
588
|
+
});
|
|
589
|
+
await agent.request(acp.methods.agent.authenticate, {
|
|
590
|
+
methodId: authentication.methodId,
|
|
591
|
+
...(authentication.meta == null ? {} : { _meta: authentication.meta }),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function resolveClientApp(): ACPClientApp {
|
|
596
|
+
const name = processEnv.AI_SDK_ACP_CLIENT_APP_NAME;
|
|
597
|
+
const version = processEnv.AI_SDK_ACP_CLIENT_APP_VERSION;
|
|
598
|
+
if (name == null || version == null) {
|
|
599
|
+
throw new Error('ACP client app values were not supplied to the bridge.');
|
|
600
|
+
}
|
|
601
|
+
return { name, version };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function resolveGatewayValues({
|
|
605
|
+
clientApp,
|
|
606
|
+
}: {
|
|
607
|
+
clientApp: ACPClientApp;
|
|
608
|
+
}): ACPGatewayValues {
|
|
609
|
+
const apiKey = processEnv.AI_SDK_ACP_GATEWAY_API_KEY;
|
|
610
|
+
const baseUrl = processEnv.AI_SDK_ACP_GATEWAY_BASE_URL;
|
|
611
|
+
if (apiKey == null || baseUrl == null) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
'AI Gateway profile values were not supplied to the ACP bridge.',
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
apiKey,
|
|
618
|
+
baseUrl,
|
|
619
|
+
clientAppName: clientApp.name,
|
|
620
|
+
clientAppVersion: clientApp.version,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function createChildEnvironment({
|
|
625
|
+
launchEnv,
|
|
626
|
+
}: {
|
|
627
|
+
launchEnv: Readonly<Record<string, string>>;
|
|
628
|
+
}): NodeJS.ProcessEnv {
|
|
629
|
+
const blocked = new Set([
|
|
630
|
+
'BRIDGE_CHANNEL_TOKEN',
|
|
631
|
+
'BRIDGE_WS_PORT',
|
|
632
|
+
'AI_SDK_ACP_GATEWAY_API_KEY',
|
|
633
|
+
'AI_SDK_ACP_GATEWAY_BASE_URL',
|
|
634
|
+
'AI_SDK_ACP_CLIENT_APP_NAME',
|
|
635
|
+
'AI_SDK_ACP_CLIENT_APP_VERSION',
|
|
636
|
+
ACP_BRIDGE_CONFIGURATION_ENV,
|
|
637
|
+
]);
|
|
638
|
+
return {
|
|
639
|
+
...Object.fromEntries(
|
|
640
|
+
Object.entries(processEnv).filter(
|
|
641
|
+
([key, value]) => !blocked.has(key) && value != null,
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
...launchEnv,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
649
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function readImplementationDescriptor({
|
|
653
|
+
path,
|
|
654
|
+
}: {
|
|
655
|
+
path: string;
|
|
656
|
+
}): Promise<ImplementationDescriptor> {
|
|
657
|
+
const text = await readFile(path, 'utf8');
|
|
658
|
+
const value = await new Response(text, {
|
|
659
|
+
headers: { 'content-type': 'application/json' },
|
|
660
|
+
}).json();
|
|
661
|
+
if (
|
|
662
|
+
!isRecord(value) ||
|
|
663
|
+
typeof value.executable !== 'string' ||
|
|
664
|
+
!Array.isArray(value.args) ||
|
|
665
|
+
!value.args.every(item => typeof item === 'string') ||
|
|
666
|
+
!Array.isArray(value.envKeys) ||
|
|
667
|
+
!value.envKeys.every(item => typeof item === 'string')
|
|
668
|
+
) {
|
|
669
|
+
throw new Error('Invalid ACP implementation descriptor.');
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
executable: value.executable,
|
|
673
|
+
args: value.args as string[],
|
|
674
|
+
envKeys: value.envKeys as string[],
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function parseArgs({ args }: { args: ReadonlyArray<string> }): {
|
|
679
|
+
workDir?: string;
|
|
680
|
+
bridgeStateDir?: string;
|
|
681
|
+
implementationDir?: string;
|
|
682
|
+
bridgeType?: string;
|
|
683
|
+
} {
|
|
684
|
+
const result: {
|
|
685
|
+
workDir?: string;
|
|
686
|
+
bridgeStateDir?: string;
|
|
687
|
+
implementationDir?: string;
|
|
688
|
+
bridgeType?: string;
|
|
689
|
+
} = {};
|
|
690
|
+
for (let index = 0; index < args.length; index++) {
|
|
691
|
+
const value = args[index + 1];
|
|
692
|
+
if (value == null) continue;
|
|
693
|
+
if (args[index] === '--workdir') result.workDir = value;
|
|
694
|
+
else if (args[index] === '--bridge-state-dir')
|
|
695
|
+
result.bridgeStateDir = value;
|
|
696
|
+
else if (args[index] === '--implementation-dir')
|
|
697
|
+
result.implementationDir = value;
|
|
698
|
+
else if (args[index] === '--bridge-type') result.bridgeType = value;
|
|
699
|
+
}
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function requireArg({
|
|
704
|
+
value,
|
|
705
|
+
name,
|
|
706
|
+
}: {
|
|
707
|
+
value: string | undefined;
|
|
708
|
+
name: string;
|
|
709
|
+
}): string {
|
|
710
|
+
if (value == null || value.length === 0) {
|
|
711
|
+
throw new Error(`Missing ${name} argument.`);
|
|
712
|
+
}
|
|
713
|
+
return value;
|
|
714
|
+
}
|