@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,1461 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { posix } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
HarnessCapabilityUnsupportedError,
|
|
7
|
+
harnessV1DiagnosticFromBridgeFrame,
|
|
8
|
+
type HarnessV1,
|
|
9
|
+
type HarnessV1Bootstrap,
|
|
10
|
+
type HarnessV1DebugConfig,
|
|
11
|
+
type HarnessV1NetworkSandboxSession,
|
|
12
|
+
type HarnessV1PromptControl,
|
|
13
|
+
type HarnessV1Session,
|
|
14
|
+
type HarnessV1StreamPart,
|
|
15
|
+
type HarnessV1ToolSpec,
|
|
16
|
+
} from '@ai-sdk/harness';
|
|
17
|
+
import { HarnessBridgeCapabilityUnsupportedError } from '@ai-sdk/harness/bridge';
|
|
18
|
+
import {
|
|
19
|
+
createBridgeErrorHandler,
|
|
20
|
+
createBridgeStartupError,
|
|
21
|
+
classifyDiskLog,
|
|
22
|
+
drainBridgeProcessStream,
|
|
23
|
+
forwardBridgeProcessStream,
|
|
24
|
+
markBridgeStarting,
|
|
25
|
+
resolveSandboxHomeDir,
|
|
26
|
+
SandboxChannel,
|
|
27
|
+
shellQuote,
|
|
28
|
+
waitForBridgeReady,
|
|
29
|
+
} from '@ai-sdk/harness/utils';
|
|
30
|
+
import {
|
|
31
|
+
asSchema,
|
|
32
|
+
type Experimental_SandboxProcess,
|
|
33
|
+
type ToolSet,
|
|
34
|
+
} from '@ai-sdk/provider-utils';
|
|
35
|
+
import { WebSocket } from 'ws';
|
|
36
|
+
import {
|
|
37
|
+
createACPAuthenticationProfileIdentity,
|
|
38
|
+
resolveACPProviderAuthentication,
|
|
39
|
+
resolveACPProviderAuthenticationCompatibility,
|
|
40
|
+
type ACPAuthenticationProfileIdentity,
|
|
41
|
+
type ACPClientApp,
|
|
42
|
+
} from '../acp-auth';
|
|
43
|
+
import {
|
|
44
|
+
createACPV1Implementation,
|
|
45
|
+
createImplementationDescriptor,
|
|
46
|
+
createImplementationIdentity,
|
|
47
|
+
createImplementationInstallCommand,
|
|
48
|
+
createImplementationManifest,
|
|
49
|
+
getImplementationLockfile,
|
|
50
|
+
resolveImplementationEnvironment,
|
|
51
|
+
validateACPV1Implementation,
|
|
52
|
+
} from './implementation';
|
|
53
|
+
import {
|
|
54
|
+
outboundMessageSchema,
|
|
55
|
+
type ACPBuiltinToolMapping,
|
|
56
|
+
type ACPColdSessionState,
|
|
57
|
+
type ACPTurnStartConfig,
|
|
58
|
+
type InboundMessage,
|
|
59
|
+
type OutboundMessage,
|
|
60
|
+
type StartMessage,
|
|
61
|
+
} from './acp-v1-bridge-protocol';
|
|
62
|
+
import { createACPBridgeEnvironment } from './bridge/acp-v1-bridge-environment';
|
|
63
|
+
import {
|
|
64
|
+
createACPColdSessionState,
|
|
65
|
+
createACPTurnStartConfig,
|
|
66
|
+
} from './acp-v1-turn-start-config';
|
|
67
|
+
import {
|
|
68
|
+
resolveACPInitialGuidanceApplied,
|
|
69
|
+
shouldMaterializeACPSkills,
|
|
70
|
+
validateACPLifecycleCompatibility,
|
|
71
|
+
type ACPLifecycleData,
|
|
72
|
+
} from './acp-v1-lifecycle';
|
|
73
|
+
import {
|
|
74
|
+
convertHarnessPromptToACPTextBlocks,
|
|
75
|
+
prependACPInitialGuidance,
|
|
76
|
+
type ACPSkillCatalogEntry,
|
|
77
|
+
} from './acp-v1-prompt';
|
|
78
|
+
import type {
|
|
79
|
+
ACPPermissionModeMapping,
|
|
80
|
+
ACPPermissionModeTarget,
|
|
81
|
+
ACPSerializableValue,
|
|
82
|
+
ACPV1Settings,
|
|
83
|
+
} from './acp-v1-settings';
|
|
84
|
+
import {
|
|
85
|
+
createACPSkillsFingerprint,
|
|
86
|
+
materializeACPSkills,
|
|
87
|
+
resolveACPPrivateSessionDirectory,
|
|
88
|
+
} from './acp-v1-skills';
|
|
89
|
+
|
|
90
|
+
const HARNESS_ID_REGEXP = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
91
|
+
|
|
92
|
+
type ACPChannel = SandboxChannel<OutboundMessage, InboundMessage>;
|
|
93
|
+
|
|
94
|
+
type ACPRespawnStrategy =
|
|
95
|
+
| {
|
|
96
|
+
readonly mode: 'disk-replay';
|
|
97
|
+
readonly reason: string;
|
|
98
|
+
readonly afterSeq: number;
|
|
99
|
+
}
|
|
100
|
+
| {
|
|
101
|
+
readonly mode: 'lossy-rerun';
|
|
102
|
+
readonly reason: string;
|
|
103
|
+
readonly turnStartConfig: ACPTurnStartConfig;
|
|
104
|
+
readonly acpSessionId: string;
|
|
105
|
+
}
|
|
106
|
+
| {
|
|
107
|
+
readonly mode: 'cold-restore';
|
|
108
|
+
readonly turnStartConfig: ACPTurnStartConfig;
|
|
109
|
+
readonly acpSessionId: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function createACPV1<TBuiltinTools extends ToolSet = {}>({
|
|
113
|
+
settings,
|
|
114
|
+
builtinTools,
|
|
115
|
+
port: portOverride,
|
|
116
|
+
startupTimeoutMs,
|
|
117
|
+
clientApp,
|
|
118
|
+
lifecycleStateSchema,
|
|
119
|
+
}: {
|
|
120
|
+
settings: ACPV1Settings;
|
|
121
|
+
builtinTools: TBuiltinTools;
|
|
122
|
+
port?: number;
|
|
123
|
+
startupTimeoutMs?: number;
|
|
124
|
+
clientApp: ACPClientApp;
|
|
125
|
+
lifecycleStateSchema: NonNullable<
|
|
126
|
+
HarnessV1<TBuiltinTools>['lifecycleStateSchema']
|
|
127
|
+
>;
|
|
128
|
+
}): HarnessV1<TBuiltinTools> {
|
|
129
|
+
if (!HARNESS_ID_REGEXP.test(settings.harnessId)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`ACP harnessId must be a stable kebab-case identifier; received ${JSON.stringify(settings.harnessId)}.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const implementation = createACPV1Implementation({ settings });
|
|
135
|
+
validateACPV1Implementation(implementation);
|
|
136
|
+
|
|
137
|
+
const BOOTSTRAP_DIR = `.harness-bootstrap/${settings.harnessId}`;
|
|
138
|
+
|
|
139
|
+
let cachedBootstrap: HarnessV1Bootstrap | undefined;
|
|
140
|
+
const permissionModeMapping = isCompletePermissionModeMapping({
|
|
141
|
+
value: settings.permissionModeMapping,
|
|
142
|
+
})
|
|
143
|
+
? settings.permissionModeMapping
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
specificationVersion: 'harness-v1',
|
|
148
|
+
harnessId: settings.harnessId,
|
|
149
|
+
builtinTools,
|
|
150
|
+
supportsBuiltinToolApprovals: true,
|
|
151
|
+
supportsBuiltinToolFiltering: false,
|
|
152
|
+
lifecycleStateSchema,
|
|
153
|
+
getBootstrap: async () => {
|
|
154
|
+
if (cachedBootstrap != null) return cachedBootstrap;
|
|
155
|
+
const [bridgePackage, bridgeLock, bridge, hostToolMCP] =
|
|
156
|
+
await Promise.all([
|
|
157
|
+
readBridgeAsset({ name: 'package.json' }),
|
|
158
|
+
readBridgeAsset({ name: 'pnpm-lock.yaml' }),
|
|
159
|
+
readBridgeAsset({ name: 'index.mjs' }),
|
|
160
|
+
readBridgeAsset({ name: 'host-tool-mcp.mjs' }),
|
|
161
|
+
]);
|
|
162
|
+
const implementationLock = getImplementationLockfile({
|
|
163
|
+
implementation,
|
|
164
|
+
});
|
|
165
|
+
cachedBootstrap = {
|
|
166
|
+
harnessId: settings.harnessId,
|
|
167
|
+
bootstrapDir: BOOTSTRAP_DIR,
|
|
168
|
+
files: [
|
|
169
|
+
{
|
|
170
|
+
path: `${BOOTSTRAP_DIR}/package.json`,
|
|
171
|
+
content: bridgePackage,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`,
|
|
175
|
+
content: bridgeLock,
|
|
176
|
+
},
|
|
177
|
+
{ path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge },
|
|
178
|
+
{
|
|
179
|
+
path: `${BOOTSTRAP_DIR}/host-tool-mcp.mjs`,
|
|
180
|
+
content: hostToolMCP,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
path: `${BOOTSTRAP_DIR}/implementation/package.json`,
|
|
184
|
+
content: createImplementationManifest({
|
|
185
|
+
implementation,
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
path: `${BOOTSTRAP_DIR}/implementation/implementation.json`,
|
|
190
|
+
content: createImplementationDescriptor({
|
|
191
|
+
implementation,
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
...(implementationLock == null
|
|
195
|
+
? []
|
|
196
|
+
: [
|
|
197
|
+
{
|
|
198
|
+
path: `${BOOTSTRAP_DIR}/implementation/pnpm-lock.yaml`,
|
|
199
|
+
content: implementationLock,
|
|
200
|
+
},
|
|
201
|
+
]),
|
|
202
|
+
],
|
|
203
|
+
commands: [
|
|
204
|
+
{
|
|
205
|
+
command: 'pnpm install --frozen-lockfile --store-dir .pnpm-store',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
command: createImplementationInstallCommand({
|
|
209
|
+
implementationDir: 'implementation',
|
|
210
|
+
storeDir: '../.pnpm-store',
|
|
211
|
+
implementation,
|
|
212
|
+
}),
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
return cachedBootstrap;
|
|
217
|
+
},
|
|
218
|
+
doStart: async startOptions => {
|
|
219
|
+
if (startOptions.builtinToolFiltering != null) {
|
|
220
|
+
throw unsupported({
|
|
221
|
+
harnessId: settings.harnessId,
|
|
222
|
+
message:
|
|
223
|
+
'ACP built-in tool filtering is not available in this initial ACP v1 implementation.',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const permissionMode = startOptions.permissionMode ?? 'allow-all';
|
|
227
|
+
const env = { ...process.env };
|
|
228
|
+
const providerAuthenticationCompatibility =
|
|
229
|
+
resolveACPProviderAuthenticationCompatibility({
|
|
230
|
+
auth: settings.auth,
|
|
231
|
+
providerAuthentication: settings.providerAuthentication,
|
|
232
|
+
env,
|
|
233
|
+
});
|
|
234
|
+
const implementationIdentity = createImplementationIdentity({
|
|
235
|
+
harnessId: settings.harnessId,
|
|
236
|
+
acpVersion: 'v1',
|
|
237
|
+
implementation,
|
|
238
|
+
clientApp,
|
|
239
|
+
providerAuthentication: providerAuthenticationCompatibility,
|
|
240
|
+
permissionModeMapping: settings.permissionModeMapping,
|
|
241
|
+
});
|
|
242
|
+
const authenticationProfile = createACPAuthenticationProfileIdentity({
|
|
243
|
+
authentication: settings.authentication,
|
|
244
|
+
providerAuthenticationCompatibility,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const resolvedProviderAuthentication = resolveACPProviderAuthentication({
|
|
248
|
+
auth: {
|
|
249
|
+
mode: settings.auth,
|
|
250
|
+
providerAuthentication: settings.providerAuthentication,
|
|
251
|
+
clientApp,
|
|
252
|
+
},
|
|
253
|
+
env,
|
|
254
|
+
compatibility: providerAuthenticationCompatibility,
|
|
255
|
+
});
|
|
256
|
+
const sandboxSession = startOptions.sandboxSession;
|
|
257
|
+
const sandbox = sandboxSession.restricted();
|
|
258
|
+
const resolvedBridgeDir = posix.resolve(
|
|
259
|
+
sandboxSession.defaultWorkingDirectory,
|
|
260
|
+
BOOTSTRAP_DIR,
|
|
261
|
+
);
|
|
262
|
+
const resolvedImplementationDir = `${resolvedBridgeDir}/implementation`;
|
|
263
|
+
const workDir = startOptions.sessionWorkDir;
|
|
264
|
+
const sandboxHomeDir = await resolveSandboxHomeDir({
|
|
265
|
+
sandbox,
|
|
266
|
+
abortSignal: startOptions.abortSignal,
|
|
267
|
+
});
|
|
268
|
+
const privateSessionDir = resolveACPPrivateSessionDirectory({
|
|
269
|
+
sandboxHomeDir,
|
|
270
|
+
sessionWorkDir: workDir,
|
|
271
|
+
harnessId: settings.harnessId,
|
|
272
|
+
sessionId: startOptions.sessionId,
|
|
273
|
+
});
|
|
274
|
+
const bridgeStateDir = `${privateSessionDir}/bridge`;
|
|
275
|
+
const continueFrom =
|
|
276
|
+
startOptions.continueFrom ?? startOptions.resumeFrom?.continueFrom;
|
|
277
|
+
const lifecycleState = continueFrom ?? startOptions.resumeFrom;
|
|
278
|
+
const isContinue = continueFrom != null;
|
|
279
|
+
const isResume = lifecycleState != null;
|
|
280
|
+
const lifecycleData =
|
|
281
|
+
lifecycleState != null
|
|
282
|
+
? (lifecycleState.data as unknown as ACPLifecycleData)
|
|
283
|
+
: undefined;
|
|
284
|
+
if (lifecycleData != null && lifecycleState != null) {
|
|
285
|
+
validateACPLifecycleCompatibility({
|
|
286
|
+
harnessId: settings.harnessId,
|
|
287
|
+
lifecycleHarnessId: lifecycleState.harnessId,
|
|
288
|
+
implementationIdentity,
|
|
289
|
+
authenticationProfile,
|
|
290
|
+
lifecycleData,
|
|
291
|
+
sandboxId: sandboxSession.id,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const skills = startOptions.skills ?? [];
|
|
295
|
+
const skillsFingerprint = createACPSkillsFingerprint({ skills });
|
|
296
|
+
const shouldMaterializeSkills = shouldMaterializeACPSkills({
|
|
297
|
+
isResume,
|
|
298
|
+
lifecycleState: lifecycleData,
|
|
299
|
+
skillsFingerprint,
|
|
300
|
+
});
|
|
301
|
+
let skillCatalog: ReadonlyArray<ACPSkillCatalogEntry> = [];
|
|
302
|
+
if (skills.length > 0) {
|
|
303
|
+
skillCatalog = (
|
|
304
|
+
await materializeACPSkills({
|
|
305
|
+
sandbox,
|
|
306
|
+
sandboxHomeDir,
|
|
307
|
+
sessionWorkDir: workDir,
|
|
308
|
+
harnessId: settings.harnessId,
|
|
309
|
+
sessionId: startOptions.sessionId,
|
|
310
|
+
skills,
|
|
311
|
+
shouldMaterialize: shouldMaterializeSkills,
|
|
312
|
+
abortSignal: startOptions.abortSignal,
|
|
313
|
+
})
|
|
314
|
+
).catalog;
|
|
315
|
+
}
|
|
316
|
+
const report = startOptions.observability?.report;
|
|
317
|
+
const onDiagnostic = report
|
|
318
|
+
? (frame: Parameters<typeof harnessV1DiagnosticFromBridgeFrame>[0]) =>
|
|
319
|
+
report(
|
|
320
|
+
harnessV1DiagnosticFromBridgeFrame(frame, {
|
|
321
|
+
sessionId: startOptions.sessionId,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
}),
|
|
324
|
+
)
|
|
325
|
+
: undefined;
|
|
326
|
+
const onBridgeError = createBridgeErrorHandler({
|
|
327
|
+
harnessId: settings.harnessId,
|
|
328
|
+
sessionId: startOptions.sessionId,
|
|
329
|
+
});
|
|
330
|
+
const builtinToolCatalog = serializeBuiltinTools({ builtinTools });
|
|
331
|
+
let respawnStrategy: ACPRespawnStrategy | undefined;
|
|
332
|
+
|
|
333
|
+
if (isResume) {
|
|
334
|
+
if (lifecycleData == null) {
|
|
335
|
+
throw new Error('ACP lifecycle state data is missing.');
|
|
336
|
+
}
|
|
337
|
+
const coords = lifecycleData.bridge;
|
|
338
|
+
if (coords == null && isContinue) {
|
|
339
|
+
throw unsupported({
|
|
340
|
+
harnessId: settings.harnessId,
|
|
341
|
+
message:
|
|
342
|
+
'ACP continuation state does not contain bridge coordinates required for replay or process-loss rerun.',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (coords != null) {
|
|
346
|
+
try {
|
|
347
|
+
const attachUrl =
|
|
348
|
+
(await sandboxSession.getPortUrl({
|
|
349
|
+
port: coords.port,
|
|
350
|
+
protocol: 'ws',
|
|
351
|
+
})) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
|
|
352
|
+
const attachChannel: ACPChannel = new SandboxChannel({
|
|
353
|
+
connect: () => openWebSocket({ url: attachUrl }),
|
|
354
|
+
outboundSchema: outboundMessageSchema,
|
|
355
|
+
initialLastSeenEventId: coords.lastSeenEventId,
|
|
356
|
+
onDiagnostic,
|
|
357
|
+
onBridgeError,
|
|
358
|
+
});
|
|
359
|
+
await attachChannel.open(isContinue ? { resume: true } : undefined);
|
|
360
|
+
return createSession({
|
|
361
|
+
sessionId: startOptions.sessionId,
|
|
362
|
+
harnessId: settings.harnessId,
|
|
363
|
+
channel: attachChannel,
|
|
364
|
+
proc: undefined,
|
|
365
|
+
modelId: settings.modelId,
|
|
366
|
+
sessionMeta: settings.session?.meta,
|
|
367
|
+
debug: startOptions.observability?.debug,
|
|
368
|
+
implementationIdentity,
|
|
369
|
+
authenticationProfile,
|
|
370
|
+
builtinTools: builtinToolCatalog,
|
|
371
|
+
permissionMode,
|
|
372
|
+
permissionModeMapping,
|
|
373
|
+
initialGuidanceApplied: resolveACPInitialGuidanceApplied({
|
|
374
|
+
isResume: true,
|
|
375
|
+
lifecycleState: lifecycleData,
|
|
376
|
+
}),
|
|
377
|
+
skillCatalog,
|
|
378
|
+
skillsFingerprint,
|
|
379
|
+
acpSessionId: lifecycleData.acpSessionId,
|
|
380
|
+
bridgePort: coords.port,
|
|
381
|
+
bridgeToken: coords.token,
|
|
382
|
+
sandboxId: sandboxSession.id,
|
|
383
|
+
isResume: true,
|
|
384
|
+
turnInFlight: isContinue,
|
|
385
|
+
bridgeStateDir,
|
|
386
|
+
turnStartConfig: lifecycleData.turnStartConfig,
|
|
387
|
+
recoveryStatus: lifecycleData.recovery,
|
|
388
|
+
restoration: lifecycleData.restoration,
|
|
389
|
+
replayOnly: false,
|
|
390
|
+
lossyRerun: false,
|
|
391
|
+
});
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (isContinue) {
|
|
394
|
+
const eventLog = await Promise.resolve(
|
|
395
|
+
sandbox.readTextFile({
|
|
396
|
+
path: `${bridgeStateDir}/event-log.ndjson`,
|
|
397
|
+
abortSignal: startOptions.abortSignal,
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
const recoveryMode = await classifyDiskLog(eventLog);
|
|
401
|
+
if (recoveryMode === 'replay') {
|
|
402
|
+
respawnStrategy = {
|
|
403
|
+
mode: 'disk-replay',
|
|
404
|
+
reason: 'completed coherent event log',
|
|
405
|
+
afterSeq: coords.lastSeenEventId,
|
|
406
|
+
};
|
|
407
|
+
} else {
|
|
408
|
+
const turnStartConfig = lifecycleData.turnStartConfig;
|
|
409
|
+
const acpSessionId = lifecycleData.acpSessionId;
|
|
410
|
+
if (turnStartConfig == null || acpSessionId == null) {
|
|
411
|
+
throw unsupported({
|
|
412
|
+
harnessId: settings.harnessId,
|
|
413
|
+
message:
|
|
414
|
+
'ACP process-loss recovery is unavailable because the lifecycle state does not contain the persisted turn start configuration and ACP session identifier.',
|
|
415
|
+
cause: error,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
validateACPTurnStartConfig({
|
|
419
|
+
turnStartConfig,
|
|
420
|
+
authenticationProfile,
|
|
421
|
+
sessionMeta: settings.session?.meta,
|
|
422
|
+
builtinTools: builtinToolCatalog,
|
|
423
|
+
permissionModeMapping,
|
|
424
|
+
});
|
|
425
|
+
respawnStrategy = {
|
|
426
|
+
mode: 'lossy-rerun',
|
|
427
|
+
reason: 'event log not replayable',
|
|
428
|
+
turnStartConfig,
|
|
429
|
+
acpSessionId,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (!isContinue) {
|
|
436
|
+
const coldSession = lifecycleData.coldSession;
|
|
437
|
+
const acpSessionId = lifecycleData.acpSessionId;
|
|
438
|
+
if (coldSession == null || acpSessionId == null) {
|
|
439
|
+
throw unsupported({
|
|
440
|
+
harnessId: settings.harnessId,
|
|
441
|
+
message:
|
|
442
|
+
'Cold ACP session restoration requires persisted cold-session configuration and an ACP session identifier.',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const turnStartConfig = validateACPColdSessionConfiguration({
|
|
446
|
+
coldSession,
|
|
447
|
+
modelId: settings.modelId,
|
|
448
|
+
permissionMode,
|
|
449
|
+
authenticationProfile,
|
|
450
|
+
sessionMeta: settings.session?.meta,
|
|
451
|
+
builtinTools: builtinToolCatalog,
|
|
452
|
+
permissionModeMapping,
|
|
453
|
+
debug: startOptions.observability?.debug,
|
|
454
|
+
});
|
|
455
|
+
respawnStrategy = {
|
|
456
|
+
mode: 'cold-restore',
|
|
457
|
+
turnStartConfig,
|
|
458
|
+
acpSessionId,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const port = resolveBridgePort({
|
|
464
|
+
sandboxSession,
|
|
465
|
+
override: portOverride,
|
|
466
|
+
harnessId: settings.harnessId,
|
|
467
|
+
});
|
|
468
|
+
const token = randomBytes(32).toString('hex');
|
|
469
|
+
await sandbox.run({
|
|
470
|
+
command: `mkdir -p ${shellQuote(workDir)} ${shellQuote(bridgeStateDir)}`,
|
|
471
|
+
abortSignal: startOptions.abortSignal,
|
|
472
|
+
});
|
|
473
|
+
await markBridgeStarting({
|
|
474
|
+
sandbox,
|
|
475
|
+
bridgeStateDir,
|
|
476
|
+
bridgeType: settings.harnessId,
|
|
477
|
+
abortSignal: startOptions.abortSignal,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const proc = await sandbox.spawn({
|
|
481
|
+
command:
|
|
482
|
+
`node ${shellQuote(`${resolvedBridgeDir}/bridge.mjs`)}` +
|
|
483
|
+
` --workdir ${shellQuote(workDir)}` +
|
|
484
|
+
` --bridge-state-dir ${shellQuote(bridgeStateDir)}` +
|
|
485
|
+
` --implementation-dir ${shellQuote(resolvedImplementationDir)}` +
|
|
486
|
+
` --bridge-type ${shellQuote(settings.harnessId)}`,
|
|
487
|
+
env: {
|
|
488
|
+
...resolveImplementationEnvironment({
|
|
489
|
+
implementation,
|
|
490
|
+
env,
|
|
491
|
+
}),
|
|
492
|
+
...createACPBridgeEnvironment({
|
|
493
|
+
authentication: settings.authentication,
|
|
494
|
+
providerAuthentication:
|
|
495
|
+
resolvedProviderAuthentication.providerAuthentication,
|
|
496
|
+
sessionMeta: settings.session?.meta,
|
|
497
|
+
}),
|
|
498
|
+
...resolvedProviderAuthentication.env,
|
|
499
|
+
BRIDGE_CHANNEL_TOKEN: token,
|
|
500
|
+
BRIDGE_WS_PORT: String(port),
|
|
501
|
+
...(respawnStrategy?.mode === 'disk-replay'
|
|
502
|
+
? {
|
|
503
|
+
BRIDGE_REPLAY_FROM_DISK: '1',
|
|
504
|
+
}
|
|
505
|
+
: {}),
|
|
506
|
+
},
|
|
507
|
+
abortSignal: startOptions.abortSignal,
|
|
508
|
+
});
|
|
509
|
+
const stderrTail: string[] = [];
|
|
510
|
+
const bridgeStderrDone = forwardBridgeProcessStream({
|
|
511
|
+
stream: proc.stderr,
|
|
512
|
+
streamName: 'stderr',
|
|
513
|
+
source: settings.harnessId,
|
|
514
|
+
collectTail: stderrTail,
|
|
515
|
+
});
|
|
516
|
+
const timeoutMs = startupTimeoutMs ?? 120_000;
|
|
517
|
+
const { port: boundPort } = await waitForBridgeReady({
|
|
518
|
+
proc,
|
|
519
|
+
sandbox,
|
|
520
|
+
bridgeStateDir,
|
|
521
|
+
bridgeType: settings.harnessId,
|
|
522
|
+
timeoutMs,
|
|
523
|
+
abortSignal: startOptions.abortSignal,
|
|
524
|
+
createTimeoutError: ({ proc, stdoutTail }) =>
|
|
525
|
+
createBridgeStartupError({
|
|
526
|
+
message: `${settings.harnessId} ACP bridge did not become ready in time.`,
|
|
527
|
+
proc,
|
|
528
|
+
stdoutTail,
|
|
529
|
+
stderrTail,
|
|
530
|
+
stderrDone: bridgeStderrDone,
|
|
531
|
+
}),
|
|
532
|
+
createExitError: ({ proc, stdoutTail }) =>
|
|
533
|
+
createBridgeStartupError({
|
|
534
|
+
message: `${settings.harnessId} ACP bridge exited before becoming ready.`,
|
|
535
|
+
proc,
|
|
536
|
+
stdoutTail,
|
|
537
|
+
stderrTail,
|
|
538
|
+
stderrDone: bridgeStderrDone,
|
|
539
|
+
}),
|
|
540
|
+
});
|
|
541
|
+
void drainBridgeProcessStream(proc.stdout);
|
|
542
|
+
|
|
543
|
+
const wsUrl =
|
|
544
|
+
(await sandboxSession.getPortUrl({
|
|
545
|
+
port: boundPort,
|
|
546
|
+
protocol: 'ws',
|
|
547
|
+
})) + `?agent_bridge_token=${encodeURIComponent(token)}`;
|
|
548
|
+
const channel: ACPChannel = new SandboxChannel({
|
|
549
|
+
connect: () => openWebSocket({ url: wsUrl }),
|
|
550
|
+
outboundSchema: outboundMessageSchema,
|
|
551
|
+
...(respawnStrategy?.mode === 'disk-replay'
|
|
552
|
+
? { initialLastSeenEventId: respawnStrategy.afterSeq }
|
|
553
|
+
: {}),
|
|
554
|
+
onDiagnostic,
|
|
555
|
+
onBridgeError,
|
|
556
|
+
});
|
|
557
|
+
await channel.open(
|
|
558
|
+
respawnStrategy?.mode === 'disk-replay' ? { resume: true } : undefined,
|
|
559
|
+
);
|
|
560
|
+
let coldRestoration: 'resume' | 'load' | undefined;
|
|
561
|
+
if (respawnStrategy?.mode === 'cold-restore') {
|
|
562
|
+
try {
|
|
563
|
+
coldRestoration = await restoreColdACPSession({
|
|
564
|
+
channel,
|
|
565
|
+
harnessId: settings.harnessId,
|
|
566
|
+
start: {
|
|
567
|
+
type: 'start',
|
|
568
|
+
prompt: [],
|
|
569
|
+
debug: startOptions.observability?.debug,
|
|
570
|
+
builtinTools: builtinToolCatalog,
|
|
571
|
+
permissionMode,
|
|
572
|
+
permissionModeMapping,
|
|
573
|
+
tools: respawnStrategy.turnStartConfig.tools,
|
|
574
|
+
turnStartConfig: respawnStrategy.turnStartConfig,
|
|
575
|
+
recoveryMode: {
|
|
576
|
+
type: 'cold-restore',
|
|
577
|
+
acpSessionId: respawnStrategy.acpSessionId,
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
} catch (error) {
|
|
582
|
+
channel.beginClose();
|
|
583
|
+
try {
|
|
584
|
+
if (!channel.isClosed()) {
|
|
585
|
+
channel.send({ type: 'destroy' });
|
|
586
|
+
}
|
|
587
|
+
} catch {}
|
|
588
|
+
try {
|
|
589
|
+
await proc.kill();
|
|
590
|
+
} catch {}
|
|
591
|
+
channel.close();
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return createSession({
|
|
597
|
+
sessionId: startOptions.sessionId,
|
|
598
|
+
harnessId: settings.harnessId,
|
|
599
|
+
channel,
|
|
600
|
+
proc,
|
|
601
|
+
modelId: settings.modelId,
|
|
602
|
+
sessionMeta: settings.session?.meta,
|
|
603
|
+
debug: startOptions.observability?.debug,
|
|
604
|
+
implementationIdentity,
|
|
605
|
+
authenticationProfile,
|
|
606
|
+
builtinTools: builtinToolCatalog,
|
|
607
|
+
permissionMode,
|
|
608
|
+
permissionModeMapping,
|
|
609
|
+
initialGuidanceApplied: resolveACPInitialGuidanceApplied({
|
|
610
|
+
isResume,
|
|
611
|
+
lifecycleState: lifecycleData,
|
|
612
|
+
}),
|
|
613
|
+
skillCatalog,
|
|
614
|
+
skillsFingerprint,
|
|
615
|
+
acpSessionId: lifecycleData?.acpSessionId,
|
|
616
|
+
bridgePort: boundPort,
|
|
617
|
+
bridgeToken: token,
|
|
618
|
+
sandboxId: sandboxSession.id,
|
|
619
|
+
isResume,
|
|
620
|
+
turnInFlight:
|
|
621
|
+
respawnStrategy?.mode === 'disk-replay' ||
|
|
622
|
+
respawnStrategy?.mode === 'lossy-rerun',
|
|
623
|
+
bridgeStateDir,
|
|
624
|
+
turnStartConfig:
|
|
625
|
+
respawnStrategy?.mode === 'lossy-rerun' ||
|
|
626
|
+
respawnStrategy?.mode === 'cold-restore'
|
|
627
|
+
? respawnStrategy.turnStartConfig
|
|
628
|
+
: lifecycleData?.turnStartConfig,
|
|
629
|
+
recoveryStatus:
|
|
630
|
+
respawnStrategy?.mode === 'disk-replay' ||
|
|
631
|
+
respawnStrategy?.mode === 'lossy-rerun'
|
|
632
|
+
? {
|
|
633
|
+
mode: respawnStrategy.mode,
|
|
634
|
+
reason: respawnStrategy.reason,
|
|
635
|
+
}
|
|
636
|
+
: undefined,
|
|
637
|
+
restoration:
|
|
638
|
+
coldRestoration == null
|
|
639
|
+
? lifecycleData?.restoration
|
|
640
|
+
: { method: coldRestoration },
|
|
641
|
+
replayOnly: respawnStrategy?.mode === 'disk-replay',
|
|
642
|
+
lossyRerun: respawnStrategy?.mode === 'lossy-rerun',
|
|
643
|
+
});
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function readBridgeAsset({ name }: { name: string }): Promise<string> {
|
|
649
|
+
const candidates = resolveBridgeAssetCandidates({
|
|
650
|
+
name,
|
|
651
|
+
moduleUrl: import.meta.url,
|
|
652
|
+
});
|
|
653
|
+
let lastError: unknown;
|
|
654
|
+
for (const url of candidates) {
|
|
655
|
+
try {
|
|
656
|
+
return await readFile(fileURLToPath(url), 'utf8');
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
659
|
+
lastError = error;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
throw lastError ?? new Error(`ACP bridge asset not found: ${name}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export function resolveBridgeAssetCandidates({
|
|
666
|
+
name,
|
|
667
|
+
moduleUrl,
|
|
668
|
+
}: {
|
|
669
|
+
name: string;
|
|
670
|
+
moduleUrl: string | URL;
|
|
671
|
+
}): URL[] {
|
|
672
|
+
return [
|
|
673
|
+
new URL(`./bridge/${name}`, moduleUrl),
|
|
674
|
+
new URL(`../bridge/${name}`, moduleUrl),
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function resolveBridgePort({
|
|
679
|
+
sandboxSession,
|
|
680
|
+
override,
|
|
681
|
+
harnessId,
|
|
682
|
+
}: {
|
|
683
|
+
sandboxSession: HarnessV1NetworkSandboxSession;
|
|
684
|
+
override: number | undefined;
|
|
685
|
+
harnessId: string;
|
|
686
|
+
}): number {
|
|
687
|
+
if (override !== undefined) return override;
|
|
688
|
+
if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
|
|
689
|
+
throw unsupported({
|
|
690
|
+
harnessId,
|
|
691
|
+
message:
|
|
692
|
+
`The ${harnessId} ACP harness needs a TCP port exposed by the sandbox. ` +
|
|
693
|
+
'Create the sandbox with `ports: [<port>]` or configure the harness `port`.',
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function openWebSocket({ url }: { url: string }): Promise<WebSocket> {
|
|
698
|
+
return new Promise((resolve, reject) => {
|
|
699
|
+
const ws = new WebSocket(url);
|
|
700
|
+
const onOpen = () => {
|
|
701
|
+
ws.off('error', onError);
|
|
702
|
+
resolve(ws);
|
|
703
|
+
};
|
|
704
|
+
const onError = (error: Error) => {
|
|
705
|
+
ws.off('open', onOpen);
|
|
706
|
+
reject(error);
|
|
707
|
+
};
|
|
708
|
+
ws.once('open', onOpen);
|
|
709
|
+
ws.once('error', onError);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function restoreColdACPSession({
|
|
714
|
+
channel,
|
|
715
|
+
harnessId,
|
|
716
|
+
start,
|
|
717
|
+
}: {
|
|
718
|
+
channel: ACPChannel;
|
|
719
|
+
harnessId: string;
|
|
720
|
+
start: StartMessage;
|
|
721
|
+
}): Promise<'resume' | 'load'> {
|
|
722
|
+
return new Promise<{
|
|
723
|
+
method?: 'resume' | 'load';
|
|
724
|
+
error?: unknown;
|
|
725
|
+
}>(complete => {
|
|
726
|
+
let restorationMethod: 'resume' | 'load' | undefined;
|
|
727
|
+
let settled = false;
|
|
728
|
+
const subscriptions: Array<() => void> = [];
|
|
729
|
+
const cleanup = () => {
|
|
730
|
+
for (const unsubscribe of subscriptions) unsubscribe();
|
|
731
|
+
};
|
|
732
|
+
const settle = ({
|
|
733
|
+
method,
|
|
734
|
+
error,
|
|
735
|
+
}: {
|
|
736
|
+
method?: 'resume' | 'load';
|
|
737
|
+
error?: unknown;
|
|
738
|
+
}) => {
|
|
739
|
+
if (settled) return;
|
|
740
|
+
settled = true;
|
|
741
|
+
cleanup();
|
|
742
|
+
complete({
|
|
743
|
+
...(method == null ? {} : { method }),
|
|
744
|
+
...(error === undefined ? {} : { error }),
|
|
745
|
+
});
|
|
746
|
+
};
|
|
747
|
+
subscriptions.push(
|
|
748
|
+
channel.on('raw', event => {
|
|
749
|
+
const rawValue = event.rawValue;
|
|
750
|
+
if (
|
|
751
|
+
isRecord(rawValue) &&
|
|
752
|
+
rawValue.type === 'acp-session-restored' &&
|
|
753
|
+
(rawValue.method === 'resume' || rawValue.method === 'load')
|
|
754
|
+
) {
|
|
755
|
+
restorationMethod = rawValue.method;
|
|
756
|
+
}
|
|
757
|
+
}),
|
|
758
|
+
);
|
|
759
|
+
subscriptions.push(
|
|
760
|
+
channel.on('finish', () => {
|
|
761
|
+
settle({ method: restorationMethod });
|
|
762
|
+
}),
|
|
763
|
+
);
|
|
764
|
+
subscriptions.push(
|
|
765
|
+
channel.on('error', event => {
|
|
766
|
+
settle({
|
|
767
|
+
error: deserializeBridgeError({
|
|
768
|
+
error: event.error,
|
|
769
|
+
harnessId,
|
|
770
|
+
}),
|
|
771
|
+
});
|
|
772
|
+
}),
|
|
773
|
+
);
|
|
774
|
+
channel.onClose((_code, reason) => {
|
|
775
|
+
settle({
|
|
776
|
+
error: new Error(
|
|
777
|
+
`${harnessId} ACP bridge closed during cold restoration: ${reason}`,
|
|
778
|
+
),
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
channel.send(start);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
settle({ error });
|
|
785
|
+
}
|
|
786
|
+
}).then(result => {
|
|
787
|
+
if (result.error !== undefined) throw result.error;
|
|
788
|
+
if (result.method == null) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
`${harnessId} ACP cold restoration completed without identifying the negotiated method.`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return result.method;
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function createSession({
|
|
798
|
+
sessionId,
|
|
799
|
+
harnessId,
|
|
800
|
+
channel,
|
|
801
|
+
proc,
|
|
802
|
+
modelId,
|
|
803
|
+
sessionMeta,
|
|
804
|
+
debug,
|
|
805
|
+
implementationIdentity,
|
|
806
|
+
authenticationProfile,
|
|
807
|
+
builtinTools,
|
|
808
|
+
permissionMode,
|
|
809
|
+
permissionModeMapping,
|
|
810
|
+
initialGuidanceApplied: initialGuidanceAppliedAtStart,
|
|
811
|
+
skillCatalog,
|
|
812
|
+
skillsFingerprint,
|
|
813
|
+
acpSessionId: acpSessionIdAtStart,
|
|
814
|
+
bridgePort,
|
|
815
|
+
bridgeToken,
|
|
816
|
+
sandboxId,
|
|
817
|
+
isResume,
|
|
818
|
+
turnInFlight: turnInFlightAtStart,
|
|
819
|
+
bridgeStateDir,
|
|
820
|
+
turnStartConfig: turnStartConfigAtStart,
|
|
821
|
+
recoveryStatus,
|
|
822
|
+
restoration,
|
|
823
|
+
replayOnly,
|
|
824
|
+
lossyRerun,
|
|
825
|
+
}: {
|
|
826
|
+
sessionId: string;
|
|
827
|
+
harnessId: string;
|
|
828
|
+
channel: ACPChannel;
|
|
829
|
+
proc: Experimental_SandboxProcess | undefined;
|
|
830
|
+
modelId: string | undefined;
|
|
831
|
+
sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
|
|
832
|
+
debug: HarnessV1DebugConfig | undefined;
|
|
833
|
+
implementationIdentity: string;
|
|
834
|
+
authenticationProfile: ACPAuthenticationProfileIdentity;
|
|
835
|
+
builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
|
|
836
|
+
permissionMode: NonNullable<StartMessage['permissionMode']>;
|
|
837
|
+
permissionModeMapping: StartMessage['permissionModeMapping'];
|
|
838
|
+
initialGuidanceApplied: boolean;
|
|
839
|
+
skillCatalog: ReadonlyArray<ACPSkillCatalogEntry>;
|
|
840
|
+
skillsFingerprint: string;
|
|
841
|
+
acpSessionId: string | undefined;
|
|
842
|
+
bridgePort: number;
|
|
843
|
+
bridgeToken: string;
|
|
844
|
+
sandboxId: string | undefined;
|
|
845
|
+
isResume: boolean;
|
|
846
|
+
turnInFlight: boolean;
|
|
847
|
+
bridgeStateDir: string;
|
|
848
|
+
turnStartConfig: ACPTurnStartConfig | undefined;
|
|
849
|
+
recoveryStatus: ACPLifecycleData['recovery'];
|
|
850
|
+
restoration: ACPLifecycleData['restoration'];
|
|
851
|
+
replayOnly: boolean;
|
|
852
|
+
lossyRerun: boolean;
|
|
853
|
+
}): HarnessV1Session {
|
|
854
|
+
let stopped = false;
|
|
855
|
+
let turnInFlight = turnInFlightAtStart;
|
|
856
|
+
let initialGuidanceApplied = initialGuidanceAppliedAtStart;
|
|
857
|
+
let latestACPSessionId = acpSessionIdAtStart;
|
|
858
|
+
let latestTurnStartConfig = turnStartConfigAtStart;
|
|
859
|
+
|
|
860
|
+
const markTurnFinished = () => {
|
|
861
|
+
turnInFlight = false;
|
|
862
|
+
};
|
|
863
|
+
channel.on('bridge-thread', event => {
|
|
864
|
+
latestACPSessionId = event.threadId;
|
|
865
|
+
});
|
|
866
|
+
channel.on('finish', markTurnFinished);
|
|
867
|
+
channel.on('error', markTurnFinished);
|
|
868
|
+
channel.onClose(markTurnFinished);
|
|
869
|
+
|
|
870
|
+
const wireTurn = ({
|
|
871
|
+
emit,
|
|
872
|
+
abortSignal,
|
|
873
|
+
start,
|
|
874
|
+
}: {
|
|
875
|
+
emit: (event: HarnessV1StreamPart) => void;
|
|
876
|
+
abortSignal?: AbortSignal;
|
|
877
|
+
start: () => void;
|
|
878
|
+
}): HarnessV1PromptControl => {
|
|
879
|
+
let resolveDone: (() => void) | undefined;
|
|
880
|
+
let rejectDone: ((error: unknown) => void) | undefined;
|
|
881
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
882
|
+
resolveDone = resolve;
|
|
883
|
+
rejectDone = reject;
|
|
884
|
+
});
|
|
885
|
+
let settled = false;
|
|
886
|
+
let abortRequested = false;
|
|
887
|
+
let abortError: unknown;
|
|
888
|
+
let abortListenerAttached = false;
|
|
889
|
+
let openBlock:
|
|
890
|
+
| { readonly type: 'text' | 'reasoning'; readonly id: string }
|
|
891
|
+
| undefined;
|
|
892
|
+
const subscriptions: Array<() => void> = [];
|
|
893
|
+
const forward = (event: HarnessV1StreamPart) => {
|
|
894
|
+
if (event.type === 'text-start' || event.type === 'reasoning-start') {
|
|
895
|
+
openBlock = {
|
|
896
|
+
type: event.type === 'text-start' ? 'text' : 'reasoning',
|
|
897
|
+
id: event.id,
|
|
898
|
+
};
|
|
899
|
+
} else if (
|
|
900
|
+
(event.type === 'text-end' || event.type === 'reasoning-end') &&
|
|
901
|
+
openBlock?.id === event.id
|
|
902
|
+
) {
|
|
903
|
+
openBlock = undefined;
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
emit(event);
|
|
907
|
+
} catch {}
|
|
908
|
+
};
|
|
909
|
+
const closeForwardedBlock = () => {
|
|
910
|
+
if (openBlock == null) return;
|
|
911
|
+
const block = openBlock;
|
|
912
|
+
openBlock = undefined;
|
|
913
|
+
forward({
|
|
914
|
+
type: block.type === 'text' ? 'text-end' : 'reasoning-end',
|
|
915
|
+
id: block.id,
|
|
916
|
+
});
|
|
917
|
+
};
|
|
918
|
+
const eventTypes = [
|
|
919
|
+
'stream-start',
|
|
920
|
+
'text-start',
|
|
921
|
+
'text-delta',
|
|
922
|
+
'text-end',
|
|
923
|
+
'reasoning-start',
|
|
924
|
+
'reasoning-delta',
|
|
925
|
+
'reasoning-end',
|
|
926
|
+
'tool-call',
|
|
927
|
+
'tool-approval-request',
|
|
928
|
+
'tool-result',
|
|
929
|
+
'file-change',
|
|
930
|
+
'finish-step',
|
|
931
|
+
'raw',
|
|
932
|
+
] as const;
|
|
933
|
+
const cleanup = () => {
|
|
934
|
+
for (const unsubscribe of subscriptions) unsubscribe();
|
|
935
|
+
if (abortListenerAttached) {
|
|
936
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
const settle = ({ error }: { error?: unknown }) => {
|
|
940
|
+
if (settled) return;
|
|
941
|
+
settled = true;
|
|
942
|
+
cleanup();
|
|
943
|
+
if (error === undefined) resolveDone!();
|
|
944
|
+
else rejectDone!(error);
|
|
945
|
+
};
|
|
946
|
+
const onAbort = () => {
|
|
947
|
+
if (settled || abortRequested) return;
|
|
948
|
+
abortRequested = true;
|
|
949
|
+
abortError =
|
|
950
|
+
abortSignal?.reason ?? new DOMException('Aborted', 'AbortError');
|
|
951
|
+
try {
|
|
952
|
+
channel.send({ type: 'abort' });
|
|
953
|
+
} catch {
|
|
954
|
+
settle({ error: abortError });
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
for (const type of eventTypes) {
|
|
958
|
+
subscriptions.push(channel.on(type, event => forward(event)));
|
|
959
|
+
}
|
|
960
|
+
subscriptions.push(
|
|
961
|
+
channel.on('finish', event => {
|
|
962
|
+
closeForwardedBlock();
|
|
963
|
+
forward(event);
|
|
964
|
+
settle(abortRequested ? { error: abortError } : {});
|
|
965
|
+
}),
|
|
966
|
+
);
|
|
967
|
+
subscriptions.push(
|
|
968
|
+
channel.on('error', event => {
|
|
969
|
+
closeForwardedBlock();
|
|
970
|
+
const error = deserializeBridgeError({
|
|
971
|
+
error: event.error,
|
|
972
|
+
harnessId,
|
|
973
|
+
});
|
|
974
|
+
forward({ ...event, error });
|
|
975
|
+
settle({ error });
|
|
976
|
+
}),
|
|
977
|
+
);
|
|
978
|
+
channel.onClose((_code, reason) => {
|
|
979
|
+
if (reason === 'suspended') {
|
|
980
|
+
settle({});
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
closeForwardedBlock();
|
|
984
|
+
settle({
|
|
985
|
+
error:
|
|
986
|
+
abortRequested && abortError !== undefined
|
|
987
|
+
? abortError
|
|
988
|
+
: new Error(`${harnessId} ACP bridge closed before turn end.`),
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
try {
|
|
992
|
+
start();
|
|
993
|
+
} catch (error) {
|
|
994
|
+
settled = true;
|
|
995
|
+
cleanup();
|
|
996
|
+
throw error;
|
|
997
|
+
}
|
|
998
|
+
if (abortSignal?.aborted) onAbort();
|
|
999
|
+
else if (abortSignal != null) {
|
|
1000
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
1001
|
+
abortListenerAttached = true;
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
submitToolApproval: async input => {
|
|
1005
|
+
channel.send({
|
|
1006
|
+
type: 'tool-approval-response',
|
|
1007
|
+
approvalId: input.approvalId,
|
|
1008
|
+
approved: input.approved,
|
|
1009
|
+
reason: input.reason,
|
|
1010
|
+
});
|
|
1011
|
+
},
|
|
1012
|
+
submitToolResult: async input => {
|
|
1013
|
+
channel.send({
|
|
1014
|
+
type: 'tool-result',
|
|
1015
|
+
toolCallId: input.toolCallId,
|
|
1016
|
+
output: input.output,
|
|
1017
|
+
isError: input.isError,
|
|
1018
|
+
});
|
|
1019
|
+
},
|
|
1020
|
+
done,
|
|
1021
|
+
};
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const terminateBridge = async ({
|
|
1025
|
+
command,
|
|
1026
|
+
}: {
|
|
1027
|
+
command: 'stop' | 'destroy';
|
|
1028
|
+
}) => {
|
|
1029
|
+
if (stopped) return;
|
|
1030
|
+
stopped = true;
|
|
1031
|
+
channel.beginClose();
|
|
1032
|
+
const channelClosed = new Promise<void>(resolve => {
|
|
1033
|
+
channel.onClose(() => resolve());
|
|
1034
|
+
});
|
|
1035
|
+
try {
|
|
1036
|
+
if (!channel.isClosed()) {
|
|
1037
|
+
channel.send({ type: command });
|
|
1038
|
+
}
|
|
1039
|
+
} catch {}
|
|
1040
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1041
|
+
try {
|
|
1042
|
+
await Promise.race([
|
|
1043
|
+
proc == null ? channelClosed : proc.wait(),
|
|
1044
|
+
new Promise<void>(resolve => {
|
|
1045
|
+
timer = setTimeout(resolve, 5000);
|
|
1046
|
+
timer.unref?.();
|
|
1047
|
+
}),
|
|
1048
|
+
]);
|
|
1049
|
+
} finally {
|
|
1050
|
+
if (timer != null) clearTimeout(timer);
|
|
1051
|
+
try {
|
|
1052
|
+
await proc?.kill();
|
|
1053
|
+
} catch {}
|
|
1054
|
+
channel.close();
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
const createLifecycleData = ({
|
|
1059
|
+
bridge,
|
|
1060
|
+
includeTurnStartConfig = true,
|
|
1061
|
+
}: {
|
|
1062
|
+
bridge?: ACPLifecycleData['bridge'];
|
|
1063
|
+
includeTurnStartConfig?: boolean;
|
|
1064
|
+
}): ACPLifecycleData => ({
|
|
1065
|
+
implementationIdentity,
|
|
1066
|
+
authenticationProfile,
|
|
1067
|
+
...(latestACPSessionId == null ? {} : { acpSessionId: latestACPSessionId }),
|
|
1068
|
+
...(bridge == null ? {} : { bridge }),
|
|
1069
|
+
...(latestTurnStartConfig == null
|
|
1070
|
+
? {}
|
|
1071
|
+
: {
|
|
1072
|
+
coldSession: createACPColdSessionState({
|
|
1073
|
+
turnStartConfig: latestTurnStartConfig,
|
|
1074
|
+
modelId,
|
|
1075
|
+
}),
|
|
1076
|
+
}),
|
|
1077
|
+
...(!includeTurnStartConfig || latestTurnStartConfig == null
|
|
1078
|
+
? {}
|
|
1079
|
+
: { turnStartConfig: latestTurnStartConfig }),
|
|
1080
|
+
...(recoveryStatus == null ? {} : { recovery: recoveryStatus }),
|
|
1081
|
+
...(restoration == null ? {} : { restoration }),
|
|
1082
|
+
initialGuidanceApplied,
|
|
1083
|
+
skillsMaterialized: true,
|
|
1084
|
+
skillsFingerprint,
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
return {
|
|
1088
|
+
sessionId,
|
|
1089
|
+
isResume,
|
|
1090
|
+
...(modelId == null ? {} : { modelId }),
|
|
1091
|
+
doPromptTurn: async options => {
|
|
1092
|
+
if (replayOnly) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`${harnessId} recovered this turn through disk replay only and has no restored ACP process for a subsequent prompt.`,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
if (options.abortSignal?.aborted) {
|
|
1098
|
+
throw (
|
|
1099
|
+
options.abortSignal.reason ??
|
|
1100
|
+
new DOMException('Aborted', 'AbortError')
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
const prompt = convertHarnessPromptToACPTextBlocks({
|
|
1104
|
+
prompt: options.prompt,
|
|
1105
|
+
harnessId,
|
|
1106
|
+
});
|
|
1107
|
+
const turnStartConfig = createACPTurnStartConfig({
|
|
1108
|
+
prompt,
|
|
1109
|
+
tools: options.tools ?? [],
|
|
1110
|
+
builtinTools,
|
|
1111
|
+
permissionMode,
|
|
1112
|
+
permissionModeMapping,
|
|
1113
|
+
debug,
|
|
1114
|
+
authenticationProfile,
|
|
1115
|
+
sessionMeta,
|
|
1116
|
+
});
|
|
1117
|
+
const control = wireTurn({
|
|
1118
|
+
emit: options.emit,
|
|
1119
|
+
abortSignal: options.abortSignal,
|
|
1120
|
+
start: () => {
|
|
1121
|
+
if (turnInFlight) {
|
|
1122
|
+
throw new Error(
|
|
1123
|
+
`${harnessId} cannot start a new ACP prompt while a turn is in flight.`,
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
turnInFlight = true;
|
|
1127
|
+
channel.send({
|
|
1128
|
+
type: 'start',
|
|
1129
|
+
prompt: initialGuidanceApplied
|
|
1130
|
+
? prompt
|
|
1131
|
+
: prependACPInitialGuidance({
|
|
1132
|
+
prompt,
|
|
1133
|
+
instructions: options.instructions,
|
|
1134
|
+
skills: skillCatalog,
|
|
1135
|
+
}),
|
|
1136
|
+
debug,
|
|
1137
|
+
builtinTools,
|
|
1138
|
+
permissionMode,
|
|
1139
|
+
permissionModeMapping,
|
|
1140
|
+
tools: options.tools == null ? undefined : turnStartConfig.tools,
|
|
1141
|
+
turnStartConfig,
|
|
1142
|
+
});
|
|
1143
|
+
latestTurnStartConfig = turnStartConfig;
|
|
1144
|
+
initialGuidanceApplied = true;
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1147
|
+
return control;
|
|
1148
|
+
},
|
|
1149
|
+
doContinueTurn: async options => {
|
|
1150
|
+
if (!turnInFlight) {
|
|
1151
|
+
throw new Error(`${harnessId} has no in-flight ACP turn to continue.`);
|
|
1152
|
+
}
|
|
1153
|
+
if (lossyRerun) {
|
|
1154
|
+
if (latestTurnStartConfig == null || latestACPSessionId == null) {
|
|
1155
|
+
throw new Error(
|
|
1156
|
+
`${harnessId} cannot perform lossy ACP rerun without persisted start configuration and an ACP session identifier.`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
assertRecoveryToolCatalog({
|
|
1160
|
+
persisted: latestTurnStartConfig.tools,
|
|
1161
|
+
current: options.tools ?? [],
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
return wireTurn({
|
|
1165
|
+
emit: options.emit,
|
|
1166
|
+
abortSignal: options.abortSignal,
|
|
1167
|
+
start: () => {
|
|
1168
|
+
if (!lossyRerun) return;
|
|
1169
|
+
const turnStartConfig = latestTurnStartConfig!;
|
|
1170
|
+
channel.send({
|
|
1171
|
+
type: 'start',
|
|
1172
|
+
prompt: turnStartConfig.prompt,
|
|
1173
|
+
debug: turnStartConfig.debug,
|
|
1174
|
+
builtinTools: turnStartConfig.builtinTools,
|
|
1175
|
+
permissionMode: turnStartConfig.permissionMode,
|
|
1176
|
+
permissionModeMapping: turnStartConfig.permissionModeMapping,
|
|
1177
|
+
tools: turnStartConfig.tools,
|
|
1178
|
+
turnStartConfig,
|
|
1179
|
+
recoveryMode: {
|
|
1180
|
+
type: 'lossy-rerun',
|
|
1181
|
+
acpSessionId: latestACPSessionId!,
|
|
1182
|
+
reason: recoveryStatus?.reason ?? 'bridge process loss',
|
|
1183
|
+
},
|
|
1184
|
+
});
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
},
|
|
1188
|
+
doCompact: async () => {
|
|
1189
|
+
throw unsupported({
|
|
1190
|
+
harnessId,
|
|
1191
|
+
message: 'ACP v1 does not define manual session compaction.',
|
|
1192
|
+
});
|
|
1193
|
+
},
|
|
1194
|
+
doSuspendTurn: async () => {
|
|
1195
|
+
if (stopped) {
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
`${harnessId} ACP session ${sessionId} is stopped; cannot suspend.`,
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
if (!turnInFlight) {
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
`${harnessId} ACP session ${sessionId} has no in-flight turn to suspend.`,
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
stopped = true;
|
|
1206
|
+
const lastSeenEventId = await channel.suspend();
|
|
1207
|
+
return {
|
|
1208
|
+
type: 'continue-turn',
|
|
1209
|
+
harnessId,
|
|
1210
|
+
specificationVersion: 'harness-v1',
|
|
1211
|
+
data: createLifecycleData({
|
|
1212
|
+
bridge: {
|
|
1213
|
+
port: bridgePort,
|
|
1214
|
+
token: bridgeToken,
|
|
1215
|
+
lastSeenEventId,
|
|
1216
|
+
...(sandboxId == null ? {} : { sandboxId }),
|
|
1217
|
+
stateDir: bridgeStateDir,
|
|
1218
|
+
},
|
|
1219
|
+
}),
|
|
1220
|
+
};
|
|
1221
|
+
},
|
|
1222
|
+
doDetach: async () => {
|
|
1223
|
+
if (stopped) {
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
`${harnessId} ACP session ${sessionId} is stopped; cannot detach.`,
|
|
1226
|
+
);
|
|
1227
|
+
}
|
|
1228
|
+
if (turnInFlight) {
|
|
1229
|
+
throw new Error(
|
|
1230
|
+
`${harnessId} ACP session ${sessionId} has an in-flight turn; suspend it instead.`,
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
stopped = true;
|
|
1234
|
+
const lastSeenEventId = await channel.suspend();
|
|
1235
|
+
return {
|
|
1236
|
+
type: 'resume-session',
|
|
1237
|
+
harnessId,
|
|
1238
|
+
specificationVersion: 'harness-v1',
|
|
1239
|
+
data: createLifecycleData({
|
|
1240
|
+
bridge: {
|
|
1241
|
+
port: bridgePort,
|
|
1242
|
+
token: bridgeToken,
|
|
1243
|
+
lastSeenEventId,
|
|
1244
|
+
...(sandboxId == null ? {} : { sandboxId }),
|
|
1245
|
+
stateDir: bridgeStateDir,
|
|
1246
|
+
},
|
|
1247
|
+
}),
|
|
1248
|
+
};
|
|
1249
|
+
},
|
|
1250
|
+
doStop: async () => {
|
|
1251
|
+
const data = createLifecycleData({ includeTurnStartConfig: false });
|
|
1252
|
+
await terminateBridge({ command: 'stop' });
|
|
1253
|
+
return {
|
|
1254
|
+
type: 'resume-session',
|
|
1255
|
+
harnessId,
|
|
1256
|
+
specificationVersion: 'harness-v1',
|
|
1257
|
+
data,
|
|
1258
|
+
};
|
|
1259
|
+
},
|
|
1260
|
+
doDestroy: async () => {
|
|
1261
|
+
await terminateBridge({ command: 'destroy' });
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function isCompletePermissionModeMapping({
|
|
1267
|
+
value,
|
|
1268
|
+
}: {
|
|
1269
|
+
value: ACPPermissionModeMapping | undefined;
|
|
1270
|
+
}): boolean {
|
|
1271
|
+
return (
|
|
1272
|
+
isPermissionModeMappingValue({ value: value?.['allow-reads'] }) &&
|
|
1273
|
+
isPermissionModeMappingValue({ value: value?.['allow-edits'] }) &&
|
|
1274
|
+
isPermissionModeMappingValue({ value: value?.['allow-all'] })
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function isPermissionModeMappingValue({
|
|
1279
|
+
value,
|
|
1280
|
+
}: {
|
|
1281
|
+
value: ACPPermissionModeTarget | null | undefined;
|
|
1282
|
+
}): boolean {
|
|
1283
|
+
return value === null || isPermissionModeTarget({ value });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function isPermissionModeTarget({
|
|
1287
|
+
value,
|
|
1288
|
+
}: {
|
|
1289
|
+
value: ACPPermissionModeTarget | undefined;
|
|
1290
|
+
}): boolean {
|
|
1291
|
+
if (value?.type === 'session-mode') {
|
|
1292
|
+
return typeof value.modeId === 'string';
|
|
1293
|
+
}
|
|
1294
|
+
return (
|
|
1295
|
+
value?.type === 'session-config-option' &&
|
|
1296
|
+
typeof value.configId === 'string' &&
|
|
1297
|
+
(typeof value.value === 'string' || typeof value.value === 'boolean')
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
export function serializeBuiltinTools({
|
|
1302
|
+
builtinTools,
|
|
1303
|
+
}: {
|
|
1304
|
+
builtinTools: ToolSet;
|
|
1305
|
+
}): ReadonlyArray<ACPBuiltinToolMapping> {
|
|
1306
|
+
return Object.entries(builtinTools).map(([toolName, tool]) => {
|
|
1307
|
+
const nativeName =
|
|
1308
|
+
tool != null &&
|
|
1309
|
+
typeof tool === 'object' &&
|
|
1310
|
+
'nativeName' in tool &&
|
|
1311
|
+
typeof tool.nativeName === 'string'
|
|
1312
|
+
? tool.nativeName
|
|
1313
|
+
: undefined;
|
|
1314
|
+
let inputSchema: ACPBuiltinToolMapping['inputSchema'];
|
|
1315
|
+
if (
|
|
1316
|
+
tool != null &&
|
|
1317
|
+
typeof tool === 'object' &&
|
|
1318
|
+
'inputSchema' in tool &&
|
|
1319
|
+
tool.inputSchema != null
|
|
1320
|
+
) {
|
|
1321
|
+
try {
|
|
1322
|
+
inputSchema = asSchema(
|
|
1323
|
+
tool.inputSchema as Parameters<typeof asSchema>[0],
|
|
1324
|
+
).jsonSchema as ACPBuiltinToolMapping['inputSchema'];
|
|
1325
|
+
} catch {}
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
toolName,
|
|
1329
|
+
...(nativeName == null ? {} : { nativeName }),
|
|
1330
|
+
...(inputSchema == null ? {} : { inputSchema }),
|
|
1331
|
+
};
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function validateACPTurnStartConfig({
|
|
1336
|
+
turnStartConfig,
|
|
1337
|
+
authenticationProfile,
|
|
1338
|
+
sessionMeta,
|
|
1339
|
+
builtinTools,
|
|
1340
|
+
permissionModeMapping,
|
|
1341
|
+
}: {
|
|
1342
|
+
turnStartConfig: ACPTurnStartConfig;
|
|
1343
|
+
authenticationProfile: ACPAuthenticationProfileIdentity;
|
|
1344
|
+
sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
|
|
1345
|
+
builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
|
|
1346
|
+
permissionModeMapping: ACPPermissionModeMapping | undefined;
|
|
1347
|
+
}): void {
|
|
1348
|
+
const current = createACPTurnStartConfig({
|
|
1349
|
+
prompt: turnStartConfig.prompt,
|
|
1350
|
+
tools: turnStartConfig.tools,
|
|
1351
|
+
builtinTools,
|
|
1352
|
+
permissionMode: turnStartConfig.permissionMode,
|
|
1353
|
+
permissionModeMapping,
|
|
1354
|
+
debug: turnStartConfig.debug,
|
|
1355
|
+
authenticationProfile,
|
|
1356
|
+
sessionMeta,
|
|
1357
|
+
});
|
|
1358
|
+
if (
|
|
1359
|
+
current.configurationFingerprint !==
|
|
1360
|
+
turnStartConfig.configurationFingerprint
|
|
1361
|
+
) {
|
|
1362
|
+
throw new Error(
|
|
1363
|
+
'The persisted ACP turn start configuration is incompatible with the current non-secret start configuration.',
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function validateACPColdSessionConfiguration({
|
|
1369
|
+
coldSession,
|
|
1370
|
+
modelId,
|
|
1371
|
+
permissionMode,
|
|
1372
|
+
authenticationProfile,
|
|
1373
|
+
sessionMeta,
|
|
1374
|
+
builtinTools,
|
|
1375
|
+
permissionModeMapping,
|
|
1376
|
+
debug,
|
|
1377
|
+
}: {
|
|
1378
|
+
coldSession: ACPColdSessionState;
|
|
1379
|
+
modelId: string | undefined;
|
|
1380
|
+
permissionMode: NonNullable<StartMessage['permissionMode']>;
|
|
1381
|
+
authenticationProfile: ACPAuthenticationProfileIdentity;
|
|
1382
|
+
sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
|
|
1383
|
+
builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
|
|
1384
|
+
permissionModeMapping: ACPPermissionModeMapping | undefined;
|
|
1385
|
+
debug: HarnessV1DebugConfig | undefined;
|
|
1386
|
+
}): ACPTurnStartConfig {
|
|
1387
|
+
const current = createACPTurnStartConfig({
|
|
1388
|
+
prompt: [],
|
|
1389
|
+
tools: coldSession.tools,
|
|
1390
|
+
builtinTools,
|
|
1391
|
+
permissionMode,
|
|
1392
|
+
permissionModeMapping,
|
|
1393
|
+
debug,
|
|
1394
|
+
authenticationProfile,
|
|
1395
|
+
sessionMeta,
|
|
1396
|
+
});
|
|
1397
|
+
if (
|
|
1398
|
+
current.configurationFingerprint !== coldSession.configurationFingerprint ||
|
|
1399
|
+
coldSession.permissionMode !== permissionMode ||
|
|
1400
|
+
coldSession.modelId !== modelId
|
|
1401
|
+
) {
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
'ACP cold-session state is incompatible with the current non-secret session configuration.',
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
return current;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function assertRecoveryToolCatalog({
|
|
1410
|
+
persisted,
|
|
1411
|
+
current,
|
|
1412
|
+
}: {
|
|
1413
|
+
persisted: ReadonlyArray<HarnessV1ToolSpec>;
|
|
1414
|
+
current: ReadonlyArray<HarnessV1ToolSpec>;
|
|
1415
|
+
}): void {
|
|
1416
|
+
if (
|
|
1417
|
+
fingerprintValue({ value: persisted }) !==
|
|
1418
|
+
fingerprintValue({ value: current })
|
|
1419
|
+
) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
'ACP lossy rerun requires the same active host tool catalog as the original turn.',
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function fingerprintValue({ value }: { value: unknown }): string {
|
|
1427
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1431
|
+
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function unsupported({
|
|
1435
|
+
harnessId,
|
|
1436
|
+
message,
|
|
1437
|
+
cause,
|
|
1438
|
+
}: {
|
|
1439
|
+
harnessId: string;
|
|
1440
|
+
message: string;
|
|
1441
|
+
cause?: unknown;
|
|
1442
|
+
}): HarnessCapabilityUnsupportedError {
|
|
1443
|
+
return new HarnessCapabilityUnsupportedError({
|
|
1444
|
+
harnessId,
|
|
1445
|
+
message,
|
|
1446
|
+
cause,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function deserializeBridgeError({
|
|
1451
|
+
error,
|
|
1452
|
+
harnessId,
|
|
1453
|
+
}: {
|
|
1454
|
+
error: unknown;
|
|
1455
|
+
harnessId: string;
|
|
1456
|
+
}): unknown {
|
|
1457
|
+
if (HarnessBridgeCapabilityUnsupportedError.isInstance(error)) {
|
|
1458
|
+
return unsupported({ harnessId, message: error.message });
|
|
1459
|
+
}
|
|
1460
|
+
return error;
|
|
1461
|
+
}
|