@tangle-network/sdk-provider-cli-base 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +11 -0
- package/dist/conformance/index.d.mts +79 -0
- package/dist/conformance/index.mjs +192 -0
- package/dist/index.d.mts +1616 -0
- package/dist/index.mjs +4394 -0
- package/dist/prompt-input-BHn_1z69.d.mts +73 -0
- package/package.json +52 -3
- package/README.md +0 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,4394 @@
|
|
|
1
|
+
import { DEFAULT_INTERACTION_DECISION_TIMEOUT_MS as PERMISSION_DECISION_TIMEOUT_MS, InteractionBroker, InteractionBroker as InteractionBroker$1, InteractionHttpBridge, brokerInteractionTools, brokerInteractionTools as brokerInteractionTools$1 } from "@tangle-network/agent-core/interactions";
|
|
2
|
+
import { BridgeClosedError, InvalidAttachmentError, JsonRpcError, RpcRequestTimeoutError, execCli, launchCliProcess, resolveRawCapturePath, spawnCliProcess, spawnRpcBridge } from "@tangle-network/sdk-cli-runner";
|
|
3
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
4
|
+
import { AgentExecutionError, AgentExecutionOutcomeSchema, AgentRuntimeAttachmentsSchema, InteractionExecutionBindingSchema, InteractionKind, REASONING_EFFORTS, agentProfileSchema, nativeReasoningControl, normalizeInputParts } from "@tangle-network/agent-interface";
|
|
5
|
+
import { UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER, applyWorkspacePlanWithSecretProvider, bindLaunchToolBinDirs, enforceWorkspacePlanSupport, ensurePublicConcurrentProfileRoots, joinSystemPromptAdditions, keepsHarnessSystemPrompt, materializeProfile, modelIdsMatch, parseSandboxToolBinDirs, preparePublicConcurrentProfileRevision, prepareSecretConcurrentProfileRevision, resolveAgentProfileResources } from "@tangle-network/agent-profile-materialize";
|
|
6
|
+
import { HarnessSessionContractError, assertArgvHonoursSessionContract, assertFlagsHonourSessionContract, assertStoreLocationHonoursSessionContract, compileSessionStoreGlob } from "@tangle-network/cli-agent-registry/session-contract";
|
|
7
|
+
import { MAX_SECRET_PROFILE_REPLAY_CHARS, RESTORED_SESSION_CONTEXT_PREAMBLE, RESTORED_SESSION_CONTEXT_TAG, buildCompleteForkContextBlock, buildForkContextBlock, createMessageStore, createSessionStore, forkSessionWithMessages, readProviderMessageIds } from "@tangle-network/sdk-session-persistence";
|
|
8
|
+
import { mkdir, open, readdir, writeFile } from "node:fs/promises";
|
|
9
|
+
import path, { basename, isAbsolute, normalize, resolve } from "node:path";
|
|
10
|
+
import fs, { constants, lstatSync, readFileSync } from "node:fs";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { isDeepStrictEqual, promisify } from "node:util";
|
|
13
|
+
import { messageStorageDirOf } from "@tangle-network/cli-agent-registry";
|
|
14
|
+
import { HARNESS_CONFORMANCE_PENDING, NATIVE_SESSION_CONTRACTS, configDirHomeEnv } from "@tangle-network/cli-agent-registry/native-session-contracts";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
|
+
import { DEFAULT_INTERACTION_MCP_SERVER_NAME, InteractionMcpServer as InteractionMcpServer$1, interactionMcpToolName } from "@tangle-network/agent-core/interactions/mcp";
|
|
19
|
+
//#region src/acp-process-launch.ts
|
|
20
|
+
/** Spawn an ACP agent over NDJSON stdio. The adapter owns abort, so no signal. */
|
|
21
|
+
function spawnAcpBridge(request) {
|
|
22
|
+
return spawnRpcBridge({
|
|
23
|
+
...request,
|
|
24
|
+
maxStderrBytes: 65536
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A JSON-RPC error with the cause the agent put in `data`: hermes sends
|
|
29
|
+
* `data.details`, codex-acp `data.message`.
|
|
30
|
+
*/
|
|
31
|
+
function jsonRpcErrorText(err) {
|
|
32
|
+
const data = err.data && typeof err.data === "object" ? err.data : void 0;
|
|
33
|
+
const cause = [data?.details, data?.message].find((value) => typeof value === "string" && value.length > 0);
|
|
34
|
+
return cause && cause !== err.message ? `${err.message}: ${cause}` : err.message;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Overlay the agent environment on an ACP process env, dropping unset keys. An
|
|
38
|
+
* absent env means the inherited one, which the overlay then starts from.
|
|
39
|
+
*/
|
|
40
|
+
function withAgentEnvironment(env, agent) {
|
|
41
|
+
if (Object.keys(agent).length === 0) return env;
|
|
42
|
+
const merged = {};
|
|
43
|
+
for (const [key, value] of Object.entries(env ?? process.env)) if (value !== void 0) merged[key] = value;
|
|
44
|
+
for (const [key, value] of Object.entries(agent)) if (value !== void 0) merged[key] = value;
|
|
45
|
+
return merged;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/acp-protocol.ts
|
|
49
|
+
/** The ACP protocol version this client speaks, and the oldest it accepts. */
|
|
50
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
51
|
+
const ACP_MIN_PROTOCOL_VERSION = 1;
|
|
52
|
+
/** Text carried by ACP content blocks, including tool-call content. */
|
|
53
|
+
function acpContentText(content) {
|
|
54
|
+
if (!content) return "";
|
|
55
|
+
const blocks = Array.isArray(content) ? content : [content];
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const block of blocks) {
|
|
58
|
+
if (!block || typeof block !== "object") continue;
|
|
59
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
60
|
+
else if (block.type === "content" && block.content?.type === "text" && typeof block.content.text === "string") parts.push(block.content.text);
|
|
61
|
+
}
|
|
62
|
+
return parts.join("");
|
|
63
|
+
}
|
|
64
|
+
/** Map an ACP stop reason onto a turn status. */
|
|
65
|
+
function mapAcpStopReason(reason) {
|
|
66
|
+
switch (reason) {
|
|
67
|
+
case "end_turn": return { status: "completed" };
|
|
68
|
+
case "max_tokens": return {
|
|
69
|
+
status: "completed",
|
|
70
|
+
detail: "Stopped at max tokens (output may be truncated)"
|
|
71
|
+
};
|
|
72
|
+
case "max_turn_requests": return {
|
|
73
|
+
status: "failed",
|
|
74
|
+
detail: "Hit max turn-requests limit"
|
|
75
|
+
};
|
|
76
|
+
case "refusal": return {
|
|
77
|
+
status: "failed",
|
|
78
|
+
detail: "Agent refused the request"
|
|
79
|
+
};
|
|
80
|
+
case "cancelled": return {
|
|
81
|
+
status: "failed",
|
|
82
|
+
detail: "Cancelled"
|
|
83
|
+
};
|
|
84
|
+
default: return {
|
|
85
|
+
status: "failed",
|
|
86
|
+
detail: `Unknown stop reason: ${reason ?? "<missing>"}`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/reasoning-parts.ts
|
|
92
|
+
/**
|
|
93
|
+
* Assigns a stable part id and an append-only delta to a reasoning block
|
|
94
|
+
* emission, absorbing provider replay/growth patterns:
|
|
95
|
+
*
|
|
96
|
+
* - text exactly matches an already-tracked block → `null` (duplicate
|
|
97
|
+
* delivery, e.g. a final assistant message replaying streamed content —
|
|
98
|
+
* emit nothing). Byte-identical distinct blocks therefore dedupe to one
|
|
99
|
+
* part; safer than duplicate-part spam.
|
|
100
|
+
* - text extends the most recent block → same part id, delta = the suffix
|
|
101
|
+
* (cumulative re-emission of a growing block).
|
|
102
|
+
* - otherwise → a new block: fresh part id, delta = the full text, so
|
|
103
|
+
* consumers can be pure appenders (prev text for a fresh id is empty).
|
|
104
|
+
*/
|
|
105
|
+
function trackReasoningBlock(state, blockText) {
|
|
106
|
+
if (blockText === "") return null;
|
|
107
|
+
state.reasoningParts ??= [];
|
|
108
|
+
const parts = state.reasoningParts;
|
|
109
|
+
if (parts.some((p) => p.text === blockText)) return null;
|
|
110
|
+
const last = parts[parts.length - 1];
|
|
111
|
+
if (last && last.text !== "" && blockText.startsWith(last.text)) {
|
|
112
|
+
const delta = blockText.slice(last.text.length);
|
|
113
|
+
last.text = blockText;
|
|
114
|
+
return {
|
|
115
|
+
partId: last.id,
|
|
116
|
+
text: blockText,
|
|
117
|
+
delta,
|
|
118
|
+
isNewBlock: false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const id = randomUUID();
|
|
122
|
+
parts.push({
|
|
123
|
+
id,
|
|
124
|
+
text: blockText
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
partId: id,
|
|
128
|
+
text: blockText,
|
|
129
|
+
delta: blockText,
|
|
130
|
+
isNewBlock: true
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/stream-parts.ts
|
|
135
|
+
/**
|
|
136
|
+
* The text, reasoning and tool part emissions every stream-json harness
|
|
137
|
+
* shares. A provider's event mapper decodes its own wire shape, then calls
|
|
138
|
+
* these so part ids, buffers, tool timing and processing phases stay one
|
|
139
|
+
* implementation across claude-code, codex, kimi, amp and factory-droids.
|
|
140
|
+
*/
|
|
141
|
+
/**
|
|
142
|
+
* Records the CLI's native session id on the turn and announces it: the
|
|
143
|
+
* generic `session.updated` plus the provider-attributed observation the
|
|
144
|
+
* turn observer keys evidence on.
|
|
145
|
+
*/
|
|
146
|
+
function sessionObservedEvents(state, provider, sessionId) {
|
|
147
|
+
state.sessionId = sessionId;
|
|
148
|
+
return [{
|
|
149
|
+
type: "session.updated",
|
|
150
|
+
sessionId
|
|
151
|
+
}, {
|
|
152
|
+
type: "native.session.observed",
|
|
153
|
+
provider,
|
|
154
|
+
nativeSessionId: sessionId
|
|
155
|
+
}];
|
|
156
|
+
}
|
|
157
|
+
/** Correlation fields every emitted part carries. */
|
|
158
|
+
function streamPartBase(state, partId) {
|
|
159
|
+
return {
|
|
160
|
+
id: partId ?? randomUUID(),
|
|
161
|
+
sessionID: state.sessionId ?? "",
|
|
162
|
+
messageID: state.messageId ?? ""
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function textPartEvent(state, delta) {
|
|
166
|
+
state.textPartId ??= randomUUID();
|
|
167
|
+
return {
|
|
168
|
+
type: "message.part.updated",
|
|
169
|
+
part: {
|
|
170
|
+
...streamPartBase(state, state.textPartId),
|
|
171
|
+
type: "text",
|
|
172
|
+
text: state.textBuffer
|
|
173
|
+
},
|
|
174
|
+
delta
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Appends a text delta to the turn's single text part. The first text of a
|
|
179
|
+
* turn also signals the `generating` phase.
|
|
180
|
+
*/
|
|
181
|
+
function textDeltaEvents(state, delta) {
|
|
182
|
+
const first = state.textBuffer.length === 0;
|
|
183
|
+
state.textBuffer += delta;
|
|
184
|
+
const update = textPartEvent(state, delta);
|
|
185
|
+
return first ? [{
|
|
186
|
+
type: "model-processing",
|
|
187
|
+
phase: "generating"
|
|
188
|
+
}, update] : [update];
|
|
189
|
+
}
|
|
190
|
+
/** The whole reply as one text part, for harnesses that do not stream. */
|
|
191
|
+
function finalTextEvent(state, text) {
|
|
192
|
+
state.textBuffer = text;
|
|
193
|
+
return textPartEvent(state, text);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Applies a cumulative text frame (the whole assistant text so far). Only the
|
|
197
|
+
* suffix past the buffer is emitted; a frame that does not extend the buffer
|
|
198
|
+
* replaces it and is emitted whole.
|
|
199
|
+
*/
|
|
200
|
+
function textSnapshotEvents(state, text) {
|
|
201
|
+
const delta = text.startsWith(state.textBuffer) ? text.slice(state.textBuffer.length) : text;
|
|
202
|
+
if (!delta) return [];
|
|
203
|
+
state.textBuffer = text;
|
|
204
|
+
return [textPartEvent(state, delta)];
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Records a thinking block under a stable part id (see trackReasoningBlock).
|
|
208
|
+
* With `signalPhase`, the turn's first block also signals `thinking`.
|
|
209
|
+
*/
|
|
210
|
+
function reasoningEvents(state, thinking, options) {
|
|
211
|
+
const tracked = trackReasoningBlock(state, thinking);
|
|
212
|
+
if (!tracked) return [];
|
|
213
|
+
if (tracked.isNewBlock) state.reasoningBuffer.push(tracked.text);
|
|
214
|
+
else state.reasoningBuffer[state.reasoningBuffer.length - 1] = tracked.text;
|
|
215
|
+
const update = {
|
|
216
|
+
type: "message.part.updated",
|
|
217
|
+
part: {
|
|
218
|
+
...streamPartBase(state, tracked.partId),
|
|
219
|
+
type: "reasoning",
|
|
220
|
+
text: tracked.text
|
|
221
|
+
},
|
|
222
|
+
delta: tracked.delta
|
|
223
|
+
};
|
|
224
|
+
return options.signalPhase && tracked.isNewBlock && state.reasoningBuffer.length === 1 ? [{
|
|
225
|
+
type: "model-processing",
|
|
226
|
+
phase: "thinking"
|
|
227
|
+
}, update] : [update];
|
|
228
|
+
}
|
|
229
|
+
/** Registers a running tool call and emits its running part. */
|
|
230
|
+
function toolStartEvents(state, call) {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
state.toolCalls.set(call.id, {
|
|
233
|
+
id: call.id,
|
|
234
|
+
name: call.name,
|
|
235
|
+
input: call.input,
|
|
236
|
+
startTime: now,
|
|
237
|
+
status: "running"
|
|
238
|
+
});
|
|
239
|
+
const toolState = {
|
|
240
|
+
status: "running",
|
|
241
|
+
input: call.input ?? {},
|
|
242
|
+
time: { start: now }
|
|
243
|
+
};
|
|
244
|
+
return [{
|
|
245
|
+
type: "message.part.updated",
|
|
246
|
+
part: {
|
|
247
|
+
...streamPartBase(state, call.id),
|
|
248
|
+
type: "tool",
|
|
249
|
+
callID: call.id,
|
|
250
|
+
tool: call.name,
|
|
251
|
+
state: toolState
|
|
252
|
+
}
|
|
253
|
+
}];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Completes a tracked tool call. A successful result emits `afterSuccess`
|
|
257
|
+
* (file parts the provider detected) and then the `tool-result` phase. A
|
|
258
|
+
* result for an untracked call is a warning.
|
|
259
|
+
*/
|
|
260
|
+
function toolResultEvents(state, result) {
|
|
261
|
+
const toolCall = state.toolCalls.get(result.id);
|
|
262
|
+
if (!toolCall) return [{
|
|
263
|
+
type: "warning",
|
|
264
|
+
code: "UNKNOWN_TOOL_RESULT",
|
|
265
|
+
message: `Received result for unknown tool call: ${result.id}`
|
|
266
|
+
}];
|
|
267
|
+
toolCall.status = result.isError ? "failed" : "completed";
|
|
268
|
+
toolCall.output = result.content;
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const input = toolCall.input ?? {};
|
|
271
|
+
const time = {
|
|
272
|
+
start: toolCall.startTime,
|
|
273
|
+
end: now
|
|
274
|
+
};
|
|
275
|
+
const metadata = result.metadata ? { metadata: result.metadata } : {};
|
|
276
|
+
const toolState = result.isError ? {
|
|
277
|
+
status: "failed",
|
|
278
|
+
input,
|
|
279
|
+
error: typeof result.content === "string" ? result.content : void 0,
|
|
280
|
+
output: result.content,
|
|
281
|
+
...metadata,
|
|
282
|
+
time
|
|
283
|
+
} : {
|
|
284
|
+
status: "completed",
|
|
285
|
+
input,
|
|
286
|
+
output: result.content,
|
|
287
|
+
...metadata,
|
|
288
|
+
time
|
|
289
|
+
};
|
|
290
|
+
const events = [{
|
|
291
|
+
type: "message.part.updated",
|
|
292
|
+
part: {
|
|
293
|
+
...streamPartBase(state, result.id),
|
|
294
|
+
type: "tool",
|
|
295
|
+
callID: result.id,
|
|
296
|
+
tool: toolCall.name,
|
|
297
|
+
state: toolState
|
|
298
|
+
}
|
|
299
|
+
}];
|
|
300
|
+
if (!result.isError) events.push(...result.afterSuccess?.(toolCall) ?? [], {
|
|
301
|
+
type: "model-processing",
|
|
302
|
+
phase: "tool-result",
|
|
303
|
+
toolName: toolCall.name,
|
|
304
|
+
elapsedMs: now - toolCall.startTime
|
|
305
|
+
});
|
|
306
|
+
return events;
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/acp-session-updates.ts
|
|
310
|
+
/** An agent message or thought chunk: text extends the message part, thought joins reasoning. */
|
|
311
|
+
function acpMessageChunkEvents(kind, update, state) {
|
|
312
|
+
const text = acpContentText(update.content);
|
|
313
|
+
if (!text) return [];
|
|
314
|
+
if (kind === "agent_message_chunk") return textDeltaEvents(state, text);
|
|
315
|
+
state.reasoningBuffer.push(text);
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
/** A new tool call: recorded as running and reported as its tool part. */
|
|
319
|
+
function acpToolCallEvents(update, state) {
|
|
320
|
+
const call = update;
|
|
321
|
+
const name = call.title ?? call.kind ?? "tool";
|
|
322
|
+
return toolStartEvents(state, {
|
|
323
|
+
id: call.toolCallId,
|
|
324
|
+
name,
|
|
325
|
+
input: call.rawInput
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Fold one tool_call_update into the call's record. Tool output arrives as
|
|
330
|
+
* content blocks; rawOutput is the fallback. Returns the update's text output.
|
|
331
|
+
*/
|
|
332
|
+
function applyAcpToolCallUpdate(existing, mapped, change) {
|
|
333
|
+
const outputText = acpContentText(change.content);
|
|
334
|
+
if (mapped === "running" && outputText) existing.output = typeof existing.output === "string" ? existing.output + outputText : outputText;
|
|
335
|
+
existing.status = mapped;
|
|
336
|
+
if (mapped === "completed" || mapped === "failed") {
|
|
337
|
+
if (outputText) existing.output = typeof existing.output === "string" && existing.output.length > 0 ? existing.output + outputText : outputText;
|
|
338
|
+
else if (change.rawOutput !== void 0) existing.output = change.rawOutput;
|
|
339
|
+
}
|
|
340
|
+
return outputText;
|
|
341
|
+
}
|
|
342
|
+
/** The events a call's current record reports: its settled result, or its open part. */
|
|
343
|
+
function acpToolCallUpdateEvents(existing, state) {
|
|
344
|
+
if (existing.status === "completed" || existing.status === "failed") return toolResultEvents(state, {
|
|
345
|
+
id: existing.id,
|
|
346
|
+
content: existing.output,
|
|
347
|
+
isError: existing.status === "failed"
|
|
348
|
+
});
|
|
349
|
+
const input = existing.input ?? {};
|
|
350
|
+
return [{
|
|
351
|
+
type: "message.part.updated",
|
|
352
|
+
part: {
|
|
353
|
+
...streamPartBase(state, existing.id),
|
|
354
|
+
type: "tool",
|
|
355
|
+
tool: existing.name,
|
|
356
|
+
callID: existing.id,
|
|
357
|
+
state: existing.status === "running" ? {
|
|
358
|
+
status: "running",
|
|
359
|
+
input,
|
|
360
|
+
time: { start: existing.startTime }
|
|
361
|
+
} : {
|
|
362
|
+
status: "pending",
|
|
363
|
+
input
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}];
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Plan entries are the agent's step list. Fold their text into the
|
|
370
|
+
* reasoning record, bounded per notification and per turn so a
|
|
371
|
+
* misbehaving agent cannot grow turn memory or storage without limit.
|
|
372
|
+
*/
|
|
373
|
+
function acpPlanEvents(update, state, provider) {
|
|
374
|
+
const MAX_ENTRIES_PER_UPDATE = 256;
|
|
375
|
+
const MAX_REASONING_ENTRIES_PER_TURN = 1024;
|
|
376
|
+
const MAX_ENTRY_LENGTH = 8 * 1024;
|
|
377
|
+
const raw = update.entries;
|
|
378
|
+
const entries = (Array.isArray(raw) ? raw : []).slice(0, MAX_ENTRIES_PER_UPDATE);
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
if (state.reasoningBuffer.length >= MAX_REASONING_ENTRIES_PER_TURN) break;
|
|
381
|
+
if (typeof entry.content === "string" && entry.content.length > 0) state.reasoningBuffer.push(entry.content.slice(0, MAX_ENTRY_LENGTH));
|
|
382
|
+
}
|
|
383
|
+
return [{
|
|
384
|
+
type: "raw",
|
|
385
|
+
backend: provider,
|
|
386
|
+
event: {
|
|
387
|
+
sessionUpdate: "plan",
|
|
388
|
+
entries
|
|
389
|
+
}
|
|
390
|
+
}];
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/provider-config.ts
|
|
394
|
+
/**
|
|
395
|
+
* Render a value as a digest that does not change with key order. The type
|
|
396
|
+
* parameter keeps the caller's own type at the call site, so the digest of a
|
|
397
|
+
* configuration cannot silently accept a value of an unrelated shape.
|
|
398
|
+
*/
|
|
399
|
+
function stableSerialize(value) {
|
|
400
|
+
if (value === null) return "null";
|
|
401
|
+
if (value === void 0) return "undefined";
|
|
402
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
403
|
+
if (typeof value === "number") {
|
|
404
|
+
if (Number.isNaN(value)) return "number:NaN";
|
|
405
|
+
if (Object.is(value, -0)) return "number:-0";
|
|
406
|
+
return `number:${value}`;
|
|
407
|
+
}
|
|
408
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
409
|
+
if (typeof value === "bigint") return `bigint:${value.toString()}`;
|
|
410
|
+
if (Array.isArray(value)) return `[${value.map((entry) => stableSerialize(entry)).join(",")}]`;
|
|
411
|
+
if (typeof value === "object") return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`).join(",")}}`;
|
|
412
|
+
return `${typeof value}:${String(value)}`;
|
|
413
|
+
}
|
|
414
|
+
function cloneConfig(config) {
|
|
415
|
+
return structuredClone(config);
|
|
416
|
+
}
|
|
417
|
+
function isInteractionObject(value) {
|
|
418
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
419
|
+
}
|
|
420
|
+
function mergeCliProviderConfig(current, next) {
|
|
421
|
+
const merged = {
|
|
422
|
+
...current,
|
|
423
|
+
...next
|
|
424
|
+
};
|
|
425
|
+
const currentInteractions = current.interactions;
|
|
426
|
+
const nextInteractions = next.interactions;
|
|
427
|
+
if (isInteractionObject(currentInteractions) && isInteractionObject(nextInteractions)) merged.interactions = {
|
|
428
|
+
...currentInteractions,
|
|
429
|
+
...nextInteractions
|
|
430
|
+
};
|
|
431
|
+
return merged;
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
434
|
+
//#region src/adapter-initialization.ts
|
|
435
|
+
/**
|
|
436
|
+
* The in-flight and settled initialization of one adapter, and the pending
|
|
437
|
+
* shutdown: which `initialize()` call is current, which configuration it
|
|
438
|
+
* carries, and the generation that a `shutdown()` bumps to cancel it.
|
|
439
|
+
*/
|
|
440
|
+
var InitializationTracker = class {
|
|
441
|
+
generation = 0;
|
|
442
|
+
promise = null;
|
|
443
|
+
configKey = null;
|
|
444
|
+
cancellation = null;
|
|
445
|
+
initializedConfigKey = null;
|
|
446
|
+
shutdownPromise;
|
|
447
|
+
providerTails = /* @__PURE__ */ new Set();
|
|
448
|
+
constructor(provider) {
|
|
449
|
+
this.provider = provider;
|
|
450
|
+
}
|
|
451
|
+
assertCurrent(generation, cancellation) {
|
|
452
|
+
if (generation !== this.generation || cancellation.cancelled) throw new Error(`${this.provider()} provider initialization cancelled by shutdown`);
|
|
453
|
+
}
|
|
454
|
+
/** Forget `promise` once it settles, unless a newer one replaced it. */
|
|
455
|
+
clear(promise) {
|
|
456
|
+
if (this.promise !== promise) return;
|
|
457
|
+
this.promise = null;
|
|
458
|
+
this.configKey = null;
|
|
459
|
+
this.cancellation = null;
|
|
460
|
+
}
|
|
461
|
+
/** Cancel the in-flight initialization and invalidate its generation. */
|
|
462
|
+
cancel() {
|
|
463
|
+
this.generation += 1;
|
|
464
|
+
if (this.cancellation) this.cancellation.cancelled = true;
|
|
465
|
+
this.promise = null;
|
|
466
|
+
this.configKey = null;
|
|
467
|
+
this.cancellation = null;
|
|
468
|
+
this.initializedConfigKey = null;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* The adapter configuration an `initialize()` call produces: the initial
|
|
473
|
+
* configuration overlaid with the host's runtime values. Throws when a
|
|
474
|
+
* trusted-path or identity value is malformed.
|
|
475
|
+
*/
|
|
476
|
+
function overlayProviderConfig(provider, initialConfig, config) {
|
|
477
|
+
const nextConfig = cloneConfig(initialConfig);
|
|
478
|
+
const runtimeAttachments = config.runtimeAttachments;
|
|
479
|
+
if (runtimeAttachments !== void 0) nextConfig.runtimeAttachments = structuredClone(runtimeAttachments);
|
|
480
|
+
const runtimeSecrets = config.runtimeSecrets;
|
|
481
|
+
if (runtimeSecrets !== void 0) nextConfig.runtimeSecrets = { ...runtimeSecrets };
|
|
482
|
+
const runtimeHome = config.runtimeHome;
|
|
483
|
+
if (runtimeHome !== void 0) {
|
|
484
|
+
if (typeof runtimeHome !== "string" || !path.isAbsolute(runtimeHome)) throw new Error(`${provider} runtimeHome must be an absolute filesystem path`);
|
|
485
|
+
nextConfig.runtimeHome = runtimeHome;
|
|
486
|
+
}
|
|
487
|
+
const agentHome = config.agentHome;
|
|
488
|
+
if (agentHome !== void 0) {
|
|
489
|
+
if (typeof agentHome !== "string" || !path.isAbsolute(agentHome)) throw new Error(`${provider} agentHome must be an absolute filesystem path`);
|
|
490
|
+
nextConfig.agentHome = agentHome;
|
|
491
|
+
}
|
|
492
|
+
const subprocessUser = config.subprocessUser;
|
|
493
|
+
if (subprocessUser !== void 0) {
|
|
494
|
+
if (process.getuid?.() !== 0 || !Number.isSafeInteger(subprocessUser.uid) || subprocessUser.uid <= 0 || !Number.isSafeInteger(subprocessUser.gid) || subprocessUser.gid <= 0) throw new Error(`${provider}: invalid trusted subprocess identity`);
|
|
495
|
+
nextConfig.subprocessUser = { ...subprocessUser };
|
|
496
|
+
if (config.stableIsolatedIdentity === true) nextConfig.stableIsolatedIdentity = true;
|
|
497
|
+
}
|
|
498
|
+
const publicProfileRevisionRoot = config.publicProfileRevisionRoot;
|
|
499
|
+
if (publicProfileRevisionRoot !== void 0) {
|
|
500
|
+
if (typeof publicProfileRevisionRoot !== "string" || !path.isAbsolute(publicProfileRevisionRoot)) throw new Error(`${provider} publicProfileRevisionRoot must be an absolute filesystem path`);
|
|
501
|
+
nextConfig.publicProfileRevisionRoot = publicProfileRevisionRoot;
|
|
502
|
+
}
|
|
503
|
+
const privateRevision = config;
|
|
504
|
+
if (privateRevision.secretProfileRevisionRoot !== void 0 || privateRevision.secretProfileNamingKeyPath !== void 0) {
|
|
505
|
+
if (!subprocessUser || publicProfileRevisionRoot !== void 0 || !privateRevision.secretProfileRevisionRoot || !path.isAbsolute(privateRevision.secretProfileRevisionRoot) || !privateRevision.secretProfileNamingKeyPath || !path.isAbsolute(privateRevision.secretProfileNamingKeyPath)) throw new Error(`${provider}: invalid secret profile revision root`);
|
|
506
|
+
nextConfig.secretProfileRevisionRoot = privateRevision.secretProfileRevisionRoot;
|
|
507
|
+
nextConfig.secretProfileNamingKeyPath = privateRevision.secretProfileNamingKeyPath;
|
|
508
|
+
}
|
|
509
|
+
const sandboxedRuntime = config.sandboxedRuntime;
|
|
510
|
+
if (sandboxedRuntime !== void 0) nextConfig.sandboxedRuntime = sandboxedRuntime === true;
|
|
511
|
+
const nativeAuthEnv = config.nativeAuthEnv;
|
|
512
|
+
if (nativeAuthEnv !== void 0) {
|
|
513
|
+
nextConfig.nativeAuthEnv = { ...nativeAuthEnv };
|
|
514
|
+
nextConfig.nativeAuthSecretEnv = config.nativeAuthSecretEnv;
|
|
515
|
+
}
|
|
516
|
+
if (config.model?.model !== void 0) nextConfig.model = config.model.model;
|
|
517
|
+
if (config.model?.provider !== void 0) nextConfig.provider = config.model.provider;
|
|
518
|
+
if (config.model?.apiKey !== void 0) nextConfig.apiKey = config.model.apiKey;
|
|
519
|
+
if (config.model?.baseUrl !== void 0) nextConfig.baseUrl = config.model.baseUrl;
|
|
520
|
+
if (config.model?.authMode !== void 0) nextConfig.authMode = config.model.authMode;
|
|
521
|
+
if (config.model?.authFiles !== void 0) nextConfig.authFiles = config.model.authFiles.map((file) => ({ ...file }));
|
|
522
|
+
return nextConfig;
|
|
523
|
+
}
|
|
524
|
+
//#endregion
|
|
525
|
+
//#region src/execution-lifecycle.ts
|
|
526
|
+
const lifecycles = /* @__PURE__ */ new WeakMap();
|
|
527
|
+
function executionLifecycle(owner) {
|
|
528
|
+
let lifecycle = lifecycles.get(owner);
|
|
529
|
+
if (!lifecycle) {
|
|
530
|
+
lifecycle = {
|
|
531
|
+
inFlightExecutions: /* @__PURE__ */ new Set(),
|
|
532
|
+
shuttingDown: false
|
|
533
|
+
};
|
|
534
|
+
lifecycles.set(owner, lifecycle);
|
|
535
|
+
}
|
|
536
|
+
return lifecycle;
|
|
537
|
+
}
|
|
538
|
+
function beginExecution(owner, callerAbortSignal) {
|
|
539
|
+
const lifecycle = executionLifecycle(owner);
|
|
540
|
+
if (lifecycle.shuttingDown) throw new Error(`${owner.provider} provider is shutting down`);
|
|
541
|
+
let resolveSettled;
|
|
542
|
+
const execution = {
|
|
543
|
+
controller: new AbortController(),
|
|
544
|
+
settled: new Promise((resolve) => {
|
|
545
|
+
resolveSettled = resolve;
|
|
546
|
+
}),
|
|
547
|
+
resolveSettled: () => resolveSettled()
|
|
548
|
+
};
|
|
549
|
+
lifecycle.inFlightExecutions.add(execution);
|
|
550
|
+
if (callerAbortSignal) {
|
|
551
|
+
const abortFromCaller = () => execution.controller.abort();
|
|
552
|
+
execution.callerAbortSignal = callerAbortSignal;
|
|
553
|
+
execution.callerAbortListener = abortFromCaller;
|
|
554
|
+
if (callerAbortSignal.aborted) execution.controller.abort();
|
|
555
|
+
else callerAbortSignal.addEventListener("abort", abortFromCaller, { once: true });
|
|
556
|
+
}
|
|
557
|
+
return execution;
|
|
558
|
+
}
|
|
559
|
+
function finishExecution(owner, execution) {
|
|
560
|
+
executionLifecycle(owner).inFlightExecutions.delete(execution);
|
|
561
|
+
if (execution.callerAbortSignal && execution.callerAbortListener) execution.callerAbortSignal.removeEventListener("abort", execution.callerAbortListener);
|
|
562
|
+
execution.resolveSettled();
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/writable-home.ts
|
|
566
|
+
/**
|
|
567
|
+
* The nearest ancestor of `target` that exists, or null when even the root of
|
|
568
|
+
* the path is absent. A directory that does not exist yet is usable only if
|
|
569
|
+
* something above it can be written to, so the writability question always
|
|
570
|
+
* resolves against an ancestor that is really there.
|
|
571
|
+
*/
|
|
572
|
+
function nearestExistingAncestor(target) {
|
|
573
|
+
let current = path.resolve(target);
|
|
574
|
+
for (;;) {
|
|
575
|
+
if (fs.existsSync(current)) return current;
|
|
576
|
+
const parent = path.dirname(current);
|
|
577
|
+
if (parent === current) return null;
|
|
578
|
+
current = parent;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* True when a CLI can create and write `target`.
|
|
583
|
+
*
|
|
584
|
+
* A read-only mount is the case this exists for: the shared tool-cache root is
|
|
585
|
+
* mounted read-only with only named subdirectories bound read-write, so a path
|
|
586
|
+
* under it that has no bind mount refuses `mkdir` with `EROFS`. A CLI that
|
|
587
|
+
* creates its cache directory at startup then dies before it runs, and a
|
|
588
|
+
* caller waiting on that run observes silence, not an error.
|
|
589
|
+
*
|
|
590
|
+
* The check reads permissions and never creates anything. Creating the
|
|
591
|
+
* directory here would run as the sidecar's own user and leave a directory the
|
|
592
|
+
* agent user cannot write, which trades one unusable path for another.
|
|
593
|
+
*
|
|
594
|
+
* A directory the calling process may write but the agent subprocess may not
|
|
595
|
+
* still reads as usable. Ownership is the spawn layer's concern; this answers
|
|
596
|
+
* the mount-level question that silently kills a CLI.
|
|
597
|
+
*/
|
|
598
|
+
function isWritableTarget(target) {
|
|
599
|
+
const ancestor = nearestExistingAncestor(target);
|
|
600
|
+
if (!ancestor) return false;
|
|
601
|
+
try {
|
|
602
|
+
fs.accessSync(ancestor, fs.constants.W_OK);
|
|
603
|
+
return true;
|
|
604
|
+
} catch {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** `/root` belongs to the calling process, never to the agent subprocess. */
|
|
609
|
+
const READ_ONLY_ROOT_HOME = "/root";
|
|
610
|
+
function isRootHomePath(value) {
|
|
611
|
+
return value === READ_ONLY_ROOT_HOME || value.startsWith(`${READ_ONLY_ROOT_HOME}/`);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Whether an inherited path must not reach the CLI, given whether it can be
|
|
615
|
+
* written. Two independent reasons, kept apart from the filesystem probe so
|
|
616
|
+
* each is decidable on its own:
|
|
617
|
+
*
|
|
618
|
+
* - It sits under `/root`. The agent subprocess runs as its own user, so
|
|
619
|
+
* that path is wrong even while the calling process can write there.
|
|
620
|
+
* - Nothing can create it. A read-only mount refuses `mkdir` for every user,
|
|
621
|
+
* including root.
|
|
622
|
+
*/
|
|
623
|
+
function shouldReplacePath(value, writable) {
|
|
624
|
+
return isRootHomePath(value) || !writable;
|
|
625
|
+
}
|
|
626
|
+
function mustReplace(value) {
|
|
627
|
+
return shouldReplacePath(value, isWritableTarget(value));
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Point `HOME` and the XDG directories at somewhere a CLI can actually write.
|
|
631
|
+
*
|
|
632
|
+
* An inherited value is kept whenever it is usable, so an operator can still
|
|
633
|
+
* aim a cache at a large volume. An unusable value is replaced by the
|
|
634
|
+
* equivalent path under the writable home, because keeping it only moves the
|
|
635
|
+
* failure into the CLI, where it surfaces as a run that emits nothing.
|
|
636
|
+
*/
|
|
637
|
+
function applyWritableHomeDefaults(env, workspaceRoot) {
|
|
638
|
+
const next = { ...env };
|
|
639
|
+
const writableHome = next.SIDECAR_AGENT_HOME?.trim() || next.AGENT_WORKSPACE_ROOT?.trim() || workspaceRoot;
|
|
640
|
+
const currentHome = next.HOME?.trim();
|
|
641
|
+
const home = currentHome && !mustReplace(currentHome) ? currentHome : writableHome;
|
|
642
|
+
for (const [key, fallback] of Object.entries(homeXdgEnv(home))) {
|
|
643
|
+
const current = next[key]?.trim();
|
|
644
|
+
if (key === "HOME" || !current || mustReplace(current)) next[key] = fallback;
|
|
645
|
+
}
|
|
646
|
+
return next;
|
|
647
|
+
}
|
|
648
|
+
/** HOME and the XDG base directories a CLI resolves under it. */
|
|
649
|
+
function homeXdgEnv(home) {
|
|
650
|
+
return {
|
|
651
|
+
HOME: home,
|
|
652
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
653
|
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
|
654
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
655
|
+
XDG_STATE_HOME: path.join(home, ".local", "state")
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/adapter-lifecycle.ts
|
|
660
|
+
const DEFAULT_CONTEXT_WINDOW = 2e5;
|
|
661
|
+
const execFileAsync = promisify(execFile);
|
|
662
|
+
/**
|
|
663
|
+
* Console-based logger fallback.
|
|
664
|
+
*/
|
|
665
|
+
const consoleLogger = {
|
|
666
|
+
debug: (msg, meta) => console.debug(`[cli-provider] ${msg}`, meta ?? ""),
|
|
667
|
+
info: (msg, meta) => console.info(`[cli-provider] ${msg}`, meta ?? ""),
|
|
668
|
+
warn: (msg, meta) => console.warn(`[cli-provider] ${msg}`, meta ?? ""),
|
|
669
|
+
error: (msg, meta) => console.error(`[cli-provider] ${msg}`, meta ?? "")
|
|
670
|
+
};
|
|
671
|
+
/** Owns configuration, initialization, cancellation, and subprocess identity. */
|
|
672
|
+
var CliAdapterLifecycle = class {
|
|
673
|
+
/**
|
|
674
|
+
* The message log directory when the caller names none. Unset, it is the
|
|
675
|
+
* registry spec's `messageStorageDir` for `provider`, the one source; only
|
|
676
|
+
* an adapter for a provider the registry does not know sets it.
|
|
677
|
+
*/
|
|
678
|
+
defaultStorageDir;
|
|
679
|
+
inheritProcessEnv = true;
|
|
680
|
+
config;
|
|
681
|
+
initialConfig;
|
|
682
|
+
logger;
|
|
683
|
+
sessionStore = null;
|
|
684
|
+
messageStore = null;
|
|
685
|
+
storagePath;
|
|
686
|
+
_storageDir;
|
|
687
|
+
agentEnvironment;
|
|
688
|
+
/** The consumer's executor for turn processes; a local spawn when absent. */
|
|
689
|
+
launcher;
|
|
690
|
+
/** Whether a turn's leftover process-group members are stopped at exit. */
|
|
691
|
+
ownProcessGroup;
|
|
692
|
+
initialized = false;
|
|
693
|
+
activeProcesses = /* @__PURE__ */ new Set();
|
|
694
|
+
initialization = new InitializationTracker(() => this.provider);
|
|
695
|
+
constructor(options = {}) {
|
|
696
|
+
executionLifecycle(this);
|
|
697
|
+
this.initialConfig = cloneConfig(options.config ?? {});
|
|
698
|
+
this.config = cloneConfig(this.initialConfig);
|
|
699
|
+
this.logger = options.logger ?? consoleLogger;
|
|
700
|
+
this.storagePath = options.storagePath ?? process.cwd();
|
|
701
|
+
this._storageDir = options.storageDir;
|
|
702
|
+
this.agentEnvironment = options.agentEnvironment;
|
|
703
|
+
this.launcher = options.launcher;
|
|
704
|
+
this.ownProcessGroup = options.ownProcessGroup ?? false;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Get the storage directory. Uses provided storageDir or falls back to defaultStorageDir.
|
|
708
|
+
*/
|
|
709
|
+
get storageDir() {
|
|
710
|
+
return this._storageDir ?? this.defaultStorageDir ?? messageStorageDirOf(this.provider);
|
|
711
|
+
}
|
|
712
|
+
/** Bind a live interaction to the exact provider execution that originated it. */
|
|
713
|
+
interactionExecutionBinding(input, sessionId) {
|
|
714
|
+
if (!input.interactionBinding) throw new Error(`${this.provider} interactions require AgentExecutionInput.interactionBinding`);
|
|
715
|
+
const binding = InteractionExecutionBindingSchema.parse(input.interactionBinding);
|
|
716
|
+
if (binding.provider !== this.provider) throw new Error(`Interaction binding provider "${binding.provider}" does not match adapter "${this.provider}"`);
|
|
717
|
+
if (binding.sessionId !== sessionId) throw new Error(`Interaction binding session "${binding.sessionId}" does not match adapter session "${sessionId}"`);
|
|
718
|
+
return binding;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The uid/gid every CLI process this adapter spawns runs as, or undefined
|
|
722
|
+
* when the host process is already unprivileged.
|
|
723
|
+
*
|
|
724
|
+
* This is an adapter-wide invariant, not a per-callsite option, because
|
|
725
|
+
* these CLIs seed their own state on first use — a sqlite database, a log
|
|
726
|
+
* directory, a downloaded-binary cache under the harness HOME. An auxiliary
|
|
727
|
+
* invocation left at root (`mcp add`, `mcp list`, `--version`) creates that
|
|
728
|
+
* tree root-owned, and the streamed run, which does drop privileges, then
|
|
729
|
+
* cannot open its own log and exits before emitting a single event.
|
|
730
|
+
*/
|
|
731
|
+
subprocessIdentity() {
|
|
732
|
+
const assigned = this.config.subprocessUser;
|
|
733
|
+
if (assigned) {
|
|
734
|
+
if (process.getuid?.() !== 0 || !Number.isSafeInteger(assigned.uid) || assigned.uid <= 0 || !Number.isSafeInteger(assigned.gid) || assigned.gid <= 0) throw new Error(`${this.provider}: invalid trusted subprocess identity`);
|
|
735
|
+
return assigned;
|
|
736
|
+
}
|
|
737
|
+
if (process.getuid?.() !== 0) return void 0;
|
|
738
|
+
const rawUid = process.env.AGENT_SUBPROCESS_UID ?? "1000";
|
|
739
|
+
const rawGid = process.env.AGENT_SUBPROCESS_GID ?? "1000";
|
|
740
|
+
const uid = Number(rawUid);
|
|
741
|
+
const gid = Number(rawGid);
|
|
742
|
+
if (!Number.isInteger(uid) || uid < 0 || !Number.isInteger(gid) || gid < 0) throw new Error(`${this.provider}: AGENT_SUBPROCESS_UID/AGENT_SUBPROCESS_GID must be non-negative integers (got ${rawUid}/${rawGid})`);
|
|
743
|
+
return {
|
|
744
|
+
uid,
|
|
745
|
+
gid
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** `subprocessIdentity` shaped for `spawn`'s options bag. */
|
|
749
|
+
subprocessSpawnOptions() {
|
|
750
|
+
return this.subprocessIdentity() ?? {};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Run a one-shot CLI command under this adapter's subprocess identity.
|
|
754
|
+
*
|
|
755
|
+
* Every auxiliary invocation an adapter makes goes through here so the
|
|
756
|
+
* identity cannot be forgotten at a call site. Auxiliary output stays out
|
|
757
|
+
* of the streamed turn's raw evidence.
|
|
758
|
+
*/
|
|
759
|
+
runCli(config) {
|
|
760
|
+
return execCli({
|
|
761
|
+
...config,
|
|
762
|
+
rawCapturePath: void 0,
|
|
763
|
+
...this.config.subprocessUser ? { isolatedIdentity: this.config.subprocessUser } : {},
|
|
764
|
+
spawnOptions: {
|
|
765
|
+
...this.subprocessSpawnOptions(),
|
|
766
|
+
...config.spawnOptions
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Run a builtin-only Node script under this adapter's subprocess identity
|
|
772
|
+
* and return its stdout. File work inside a directory the CLI user owns goes
|
|
773
|
+
* through here, so a root sidecar never creates, reads or writes there with
|
|
774
|
+
* its own rights: a link the agent plants reaches only what the agent could
|
|
775
|
+
* already reach. The script reads `args` from argv after `--` and `input`
|
|
776
|
+
* from stdin. A failure carries the script's stderr, not the script text.
|
|
777
|
+
*/
|
|
778
|
+
async runNodeAsSubprocess(script, args, input = "") {
|
|
779
|
+
try {
|
|
780
|
+
const pending = execFileAsync(process.execPath, [
|
|
781
|
+
"-e",
|
|
782
|
+
script,
|
|
783
|
+
"--",
|
|
784
|
+
...args
|
|
785
|
+
], {
|
|
786
|
+
...this.subprocessSpawnOptions(),
|
|
787
|
+
cwd: "/",
|
|
788
|
+
env: {},
|
|
789
|
+
timeout: 3e4,
|
|
790
|
+
maxBuffer: 64 * 1024 * 1024
|
|
791
|
+
});
|
|
792
|
+
pending.child.stdin?.on("error", () => {});
|
|
793
|
+
pending.child.stdin?.end(input);
|
|
794
|
+
return (await pending).stdout;
|
|
795
|
+
} catch (error) {
|
|
796
|
+
const stderr = String(error.stderr ?? "").trim();
|
|
797
|
+
throw new Error((stderr || (error instanceof Error ? error.message : String(error))).slice(-500));
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
getCapabilities() {
|
|
801
|
+
return {
|
|
802
|
+
streaming: true,
|
|
803
|
+
toolUse: true,
|
|
804
|
+
reasoning: true,
|
|
805
|
+
multimodal: false,
|
|
806
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
initialize(config) {
|
|
810
|
+
const attachments = config.runtimeAttachments === void 0 ? void 0 : AgentRuntimeAttachmentsSchema.safeParse(config.runtimeAttachments);
|
|
811
|
+
if (attachments && !attachments.success) return Promise.reject(/* @__PURE__ */ new Error("Invalid runtime MCP attachments"));
|
|
812
|
+
const capturedConfig = attachments?.success ? {
|
|
813
|
+
...config,
|
|
814
|
+
runtimeAttachments: attachments.data
|
|
815
|
+
} : config;
|
|
816
|
+
const configKey = stableSerialize(capturedConfig);
|
|
817
|
+
if (this.initialization.promise) {
|
|
818
|
+
if (configKey === this.initialization.configKey) return this.initialization.promise;
|
|
819
|
+
return Promise.reject(/* @__PURE__ */ new Error(`${this.provider} provider cannot start a conflicting initialize() while initialization is in flight; concurrent calls must use the same configuration`));
|
|
820
|
+
}
|
|
821
|
+
if (this.initialized) {
|
|
822
|
+
if (configKey === this.initialization.initializedConfigKey) return Promise.resolve();
|
|
823
|
+
return Promise.reject(/* @__PURE__ */ new Error(`${this.provider} provider is already initialized with a different configuration; call shutdown() before reinitializing`));
|
|
824
|
+
}
|
|
825
|
+
const pendingShutdown = this.initialization.shutdownPromise;
|
|
826
|
+
const generation = this.initialization.generation;
|
|
827
|
+
const cancellation = { cancelled: false };
|
|
828
|
+
const initialization = Promise.resolve().then(async () => {
|
|
829
|
+
if (pendingShutdown) await pendingShutdown;
|
|
830
|
+
this.initialization.assertCurrent(generation, cancellation);
|
|
831
|
+
executionLifecycle(this).shuttingDown = false;
|
|
832
|
+
await this.initializeAtGeneration(capturedConfig, configKey, generation, cancellation);
|
|
833
|
+
});
|
|
834
|
+
this.initialization.promise = initialization;
|
|
835
|
+
this.initialization.configKey = configKey;
|
|
836
|
+
this.initialization.cancellation = cancellation;
|
|
837
|
+
initialization.then(() => this.initialization.clear(initialization), () => this.initialization.clear(initialization));
|
|
838
|
+
return initialization;
|
|
839
|
+
}
|
|
840
|
+
async initializeAtGeneration(config, configKey, generation, cancellation) {
|
|
841
|
+
this.initialization.assertCurrent(generation, cancellation);
|
|
842
|
+
const nextStoragePath = config.workspace?.rootPath || this.storagePath;
|
|
843
|
+
const nextConfig = overlayProviderConfig(this.provider, this.initialConfig, config);
|
|
844
|
+
const configuredProfile = config.profile ?? nextConfig.profile;
|
|
845
|
+
if (configuredProfile !== void 0) {
|
|
846
|
+
nextConfig.profile = await resolveAgentProfileResources(agentProfileSchema.parse(configuredProfile));
|
|
847
|
+
this.initialization.assertCurrent(generation, cancellation);
|
|
848
|
+
}
|
|
849
|
+
const contract = this.sessionContract();
|
|
850
|
+
if (contract) assertFlagsHonourSessionContract(this.provider, contract, nextConfig.extraFlags ?? [], "extraFlags");
|
|
851
|
+
const persistenceConfig = {
|
|
852
|
+
storagePath: nextStoragePath,
|
|
853
|
+
storageDir: this.storageDir
|
|
854
|
+
};
|
|
855
|
+
const nextSessionStore = createSessionStore(persistenceConfig);
|
|
856
|
+
const nextMessageStore = createMessageStore(persistenceConfig);
|
|
857
|
+
this.initialization.assertCurrent(generation, cancellation);
|
|
858
|
+
this.storagePath = nextStoragePath;
|
|
859
|
+
this.config = nextConfig;
|
|
860
|
+
this.sessionStore = nextSessionStore;
|
|
861
|
+
this.messageStore = nextMessageStore;
|
|
862
|
+
const providerTail = Promise.resolve().then(() => this.afterInitialize(config));
|
|
863
|
+
this.initialization.providerTails.add(providerTail);
|
|
864
|
+
try {
|
|
865
|
+
await providerTail;
|
|
866
|
+
this.initialization.assertCurrent(generation, cancellation);
|
|
867
|
+
this.initialized = true;
|
|
868
|
+
this.initialization.initializedConfigKey = configKey;
|
|
869
|
+
} catch (error) {
|
|
870
|
+
if (generation === this.initialization.generation) {
|
|
871
|
+
this.config = cloneConfig(this.initialConfig);
|
|
872
|
+
this.sessionStore = null;
|
|
873
|
+
this.messageStore = null;
|
|
874
|
+
this.initialized = false;
|
|
875
|
+
this.initialization.initializedConfigKey = null;
|
|
876
|
+
}
|
|
877
|
+
throw error;
|
|
878
|
+
} finally {
|
|
879
|
+
this.initialization.providerTails.delete(providerTail);
|
|
880
|
+
}
|
|
881
|
+
this.logger.info(`${this.provider} provider initialized`, {
|
|
882
|
+
storagePath: this.storagePath,
|
|
883
|
+
storageDir: this.storageDir,
|
|
884
|
+
model: this.config.model
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Auth/config files the adapter needs on disk that do NOT arrive through
|
|
889
|
+
* `config.model.authFiles` — e.g. a synthesized `~/.openclaw/openclaw.json`
|
|
890
|
+
* pointing at the managed router. The sidecar materializes these through the
|
|
891
|
+
* hardened `materializeCliAuthFiles` path AFTER `initialize()`, so an adapter
|
|
892
|
+
* can derive them from the resolved config/env. Default: none.
|
|
893
|
+
*/
|
|
894
|
+
getManagedAuthFiles() {
|
|
895
|
+
return [];
|
|
896
|
+
}
|
|
897
|
+
/** Provider-specific initialization runs inside the tracked lifecycle. */
|
|
898
|
+
async afterInitialize(_config) {}
|
|
899
|
+
/**
|
|
900
|
+
* The harness session contract this adapter honours (cli-agent-registry
|
|
901
|
+
* NATIVE_SESSION_CONTRACTS), or null for a provider that declares none.
|
|
902
|
+
* Registration refuses a harness spec without one; ACP overrides this per
|
|
903
|
+
* sub-agent.
|
|
904
|
+
*/
|
|
905
|
+
sessionContract() {
|
|
906
|
+
return NATIVE_SESSION_CONTRACTS[this.provider] ?? null;
|
|
907
|
+
}
|
|
908
|
+
async shutdown() {
|
|
909
|
+
this.initialization.cancel();
|
|
910
|
+
this.initialized = false;
|
|
911
|
+
executionLifecycle(this).shuttingDown = true;
|
|
912
|
+
this.config = cloneConfig(this.initialConfig);
|
|
913
|
+
if (this.initialization.shutdownPromise) return this.initialization.shutdownPromise;
|
|
914
|
+
const executions = [...executionLifecycle(this).inFlightExecutions];
|
|
915
|
+
const processes = [...this.activeProcesses];
|
|
916
|
+
const providerInitializationTails = [...this.initialization.providerTails];
|
|
917
|
+
for (const execution of executions) execution.controller.abort();
|
|
918
|
+
const trackedShutdown = (async () => {
|
|
919
|
+
await Promise.allSettled([
|
|
920
|
+
...providerInitializationTails,
|
|
921
|
+
...processes.map((process) => terminateTurnProcess(process, this.activeProcesses)),
|
|
922
|
+
...executions.map((execution) => execution.settled)
|
|
923
|
+
]);
|
|
924
|
+
this.sessionStore = null;
|
|
925
|
+
this.messageStore = null;
|
|
926
|
+
executionLifecycle(this).shuttingDown = false;
|
|
927
|
+
this.logger.info(`${this.provider} provider shut down`);
|
|
928
|
+
})().finally(() => {
|
|
929
|
+
if (this.initialization.shutdownPromise === trackedShutdown) this.initialization.shutdownPromise = void 0;
|
|
930
|
+
});
|
|
931
|
+
this.initialization.shutdownPromise = trackedShutdown;
|
|
932
|
+
return trackedShutdown;
|
|
933
|
+
}
|
|
934
|
+
async healthCheck() {
|
|
935
|
+
try {
|
|
936
|
+
return (await this.runAuxiliaryCli(["--version"], 5e3)).exit.success;
|
|
937
|
+
} catch {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/** Run this CLI's own one-shot command (version, MCP) in the workspace. */
|
|
942
|
+
runAuxiliaryCli(args, timeoutMs) {
|
|
943
|
+
return this.runCli({
|
|
944
|
+
command: this.getCliPath(),
|
|
945
|
+
args,
|
|
946
|
+
cwd: this.storagePath,
|
|
947
|
+
env: this.applyWritableHomeDefaults(this.buildNativeExecutionEnvironment(this.config), this.storagePath),
|
|
948
|
+
inheritEnv: this.inheritProcessEnv,
|
|
949
|
+
timeoutMs
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
async updateConfig(config) {
|
|
953
|
+
const resolvedConfig = { ...config };
|
|
954
|
+
if (config.profile !== void 0) resolvedConfig.profile = await resolveAgentProfileResources(agentProfileSchema.parse(config.profile));
|
|
955
|
+
this.config = mergeCliProviderConfig(this.config, resolvedConfig);
|
|
956
|
+
return this.config;
|
|
957
|
+
}
|
|
958
|
+
/** Apply private native credentials to every CLI subprocess through one path. */
|
|
959
|
+
buildNativeExecutionEnvironment(config) {
|
|
960
|
+
const env = this.buildExecEnv(config);
|
|
961
|
+
if (config.nativeAuthSecretEnv) env[config.nativeAuthSecretEnv] = void 0;
|
|
962
|
+
return {
|
|
963
|
+
...env,
|
|
964
|
+
...config.nativeAuthEnv
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
getCliPath() {
|
|
968
|
+
return this.config.cliPath ?? this.defaultCliPath;
|
|
969
|
+
}
|
|
970
|
+
ensureInitialized() {
|
|
971
|
+
if (executionLifecycle(this).shuttingDown) throw new Error(`${this.provider} provider is shutting down`);
|
|
972
|
+
if (!this.initialized) throw new Error(`${this.provider} provider not initialized. Call initialize() first.`);
|
|
973
|
+
}
|
|
974
|
+
getSessionStore() {
|
|
975
|
+
if (!this.sessionStore) throw new Error("Session store not initialized");
|
|
976
|
+
return this.sessionStore;
|
|
977
|
+
}
|
|
978
|
+
getMessageStore() {
|
|
979
|
+
if (!this.messageStore) throw new Error("Message store not initialized");
|
|
980
|
+
return this.messageStore;
|
|
981
|
+
}
|
|
982
|
+
/** The agent environment the host supplied (CliProviderOptions.agentEnvironment). */
|
|
983
|
+
agentEnvironmentValues() {
|
|
984
|
+
return this.agentEnvironment?.() ?? {};
|
|
985
|
+
}
|
|
986
|
+
applyWritableHomeDefaults(adapterEnv, workspaceRoot) {
|
|
987
|
+
const env = {
|
|
988
|
+
...this.agentEnvironmentValues(),
|
|
989
|
+
...adapterEnv
|
|
990
|
+
};
|
|
991
|
+
const runtimeHome = this.config.runtimeHome?.trim();
|
|
992
|
+
if (!runtimeHome) return applyWritableHomeDefaults(env, workspaceRoot);
|
|
993
|
+
const agentHome = this.config.agentHome?.trim();
|
|
994
|
+
const configDir = agentHome ? configDirHomeEnv(this.provider, runtimeHome, agentHome) : void 0;
|
|
995
|
+
if (configDir) {
|
|
996
|
+
const adapterStores = Object.keys(configDir).flatMap((name) => {
|
|
997
|
+
if (name === "HOME" || name.startsWith("XDG_")) return [];
|
|
998
|
+
const value = adapterEnv[name]?.trim();
|
|
999
|
+
return value && value !== process.env[name] ? [[name, value]] : [];
|
|
1000
|
+
});
|
|
1001
|
+
return applyWritableHomeDefaults({
|
|
1002
|
+
...env,
|
|
1003
|
+
...configDir,
|
|
1004
|
+
...Object.fromEntries(adapterStores)
|
|
1005
|
+
}, workspaceRoot);
|
|
1006
|
+
}
|
|
1007
|
+
return applyWritableHomeDefaults({
|
|
1008
|
+
...env,
|
|
1009
|
+
...homeXdgEnv(runtimeHome)
|
|
1010
|
+
}, workspaceRoot);
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
async function terminateTurnProcess(process, activeProcesses) {
|
|
1014
|
+
process.kill();
|
|
1015
|
+
const escalation = setTimeout(() => process.kill("SIGKILL"), 1e3);
|
|
1016
|
+
escalation.unref();
|
|
1017
|
+
try {
|
|
1018
|
+
await process.exitPromise;
|
|
1019
|
+
} finally {
|
|
1020
|
+
clearTimeout(escalation);
|
|
1021
|
+
activeProcesses.delete(process);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
//#endregion
|
|
1025
|
+
//#region src/execution-request-headers.ts
|
|
1026
|
+
const headerValue = z.string().regex(/^[\x21-\x2b\x2d-\x7e]{1,256}$/);
|
|
1027
|
+
const CORRELATION_FIELDS = {
|
|
1028
|
+
"X-Tangle-Environment-ID": "environmentId",
|
|
1029
|
+
"X-Tangle-Session-ID": "sessionId",
|
|
1030
|
+
"X-Tangle-Execution-ID": "executionId"
|
|
1031
|
+
};
|
|
1032
|
+
/** Router column that joins a model request to the turn trace that caused it. */
|
|
1033
|
+
const EXECUTION_TRACE_HEADER = "X-Tangle-Trace-ID";
|
|
1034
|
+
const MANAGED_QUOTE_VERSION_HEADER = "X-Tangle-Managed-Quote-Version";
|
|
1035
|
+
const managedQuoteVersion = z.string().regex(/^mmq_[a-f0-9]{40}$/);
|
|
1036
|
+
/**
|
|
1037
|
+
* Capture diagnostic identity without forwarding caller-supplied transport headers.
|
|
1038
|
+
* `traceId` is the turn's trace id; a value no header can carry is omitted,
|
|
1039
|
+
* because a trace is diagnostic and must never refuse the run.
|
|
1040
|
+
*/
|
|
1041
|
+
function executionCorrelationHeaders(binding, traceId, quoteVersion) {
|
|
1042
|
+
const quote = quoteVersion === void 0 ? void 0 : managedQuoteVersion.parse(quoteVersion);
|
|
1043
|
+
if (quote !== void 0 && binding === void 0) throw new Error("Managed quote requires an exact execution binding");
|
|
1044
|
+
const trace = headerValue.safeParse(traceId);
|
|
1045
|
+
const traced = trace.success ? { [EXECUTION_TRACE_HEADER]: trace.data } : {};
|
|
1046
|
+
if (binding === void 0) return trace.success ? Object.freeze(traced) : void 0;
|
|
1047
|
+
const headers = { ...traced };
|
|
1048
|
+
if (quote !== void 0) headers[MANAGED_QUOTE_VERSION_HEADER] = quote;
|
|
1049
|
+
for (const [name, field] of Object.entries(CORRELATION_FIELDS)) {
|
|
1050
|
+
const value = headerValue.safeParse(binding[field]);
|
|
1051
|
+
if (!value.success) throw new Error(`Execution correlation ${field} must contain 1–256 printable ASCII characters without spaces or commas`);
|
|
1052
|
+
headers[name] = value.data;
|
|
1053
|
+
}
|
|
1054
|
+
return Object.freeze(headers);
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/profile-config.ts
|
|
1058
|
+
/**
|
|
1059
|
+
* Per-turn credential delivery wins over host-env resolution; the host box
|
|
1060
|
+
* environment stays the fallback for references the turn did not deliver.
|
|
1061
|
+
* With no turn secrets the host provider is returned unchanged, so the
|
|
1062
|
+
* overlay adds no behavior to executions that never asked for it.
|
|
1063
|
+
*/
|
|
1064
|
+
function runtimeSecretsOverlay(runtimeSecrets, fallback) {
|
|
1065
|
+
if (!runtimeSecrets || Object.keys(runtimeSecrets).length === 0) return fallback;
|
|
1066
|
+
return { get: async (key) => runtimeSecrets[key] ?? await fallback.get(key) };
|
|
1067
|
+
}
|
|
1068
|
+
function hasPortableProfileControls(profile) {
|
|
1069
|
+
return profile.prompt !== void 0 || profile.model !== void 0 || profile.harness !== void 0 || profile.permissions !== void 0 || profile.tools !== void 0 || profile.mcp !== void 0 || profile.connections !== void 0 || profile.subagents !== void 0 || profile.resources !== void 0 || profile.hooks !== void 0 || profile.modes !== void 0 || profile.confidential !== void 0 || profile.extensions !== void 0;
|
|
1070
|
+
}
|
|
1071
|
+
function resolveSystemPrompt(requestPrompt, profilePrompt) {
|
|
1072
|
+
if (profilePrompt === void 0) return requestPrompt;
|
|
1073
|
+
if (requestPrompt !== void 0 && requestPrompt.length > 0 && requestPrompt !== profilePrompt) throw new Error("execution system prompt conflicts with the materialized AgentProfile");
|
|
1074
|
+
return profilePrompt;
|
|
1075
|
+
}
|
|
1076
|
+
function profileForExecution(profile, requestPrompt) {
|
|
1077
|
+
const profilePrompt = profile?.prompt?.systemPrompt;
|
|
1078
|
+
if (!profile || profilePrompt === void 0 || requestPrompt === void 0 || requestPrompt.length === 0 || requestPrompt === profilePrompt) return profile;
|
|
1079
|
+
if (!requestPrompt.startsWith(`${profilePrompt}\n\n`)) throw new Error("execution system prompt conflicts with the materialized AgentProfile");
|
|
1080
|
+
return {
|
|
1081
|
+
...profile,
|
|
1082
|
+
prompt: {
|
|
1083
|
+
...profile.prompt,
|
|
1084
|
+
systemPrompt: requestPrompt
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Refuse to dispatch an execution against a model the profile did not
|
|
1090
|
+
* materialize — a caller must not silently get a different model than the one
|
|
1091
|
+
* the workspace was built for.
|
|
1092
|
+
*
|
|
1093
|
+
* The request and the profile carry the same model at different qualification
|
|
1094
|
+
* levels: the request is the id the caller sent, the profile default is that id
|
|
1095
|
+
* qualified with its provider. Both go through the one shared canonicalization
|
|
1096
|
+
* so only a genuinely different model is a conflict.
|
|
1097
|
+
*/
|
|
1098
|
+
function resolveModel(requestModel, profileModel, configuredModel, profileProvider) {
|
|
1099
|
+
if (profileModel !== void 0 && requestModel !== void 0 && !modelIdsMatch(requestModel, profileModel, profileProvider)) throw new Error("execution model conflicts with the materialized AgentProfile");
|
|
1100
|
+
return profileModel ?? requestModel ?? configuredModel;
|
|
1101
|
+
}
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region src/profile-revision-receipts.ts
|
|
1104
|
+
/** Receipts belong to their adapter; they never become subclass controls. */
|
|
1105
|
+
var ProfileRevisionReceipts = class {
|
|
1106
|
+
publicProfileRevisionReceipts = /* @__PURE__ */ new WeakMap();
|
|
1107
|
+
secretProfileRevisionReceipts = /* @__PURE__ */ new WeakMap();
|
|
1108
|
+
};
|
|
1109
|
+
const receiptsByAdapter = /* @__PURE__ */ new WeakMap();
|
|
1110
|
+
function profileRevisionReceipts(owner) {
|
|
1111
|
+
let receipts = receiptsByAdapter.get(owner);
|
|
1112
|
+
if (!receipts) {
|
|
1113
|
+
receipts = new ProfileRevisionReceipts();
|
|
1114
|
+
receiptsByAdapter.set(owner, receipts);
|
|
1115
|
+
}
|
|
1116
|
+
return receipts;
|
|
1117
|
+
}
|
|
1118
|
+
function assertExecutionProfileRevision(owner, getConfig, input, profilePlan) {
|
|
1119
|
+
if (getConfig().publicProfileRevisionRoot && profilePlan && !profileRevisionReceipts(owner).publicProfileRevisionReceipts.has(profilePlan)) throw new Error("Provider added public profile launch controls after immutable revision preparation");
|
|
1120
|
+
if (getConfig().secretProfileRevisionRoot && profilePlan && !profileRevisionReceipts(owner).secretProfileRevisionReceipts.has(profilePlan)) throw new Error("Provider added secret profile launch controls after immutable revision preparation");
|
|
1121
|
+
const expectedSecretReceipt = input.expectedSecretProfileReceipt;
|
|
1122
|
+
if (getConfig().secretProfileRevisionRoot) {
|
|
1123
|
+
const actual = profilePlan ? profileRevisionReceipts(owner).secretProfileRevisionReceipts.get(profilePlan) : void 0;
|
|
1124
|
+
if (!expectedSecretReceipt || !actual || actual.executorReceipt !== expectedSecretReceipt) throw new Error("Secret native profile receipt mismatch before native spawn");
|
|
1125
|
+
} else if (expectedSecretReceipt !== void 0) throw new Error("Secret native profile execution is unavailable");
|
|
1126
|
+
const expectedRevision = input.expectedPublicProfileRevision;
|
|
1127
|
+
if (expectedRevision !== void 0) {
|
|
1128
|
+
const actualRevision = profilePlan ? profileRevisionReceipts(owner).publicProfileRevisionReceipts.get(profilePlan) : void 0;
|
|
1129
|
+
if (!actualRevision || actualRevision.workspacePlanDigest !== expectedRevision.workspacePlanDigest || actualRevision.materializedDigest !== expectedRevision.materializedDigest || actualRevision.launchDigest !== expectedRevision.launchDigest) throw new Error("Public native profile revision mismatch before native spawn");
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region src/errors.ts
|
|
1134
|
+
/**
|
|
1135
|
+
* A session's persisted transcript exists but could not be read for replay
|
|
1136
|
+
* into the fresh provider session this turn starts. The turn fails instead
|
|
1137
|
+
* of serving an empty conversation under the same session id. The sidecar's
|
|
1138
|
+
* client-visible whitelist matches on `code`; the wire contract is the
|
|
1139
|
+
* duck-typed field, not class identity.
|
|
1140
|
+
*/
|
|
1141
|
+
var SessionRehydrationError = class extends Error {
|
|
1142
|
+
code = "session_rehydration_failed";
|
|
1143
|
+
constructor(message, cause) {
|
|
1144
|
+
super(message, { cause });
|
|
1145
|
+
this.name = "SessionRehydrationError";
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
const NOT_LAUNCHED = Symbol.for("@tangle-network/sdk-provider-cli-base.not-launched");
|
|
1149
|
+
/**
|
|
1150
|
+
* Marks a turn failure the adapter raised before it planned the launch:
|
|
1151
|
+
* session preparation or profile materialization refused, so no native
|
|
1152
|
+
* process received this turn and it left no native transcript to copy.
|
|
1153
|
+
* Only the adapter marks, on the error object it caught; error text never
|
|
1154
|
+
* does, so a provider message that reads the same is not marked.
|
|
1155
|
+
*/
|
|
1156
|
+
function rejectNotLaunched(error) {
|
|
1157
|
+
if (error instanceof Error && !Object.isFrozen(error)) Object.defineProperty(error, NOT_LAUNCHED, { value: true });
|
|
1158
|
+
throw error;
|
|
1159
|
+
}
|
|
1160
|
+
/** Whether this error is one the adapter raised before launching anything. */
|
|
1161
|
+
function wasNeverLaunched(error) {
|
|
1162
|
+
return Object.getOwnPropertyDescriptor(error, NOT_LAUNCHED)?.value === true;
|
|
1163
|
+
}
|
|
1164
|
+
//#endregion
|
|
1165
|
+
//#region src/file-parts.ts
|
|
1166
|
+
/** Extension → MIME type, keyed by lowercased extension without the dot. */
|
|
1167
|
+
const EXTENSION_TO_MEDIA_TYPE = {
|
|
1168
|
+
png: "image/png",
|
|
1169
|
+
jpg: "image/jpeg",
|
|
1170
|
+
jpeg: "image/jpeg",
|
|
1171
|
+
gif: "image/gif",
|
|
1172
|
+
webp: "image/webp",
|
|
1173
|
+
svg: "image/svg+xml",
|
|
1174
|
+
pdf: "application/pdf",
|
|
1175
|
+
mp3: "audio/mpeg",
|
|
1176
|
+
wav: "audio/wav",
|
|
1177
|
+
mp4: "video/mp4",
|
|
1178
|
+
webm: "video/webm",
|
|
1179
|
+
txt: "text/plain",
|
|
1180
|
+
md: "text/markdown",
|
|
1181
|
+
csv: "text/csv",
|
|
1182
|
+
json: "application/json",
|
|
1183
|
+
html: "text/html",
|
|
1184
|
+
markdown: "text/markdown",
|
|
1185
|
+
htm: "text/html",
|
|
1186
|
+
xml: "application/xml",
|
|
1187
|
+
yaml: "application/yaml",
|
|
1188
|
+
yml: "application/yaml",
|
|
1189
|
+
js: "text/javascript",
|
|
1190
|
+
ts: "application/typescript",
|
|
1191
|
+
py: "text/x-python"
|
|
1192
|
+
};
|
|
1193
|
+
/**
|
|
1194
|
+
* Inverse of the table above, for synthesizing a filename from a media type.
|
|
1195
|
+
* Built once from the first extension seen per media type (e.g. image/jpeg
|
|
1196
|
+
* → "jpg", not "jpeg") so the mapping is stable across calls.
|
|
1197
|
+
*/
|
|
1198
|
+
const MEDIA_TYPE_TO_EXTENSION = Object.entries(EXTENSION_TO_MEDIA_TYPE).reduce((acc, [ext, mediaType]) => {
|
|
1199
|
+
if (!(mediaType in acc)) acc[mediaType] = ext;
|
|
1200
|
+
return acc;
|
|
1201
|
+
}, {});
|
|
1202
|
+
function sha1Hex(value) {
|
|
1203
|
+
return createHash("sha1").update(value).digest("hex");
|
|
1204
|
+
}
|
|
1205
|
+
/** Infers a MIME type from a filename's extension. Unknown/no extension → undefined. */
|
|
1206
|
+
function inferMediaType(filename) {
|
|
1207
|
+
const dot = filename.lastIndexOf(".");
|
|
1208
|
+
if (dot < 0 || dot === filename.length - 1) return void 0;
|
|
1209
|
+
return EXTENSION_TO_MEDIA_TYPE[filename.slice(dot + 1).toLowerCase()];
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Builds the canonical URL for a file part. A data URI passes through
|
|
1213
|
+
* verbatim; a path is returned unchanged if it already carries a recognized
|
|
1214
|
+
* scheme, else converted to a percent-encoded `file://` URL.
|
|
1215
|
+
*
|
|
1216
|
+
* Callers must pass an already-resolved absolute path — `trackFilePart`
|
|
1217
|
+
* resolves relative paths (or drops the observation when it can't) before
|
|
1218
|
+
* ever reaching here, so a relative path arriving at this layer means a
|
|
1219
|
+
* caller bypassed that resolution. We throw rather than mint a malformed
|
|
1220
|
+
* `file://` URL (e.g. `file://./chart.png`, whose authority is `.`).
|
|
1221
|
+
*/
|
|
1222
|
+
function buildFileUrl(input) {
|
|
1223
|
+
if (input.dataUri !== void 0) return input.dataUri;
|
|
1224
|
+
if (input.path === void 0) throw new Error("buildFileUrl requires either `path` or `dataUri` to build a file part URL");
|
|
1225
|
+
if (input.path.startsWith("file://") || input.path.startsWith("http://") || input.path.startsWith("https://") || input.path.startsWith("data:")) return input.path;
|
|
1226
|
+
if (!isAbsolute(input.path)) throw new Error(`buildFileUrl requires an absolute path to build a file:// URL, got "${input.path}" — resolve it against a workspace root before calling buildFileUrl`);
|
|
1227
|
+
return pathToFileURL(input.path).href;
|
|
1228
|
+
}
|
|
1229
|
+
/** Deterministic, stable part id for a file announced within one message. */
|
|
1230
|
+
function deterministicFilePartId(messageId, key) {
|
|
1231
|
+
return `file-${sha1Hex(`${messageId}|${key}`).slice(0, 16)}`;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Stateless derivation of the canonical file-part fields for one observed
|
|
1235
|
+
* path: resolve-once (normalize, then anchor a relative path to
|
|
1236
|
+
* `workspaceRoot`), and from that single resolved path derive the dedupe
|
|
1237
|
+
* `key`, the deterministic `id`, the `filename`, the inferred `mediaType`,
|
|
1238
|
+
* and the percent-encoded `file://` `url` — so every consumer that surfaces
|
|
1239
|
+
* the same file agrees on all five.
|
|
1240
|
+
*
|
|
1241
|
+
* Returns `null` for a relative path with no `workspaceRoot` to anchor it:
|
|
1242
|
+
* detection is opportunistic and a fabricated location is worse than a
|
|
1243
|
+
* missing part (see `trackFilePart` for the full rationale).
|
|
1244
|
+
*
|
|
1245
|
+
* This is the ONE derivation chain. `trackFilePart` (exec path, with
|
|
1246
|
+
* ExecutionState memoization) and stateless mappers that own no
|
|
1247
|
+
* ExecutionState (codex's app-server item mapper) both call it — never
|
|
1248
|
+
* re-implement the chain, or the two paths drift.
|
|
1249
|
+
*/
|
|
1250
|
+
function deriveFilePart(input) {
|
|
1251
|
+
const normalized = normalize(input.path);
|
|
1252
|
+
let resolvedPath;
|
|
1253
|
+
if (isAbsolute(normalized)) resolvedPath = normalized;
|
|
1254
|
+
else if (input.workspaceRoot !== void 0) resolvedPath = resolve(input.workspaceRoot, normalized);
|
|
1255
|
+
else return null;
|
|
1256
|
+
const filename = basename(resolvedPath);
|
|
1257
|
+
return {
|
|
1258
|
+
id: deterministicFilePartId(input.messageId, resolvedPath),
|
|
1259
|
+
key: resolvedPath,
|
|
1260
|
+
filename,
|
|
1261
|
+
mediaType: inferMediaType(filename),
|
|
1262
|
+
url: buildFileUrl({ path: resolvedPath })
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
/**
|
|
1266
|
+
* Tracks a file an adapter observed being created/modified (e.g. from a
|
|
1267
|
+
* write-tool call) and returns the canonical `FilePart` fields to stream,
|
|
1268
|
+
* deduping by resolved-absolute-path or data-URI content so repeated writes
|
|
1269
|
+
* to the same file update one part instead of appending duplicates.
|
|
1270
|
+
*
|
|
1271
|
+
* The path is normalized and resolved to an absolute form ONCE, and that same
|
|
1272
|
+
* resolved value drives both the dedupe key and the `file://` url — so two
|
|
1273
|
+
* observations of the same file that differ only in spelling (`./chart.png`
|
|
1274
|
+
* vs `/workspace/chart.png`, or `/workspace/sub/../chart.png` vs
|
|
1275
|
+
* `/workspace/chart.png`) unify on one part with one stable url, satisfying
|
|
1276
|
+
* the stream invariant that a given part id keeps a stable url.
|
|
1277
|
+
*
|
|
1278
|
+
* A relative path can only be resolved against `state.workspaceRoot` (set by
|
|
1279
|
+
* the adapter from the CLI's cwd). Detection here is opportunistic — a
|
|
1280
|
+
* tool-call observation, not a guarantee of an absolute path — so when no
|
|
1281
|
+
* workspaceRoot is available to anchor a relative path, we drop the
|
|
1282
|
+
* observation (`null`) rather than fabricate an anchor (e.g. resolving
|
|
1283
|
+
* against "/"): a wrong url is worse than a missing part, and a caller that
|
|
1284
|
+
* genuinely lost the file would get a wrong, permanent url once it's on the
|
|
1285
|
+
* stream.
|
|
1286
|
+
*
|
|
1287
|
+
* Detection is opportunistic for other reasons too: a delete, or an
|
|
1288
|
+
* observation carrying neither a path nor a data URI, also yields no part
|
|
1289
|
+
* (`null`) rather than an error — callers are not required to have a file
|
|
1290
|
+
* signal on every tool result.
|
|
1291
|
+
*/
|
|
1292
|
+
function trackFilePart(state, args) {
|
|
1293
|
+
if (args.changeKind === "delete") return null;
|
|
1294
|
+
if (args.path === void 0 && args.dataUri === void 0) return null;
|
|
1295
|
+
if (state.messageId === void 0) throw new Error("trackFilePart requires state.messageId (set by createExecutionState) to derive a stable part id");
|
|
1296
|
+
let derived = null;
|
|
1297
|
+
if (args.path !== void 0) {
|
|
1298
|
+
derived = deriveFilePart({
|
|
1299
|
+
path: args.path,
|
|
1300
|
+
messageId: state.messageId,
|
|
1301
|
+
workspaceRoot: state.workspaceRoot
|
|
1302
|
+
});
|
|
1303
|
+
if (derived === null) return null;
|
|
1304
|
+
}
|
|
1305
|
+
state.fileParts ??= [];
|
|
1306
|
+
const parts = state.fileParts;
|
|
1307
|
+
const key = derived !== null ? derived.key : `data:${sha1Hex(args.dataUri)}`;
|
|
1308
|
+
const url = derived !== null ? derived.url : buildFileUrl({ dataUri: args.dataUri });
|
|
1309
|
+
const existing = parts.find((p) => p.key === key);
|
|
1310
|
+
let filename = args.filename ?? derived?.filename ?? existing?.filename;
|
|
1311
|
+
const mediaType = args.mediaType ?? (filename !== void 0 ? inferMediaType(filename) : void 0) ?? existing?.mediaType;
|
|
1312
|
+
if (filename === void 0 && args.path === void 0) {
|
|
1313
|
+
const priorDataUriEntries = parts.filter((p) => p.key.startsWith("data:")).length;
|
|
1314
|
+
const ext = mediaType ? MEDIA_TYPE_TO_EXTENSION[mediaType] ?? "bin" : "bin";
|
|
1315
|
+
filename = `attachment-${priorDataUriEntries + 1}.${ext}`;
|
|
1316
|
+
}
|
|
1317
|
+
if (existing) {
|
|
1318
|
+
existing.filename = filename;
|
|
1319
|
+
existing.mediaType = mediaType;
|
|
1320
|
+
existing.url = url;
|
|
1321
|
+
return {
|
|
1322
|
+
id: existing.id,
|
|
1323
|
+
filename: existing.filename,
|
|
1324
|
+
mediaType: existing.mediaType,
|
|
1325
|
+
url: existing.url
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
const id = derived !== null ? derived.id : deterministicFilePartId(state.messageId, key);
|
|
1329
|
+
parts.push({
|
|
1330
|
+
id,
|
|
1331
|
+
key,
|
|
1332
|
+
filename,
|
|
1333
|
+
mediaType,
|
|
1334
|
+
url
|
|
1335
|
+
});
|
|
1336
|
+
return {
|
|
1337
|
+
id,
|
|
1338
|
+
filename,
|
|
1339
|
+
mediaType,
|
|
1340
|
+
url
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
//#endregion
|
|
1344
|
+
//#region src/prompt-input.ts
|
|
1345
|
+
/** Raw-byte ceiling for inlining one image as base64 (~1.33x expansion in
|
|
1346
|
+
* memory and on the wire); larger images keep only their `path` and the
|
|
1347
|
+
* adapter falls back to path-based delivery. */
|
|
1348
|
+
const MAX_INLINE_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
1349
|
+
/** Aggregate raw-byte budget for inlined images across one request's parts —
|
|
1350
|
+
* the request schema admits up to 64 parts, so a per-image cap alone would
|
|
1351
|
+
* still let one request hold hundreds of MB of base64 until execution. */
|
|
1352
|
+
const MAX_TOTAL_INLINE_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
1353
|
+
async function readImageBase64(filePath, wroteFile, budgetBytes) {
|
|
1354
|
+
const limit = Math.min(MAX_INLINE_IMAGE_BYTES, budgetBytes);
|
|
1355
|
+
if (limit <= 0) return void 0;
|
|
1356
|
+
let handle;
|
|
1357
|
+
try {
|
|
1358
|
+
handle = await open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
1359
|
+
const stats = await handle.stat();
|
|
1360
|
+
if (!stats.isFile() || stats.size > limit) return void 0;
|
|
1361
|
+
const buffer = Buffer.alloc(stats.size + 1);
|
|
1362
|
+
let offset = 0;
|
|
1363
|
+
while (offset < buffer.length) {
|
|
1364
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
1365
|
+
if (bytesRead === 0) break;
|
|
1366
|
+
offset += bytesRead;
|
|
1367
|
+
}
|
|
1368
|
+
if (offset > stats.size) return void 0;
|
|
1369
|
+
return {
|
|
1370
|
+
base64: buffer.subarray(0, offset).toString("base64"),
|
|
1371
|
+
rawBytes: offset
|
|
1372
|
+
};
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
if (wroteFile) throw error;
|
|
1375
|
+
return;
|
|
1376
|
+
} finally {
|
|
1377
|
+
await handle?.close();
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
function inferExtension(mediaType) {
|
|
1381
|
+
if (!mediaType) return "bin";
|
|
1382
|
+
if (mediaType === "application/pdf") return "pdf";
|
|
1383
|
+
if (mediaType.startsWith("image/")) return mediaType.split("/")[1] || "img";
|
|
1384
|
+
if (mediaType.startsWith("audio/")) return mediaType.split("/")[1] || "audio";
|
|
1385
|
+
if (mediaType.startsWith("video/")) return mediaType.split("/")[1] || "video";
|
|
1386
|
+
return mediaType.includes("/") ? mediaType.split("/")[1] || "bin" : "bin";
|
|
1387
|
+
}
|
|
1388
|
+
function sanitizeFilename(filename) {
|
|
1389
|
+
return filename.replace(/[^a-zA-Z0-9._-]/g, "-") || `attachment-${randomUUID()}`;
|
|
1390
|
+
}
|
|
1391
|
+
const TEXT_MEDIA_TYPES = new Set([
|
|
1392
|
+
"application/json",
|
|
1393
|
+
"application/xml",
|
|
1394
|
+
"application/yaml",
|
|
1395
|
+
"application/x-yaml",
|
|
1396
|
+
"application/javascript",
|
|
1397
|
+
"application/csv"
|
|
1398
|
+
]);
|
|
1399
|
+
function isTextMediaType(mediaType) {
|
|
1400
|
+
if (!mediaType) return true;
|
|
1401
|
+
const essence = (mediaType.split(";")[0] ?? "").trim().toLowerCase();
|
|
1402
|
+
if (essence.startsWith("text/")) return true;
|
|
1403
|
+
if (TEXT_MEDIA_TYPES.has(essence)) return true;
|
|
1404
|
+
return essence.endsWith("+json") || essence.endsWith("+xml");
|
|
1405
|
+
}
|
|
1406
|
+
async function materializeAttachmentPart(part, workspaceRoot, sessionId, index) {
|
|
1407
|
+
const attachmentDir = path.join(workspaceRoot, ".agent-inputs", sessionId);
|
|
1408
|
+
await mkdir(attachmentDir, { recursive: true });
|
|
1409
|
+
let dataUriMatch;
|
|
1410
|
+
if (part.url?.startsWith("data:")) {
|
|
1411
|
+
const match = part.url.match(/^data:([^;,]+)?;base64,(.*)$/s);
|
|
1412
|
+
if (!match) throw new InvalidAttachmentError(`Invalid data URI for ${part.type} attachment`);
|
|
1413
|
+
dataUriMatch = match;
|
|
1414
|
+
}
|
|
1415
|
+
const mediaType = part.mediaType || dataUriMatch?.[1] || void 0;
|
|
1416
|
+
const filename = sanitizeFilename(part.filename || (part.path ? path.basename(part.path) : void 0) || `${part.type}-${index + 1}.${inferExtension(mediaType)}`);
|
|
1417
|
+
const targetPath = path.join(attachmentDir, filename);
|
|
1418
|
+
let referencePath = part.path ? path.resolve(workspaceRoot, part.path) : targetPath;
|
|
1419
|
+
let wroteFile = false;
|
|
1420
|
+
const content = "content" in part ? part.content : void 0;
|
|
1421
|
+
const payload = dataUriMatch ? Buffer.from(dataUriMatch[2], "base64") : typeof content === "string" ? content : void 0;
|
|
1422
|
+
if (payload !== void 0) {
|
|
1423
|
+
if (!dataUriMatch && !isTextMediaType(part.mediaType)) throw new InvalidAttachmentError(`Cannot write binary attachment "${filename}" (mediaType: ${part.mediaType}) via "content" — deliver it as a base64 data: URL (data:${part.mediaType};base64,...) or via "path"/"file://" url instead.`);
|
|
1424
|
+
const partDirectory = path.join(attachmentDir, `part-${randomUUID()}`);
|
|
1425
|
+
await mkdir(partDirectory);
|
|
1426
|
+
referencePath = path.join(partDirectory, filename);
|
|
1427
|
+
await writeFile(referencePath, payload, { flag: "wx" });
|
|
1428
|
+
wroteFile = true;
|
|
1429
|
+
} else if (part.url?.startsWith("file://")) try {
|
|
1430
|
+
referencePath = fileURLToPath(part.url);
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
throw new InvalidAttachmentError(`Invalid file:// URL for ${part.type} attachment: ${JSON.stringify(part.url)} (${err instanceof Error ? err.message : String(err)})`);
|
|
1433
|
+
}
|
|
1434
|
+
else if (part.url && /^https?:\/\//i.test(part.url)) referencePath = part.url;
|
|
1435
|
+
const isHttpReference = referencePath.startsWith("http://") || referencePath.startsWith("https://");
|
|
1436
|
+
return {
|
|
1437
|
+
reference: referencePath,
|
|
1438
|
+
filename,
|
|
1439
|
+
...mediaType ? { mediaType } : {},
|
|
1440
|
+
wroteFile,
|
|
1441
|
+
persistedPart: {
|
|
1442
|
+
type: "file",
|
|
1443
|
+
filename,
|
|
1444
|
+
mediaType,
|
|
1445
|
+
url: isHttpReference ? referencePath : buildFileUrl({ path: referencePath })
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
async function preparePromptInputParts(parts, workspaceRoot, sessionId, options) {
|
|
1450
|
+
const nativeKinds = new Set(options?.nativeAttachmentKinds ?? []);
|
|
1451
|
+
const promptSections = [];
|
|
1452
|
+
const attachmentRefs = [];
|
|
1453
|
+
const persistedParts = [];
|
|
1454
|
+
const attachments = [];
|
|
1455
|
+
const inlineImages = nativeKinds.has("image");
|
|
1456
|
+
let inlineBudget = options?.inlineImageBudgetBytes ?? 33554432;
|
|
1457
|
+
for (const [index, part] of parts.entries()) {
|
|
1458
|
+
if (part.type === "text") {
|
|
1459
|
+
if (!part.text) continue;
|
|
1460
|
+
promptSections.push(part.text);
|
|
1461
|
+
persistedParts.push({
|
|
1462
|
+
type: "text",
|
|
1463
|
+
text: part.text
|
|
1464
|
+
});
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
const materialized = await materializeAttachmentPart(part, workspaceRoot, sessionId, index);
|
|
1468
|
+
const marker = `[${part.type === "image" ? "Image" : "File"}: ${materialized.reference}]`;
|
|
1469
|
+
if (!nativeKinds.has(part.type)) attachmentRefs.push(marker);
|
|
1470
|
+
persistedParts.push(materialized.persistedPart);
|
|
1471
|
+
const isRemote = /^https?:\/\//i.test(materialized.reference);
|
|
1472
|
+
const attachment = {
|
|
1473
|
+
kind: part.type,
|
|
1474
|
+
filename: materialized.filename,
|
|
1475
|
+
marker,
|
|
1476
|
+
...materialized.mediaType ? { mediaType: materialized.mediaType } : {},
|
|
1477
|
+
...isRemote ? { url: materialized.reference } : { path: materialized.reference }
|
|
1478
|
+
};
|
|
1479
|
+
if (part.type === "image" && attachment.path && inlineImages) {
|
|
1480
|
+
const inlined = await readImageBase64(attachment.path, materialized.wroteFile, inlineBudget);
|
|
1481
|
+
if (inlined) {
|
|
1482
|
+
attachment.base64 = inlined.base64;
|
|
1483
|
+
inlineBudget -= inlined.rawBytes;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
attachments.push(attachment);
|
|
1487
|
+
}
|
|
1488
|
+
if (attachmentRefs.length > 0) promptSections.push(`Attached files:\n${attachmentRefs.join("\n")}`);
|
|
1489
|
+
return {
|
|
1490
|
+
promptText: promptSections.join("\n\n").trim(),
|
|
1491
|
+
persistedParts,
|
|
1492
|
+
attachments
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Partition a turn's image attachments into the ones the adapter will
|
|
1497
|
+
* forward natively and the "stranded" rest, folding each stranded image's
|
|
1498
|
+
* `[Image: ...]` marker back into the message text. Once an adapter's
|
|
1499
|
+
* nativeAttachmentKinds() claims "image", the base prompt renderer omits
|
|
1500
|
+
* every image marker — so any image the mechanism can't carry (no base64
|
|
1501
|
+
* for a stdin block, no local path for an argv flag, beyond a single-path
|
|
1502
|
+
* flag's capacity) MUST re-enter the prompt here or the model never sees
|
|
1503
|
+
* a reference to it. Uses the same "Attached files:" block format the
|
|
1504
|
+
* renderer itself emits for non-native kinds.
|
|
1505
|
+
*/
|
|
1506
|
+
function partitionImageAttachments(options) {
|
|
1507
|
+
const { message, attachments, qualifies, maxNative } = options;
|
|
1508
|
+
const native = [];
|
|
1509
|
+
const strandedMarkers = [];
|
|
1510
|
+
for (const attachment of attachments ?? []) {
|
|
1511
|
+
if (attachment.kind !== "image") continue;
|
|
1512
|
+
if ((maxNative === void 0 || native.length < maxNative) && qualifies(attachment)) native.push(attachment);
|
|
1513
|
+
else strandedMarkers.push(attachment.marker);
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
native,
|
|
1517
|
+
message: strandedMarkers.length > 0 ? `${message}\n\nAttached files:\n${strandedMarkers.join("\n")}` : message
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/** Separates the sections a harness receives as one user message. */
|
|
1521
|
+
const PROMPT_SECTION_SEPARATOR = "\n\n---\n\n";
|
|
1522
|
+
/**
|
|
1523
|
+
* Puts `section` (a system prompt or replayed context) ahead of the message
|
|
1524
|
+
* for harnesses that take no separate channel for it.
|
|
1525
|
+
*/
|
|
1526
|
+
function prependPromptSection(section, body) {
|
|
1527
|
+
return section ? `${section}${PROMPT_SECTION_SEPARATOR}${body}` : body;
|
|
1528
|
+
}
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region src/types.ts
|
|
1531
|
+
function interactionToggleEnabled(config, kind) {
|
|
1532
|
+
return config === true || typeof config === "object" && config !== null && config[kind] === true;
|
|
1533
|
+
}
|
|
1534
|
+
function enabledInteractionKinds(config, supported) {
|
|
1535
|
+
if (config === true) return [...supported];
|
|
1536
|
+
if (typeof config !== "object" || config === null) return [];
|
|
1537
|
+
return supported.filter((kind) => config[kind] === true);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Read a turn's ephemeral context, rejecting a wrong-typed value loudly.
|
|
1541
|
+
*
|
|
1542
|
+
* The published `AgentExecutionInput` does not declare the field, so nothing
|
|
1543
|
+
* type-checks it at the process boundary. Left unchecked, a caller passing a
|
|
1544
|
+
* non-string gets it stringified into the model prompt (`[object Object]`) —
|
|
1545
|
+
* a corrupted turn with no error anywhere. `undefined`/`null` mean "no context
|
|
1546
|
+
* this turn"; an empty string is likewise nothing to append.
|
|
1547
|
+
*/
|
|
1548
|
+
function readEphemeralContext(input) {
|
|
1549
|
+
const context = input.ephemeralContext;
|
|
1550
|
+
if (context === void 0 || context === null) return void 0;
|
|
1551
|
+
if (typeof context !== "string") throw new TypeError(`ephemeralContext must be a string, received ${typeof context}`);
|
|
1552
|
+
return context || void 0;
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Compose the model-facing prompt for a turn: the caller's own text, then this
|
|
1556
|
+
* turn's ephemeral context. Call it at the point the prompt is handed to the
|
|
1557
|
+
* provider — NEVER on the parts handed to `recordUserMessage` or the message
|
|
1558
|
+
* store.
|
|
1559
|
+
*/
|
|
1560
|
+
function withEphemeralContext(promptText, input) {
|
|
1561
|
+
const context = readEphemeralContext(input);
|
|
1562
|
+
if (!context) return promptText;
|
|
1563
|
+
return promptText ? `${promptText}\n\n${context}` : context;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Resolve whether an interaction kind is active for THIS turn. When the turn
|
|
1567
|
+
* carries `interactions` it is authoritative — a complete set that can also
|
|
1568
|
+
* turn a kind OFF mid-session; otherwise the adapter falls back to its latched
|
|
1569
|
+
* config (the initialize-time wiring, or a direct non-sidecar caller's option).
|
|
1570
|
+
*/
|
|
1571
|
+
function turnInteractionEnabled(input, latched, kind) {
|
|
1572
|
+
const perTurn = input.interactions;
|
|
1573
|
+
return interactionToggleEnabled(perTurn !== void 0 ? perTurn : latched, kind);
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Overlay this turn's interactions onto a latched config, returning a NEW
|
|
1577
|
+
* object (no shared mutation — safe when one adapter instance serves concurrent
|
|
1578
|
+
* turns from different sessions). When the turn omits `interactions` the config
|
|
1579
|
+
* is returned unchanged, preserving behavior for direct non-sidecar callers.
|
|
1580
|
+
* Adapters read plan/permission/question posture off the returned config so a
|
|
1581
|
+
* mid-session toggle takes effect and can turn a kind off (which the latched
|
|
1582
|
+
* config, only ever merged by updateConfig, cannot).
|
|
1583
|
+
*/
|
|
1584
|
+
function withTurnInteractions(config, input) {
|
|
1585
|
+
if (input.interactions === void 0) return config;
|
|
1586
|
+
return {
|
|
1587
|
+
...config,
|
|
1588
|
+
interactions: input.interactions
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
//#endregion
|
|
1592
|
+
//#region src/adapter-profile.ts
|
|
1593
|
+
/**
|
|
1594
|
+
* Revisions that already passed the native health check in this process, by
|
|
1595
|
+
* the verifier's scope and the revision's materialized digest. The check asks
|
|
1596
|
+
* whether the pinned native CLI loads this revision's bytes: a function of
|
|
1597
|
+
* those bytes and that CLI, not of the session or subprocess UID that will
|
|
1598
|
+
* read them (the ACL grant that publishes a revision to a UID throws when it
|
|
1599
|
+
* fails). A session's identity changes whenever its profile variant or turn
|
|
1600
|
+
* credential does, so keying by the session's receipt checked an unchanged
|
|
1601
|
+
* profile again every turn. Secret values are excluded from the digest.
|
|
1602
|
+
* Agents are rebuilt per request, so this outlives any adapter.
|
|
1603
|
+
* Insertion-ordered and bounded: the oldest entry is forgotten first.
|
|
1604
|
+
*/
|
|
1605
|
+
const verifiedSecretProfileRevisions = /* @__PURE__ */ new Set();
|
|
1606
|
+
const VERIFIED_SECRET_PROFILE_REVISIONS_MAX = 1024;
|
|
1607
|
+
function rememberVerifiedSecretProfileRevision(key) {
|
|
1608
|
+
verifiedSecretProfileRevisions.delete(key);
|
|
1609
|
+
verifiedSecretProfileRevisions.add(key);
|
|
1610
|
+
while (verifiedSecretProfileRevisions.size > VERIFIED_SECRET_PROFILE_REVISIONS_MAX) {
|
|
1611
|
+
const oldest = verifiedSecretProfileRevisions.values().next().value;
|
|
1612
|
+
if (oldest === void 0) break;
|
|
1613
|
+
verifiedSecretProfileRevisions.delete(oldest);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
/** The executor's naming key, refused unless root owns it alone. */
|
|
1617
|
+
function readSecretProfileNamingKey(keyPath) {
|
|
1618
|
+
if (!keyPath) throw new Error("Secret profile naming key is unavailable");
|
|
1619
|
+
const keyStat = lstatSync(keyPath);
|
|
1620
|
+
if (!keyStat.isFile() || keyStat.isSymbolicLink() || keyStat.uid !== 0 || (keyStat.mode & 511) !== 384 || keyStat.size !== 32) throw new Error("Secret profile naming key is not root-owned");
|
|
1621
|
+
return readFileSync(keyPath);
|
|
1622
|
+
}
|
|
1623
|
+
/** Owns native profile revisions, launch controls, and prompt composition. */
|
|
1624
|
+
var CliAdapterProfile = class extends CliAdapterLifecycle {
|
|
1625
|
+
/** Native profile lowering explicitly owned by this adapter, never inferred from a path. */
|
|
1626
|
+
profileHarness = void 0;
|
|
1627
|
+
/** Trusted implementation capabilities; never read from caller-owned configuration. */
|
|
1628
|
+
supportsSecretProfileRevisions = false;
|
|
1629
|
+
profileSecretProvider;
|
|
1630
|
+
secretProfileRuntimeKeyProvider;
|
|
1631
|
+
/**
|
|
1632
|
+
* The materialized digest behind each secret revision. It stays here: the
|
|
1633
|
+
* executor receipt is all a caller sees of a revision's resolved contents.
|
|
1634
|
+
*/
|
|
1635
|
+
secretProfileMaterializedDigests = /* @__PURE__ */ new WeakMap();
|
|
1636
|
+
/**
|
|
1637
|
+
* The value behind each `{env:NAME}` reference in a secret revision's
|
|
1638
|
+
* config, by that revision's application. Executor memory only: no launch
|
|
1639
|
+
* environment, argv or file ever carries them.
|
|
1640
|
+
*/
|
|
1641
|
+
secretProfileReferenceValues = /* @__PURE__ */ new WeakMap();
|
|
1642
|
+
constructor(options = {}) {
|
|
1643
|
+
super(options);
|
|
1644
|
+
profileRevisionReceipts(this);
|
|
1645
|
+
this.profileSecretProvider = options.profileSecretProvider ?? UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER;
|
|
1646
|
+
this.secretProfileRuntimeKeyProvider = options.secretProfileRuntimeKeyProvider ?? UNAVAILABLE_AGENT_PROFILE_SECRET_PROVIDER;
|
|
1647
|
+
}
|
|
1648
|
+
/** Apply captured diagnostic identity to this spawn, after profile composition. */
|
|
1649
|
+
/** The exact child environment a turn uses; shared with prewarm paths. */
|
|
1650
|
+
buildTurnEnvironment(executionConfig, profilePlan, workspaceRoot, correlationHeaders) {
|
|
1651
|
+
const merged = this.mergeProfilePlanEnvironment(this.buildNativeExecutionEnvironment(executionConfig), profilePlan, executionConfig.nativeAuthEnv !== void 0 || (executionConfig.authFiles?.length ?? 0) > 0);
|
|
1652
|
+
return this.bindExecutionCorrelation(this.applyWritableHomeDefaults(this.bindProfileToolBinDirs(merged, profilePlan), workspaceRoot), correlationHeaders);
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* A profile's declared executable tools, sealed in its revision, stand in
|
|
1656
|
+
* for the app's own tool dirs on this launch's PATH tail. A secret profile's
|
|
1657
|
+
* env no longer names the app's dirs (app env is scrubbed), so the sidecar's
|
|
1658
|
+
* own SANDBOX_TOOL_BIN_DIRS says which inherited PATH entries they are.
|
|
1659
|
+
*/
|
|
1660
|
+
bindProfileToolBinDirs(env, profilePlan) {
|
|
1661
|
+
const toolBinDirs = profilePlan?.spawn.toolBinDirs;
|
|
1662
|
+
if (!toolBinDirs?.length) return env;
|
|
1663
|
+
return bindLaunchToolBinDirs("PATH" in env || !this.inheritProcessEnv || process.env.PATH === void 0 ? env : {
|
|
1664
|
+
...env,
|
|
1665
|
+
PATH: process.env.PATH
|
|
1666
|
+
}, toolBinDirs, [...parseSandboxToolBinDirs(env.SANDBOX_TOOL_BIN_DIRS), ...parseSandboxToolBinDirs(process.env.SANDBOX_TOOL_BIN_DIRS)]);
|
|
1667
|
+
}
|
|
1668
|
+
bindExecutionCorrelation(environment, correlationHeaders) {
|
|
1669
|
+
if (correlationHeaders !== void 0 && Object.keys(correlationHeaders).some((name) => name !== "X-Tangle-Trace-ID")) this.logger.warn("Native model request correlation is unsupported", { provider: this.provider });
|
|
1670
|
+
return environment;
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Provision the inline profile's files and native execution controls with the
|
|
1674
|
+
* shared materializer used by cli-bridge. Unsupported portable controls fail
|
|
1675
|
+
* before spawn unless the profile is explicitly best-effort
|
|
1676
|
+
* (resources.failOnError: false), which materializes the supported subset
|
|
1677
|
+
* and warns about the rest.
|
|
1678
|
+
*/
|
|
1679
|
+
/**
|
|
1680
|
+
* Launcher controls this adapter OWNS on the spawn it starts — see
|
|
1681
|
+
* `BINDABLE_LAUNCH_CONTROLS` in the materializer. Empty by default: a plan
|
|
1682
|
+
* whose intent needs a launcher binding then fails closed instead of being
|
|
1683
|
+
* silently dropped. An adapter that really applies the plan's launch flags
|
|
1684
|
+
* to its own spawn (sdk-provider-opencode selecting the plan's primary
|
|
1685
|
+
* agent via `--agent`) overrides this with the controls it honors.
|
|
1686
|
+
*/
|
|
1687
|
+
profileMaterializationBinds() {
|
|
1688
|
+
return [];
|
|
1689
|
+
}
|
|
1690
|
+
/** Optional native instruction root. The adapter owns how its runtime locates this directory. */
|
|
1691
|
+
profileAgentDirectory(_workspaceRoot) {}
|
|
1692
|
+
/** Provider transport bound into the private immutable revision and receipt. */
|
|
1693
|
+
secretProfileProviderConfig() {}
|
|
1694
|
+
/**
|
|
1695
|
+
* The values behind a secret revision's `{env:NAME}` config references, for
|
|
1696
|
+
* an executor that serves those references itself. Never pass them to the
|
|
1697
|
+
* native process.
|
|
1698
|
+
*/
|
|
1699
|
+
secretProfileReferences(application) {
|
|
1700
|
+
return application ? this.secretProfileReferenceValues.get(application) : void 0;
|
|
1701
|
+
}
|
|
1702
|
+
async materializeProfileResources(inputProfile, workspaceRoot, ownerId, sessionId, scope = "turn") {
|
|
1703
|
+
if (!inputProfile && !this.config.runtimeAttachments) return void 0;
|
|
1704
|
+
const started = performance.now();
|
|
1705
|
+
try {
|
|
1706
|
+
return await this.applyProfileResources(inputProfile, workspaceRoot, ownerId, sessionId);
|
|
1707
|
+
} finally {
|
|
1708
|
+
this.logger.observePhase?.("profile_materialize", performance.now() - started, scope);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
async applyProfileResources(inputProfile, workspaceRoot, ownerId, sessionId) {
|
|
1712
|
+
const profile = inputProfile ?? {};
|
|
1713
|
+
const harness = this.profileHarness;
|
|
1714
|
+
if (!harness) {
|
|
1715
|
+
if (hasPortableProfileControls(profile) || this.config.runtimeAttachments !== void 0) throw new Error(`${this.provider} does not support portable AgentProfile controls`);
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const agentDir = this.profileAgentDirectory(workspaceRoot);
|
|
1719
|
+
const plan = materializeProfile(profile, harness, {
|
|
1720
|
+
runtimeAttachments: this.config.runtimeAttachments,
|
|
1721
|
+
binds: this.profileMaterializationBinds(),
|
|
1722
|
+
...agentDir === void 0 ? {} : { instructionsRoot: "agent" }
|
|
1723
|
+
});
|
|
1724
|
+
const unsupported = enforceWorkspacePlanSupport(plan, profile.resources?.failOnError);
|
|
1725
|
+
if (unsupported.length > 0) this.logger.warn("profile contains unsupported best-effort resources", {
|
|
1726
|
+
harness,
|
|
1727
|
+
unsupported
|
|
1728
|
+
});
|
|
1729
|
+
if (this.config.secretProfileRevisionRoot) return await this.applySecretProfileRevision(plan, this.config.secretProfileRevisionRoot, workspaceRoot, ownerId, sessionId);
|
|
1730
|
+
if (this.config.publicProfileRevisionRoot) {
|
|
1731
|
+
if (!ownerId) throw new Error("Concurrent public profile launch requires a session scope; idle prewarm is unsupported");
|
|
1732
|
+
const revision = await preparePublicConcurrentProfileRevision({
|
|
1733
|
+
plan,
|
|
1734
|
+
...ensurePublicConcurrentProfileRoots(this.config.publicProfileRevisionRoot),
|
|
1735
|
+
sharedWorkspaceDir: workspaceRoot,
|
|
1736
|
+
ownerId,
|
|
1737
|
+
secretProvider: runtimeSecretsOverlay(this.config.runtimeSecrets, this.profileSecretProvider)
|
|
1738
|
+
});
|
|
1739
|
+
this.logger.debug("materialized public concurrent profile revision", {
|
|
1740
|
+
harness,
|
|
1741
|
+
directory: revision.directory,
|
|
1742
|
+
digest: revision.manifest.materializedDigest
|
|
1743
|
+
});
|
|
1744
|
+
const launchDigest = revision.manifest.launchDigest;
|
|
1745
|
+
if (!launchDigest) throw new Error("Concurrent profile launch digest was not recorded");
|
|
1746
|
+
profileRevisionReceipts(this).publicProfileRevisionReceipts.set(revision.application, {
|
|
1747
|
+
workspacePlanDigest: revision.manifest.workspacePlanDigest,
|
|
1748
|
+
materializedDigest: revision.manifest.materializedDigest,
|
|
1749
|
+
launchDigest,
|
|
1750
|
+
conflicts: revision.manifest.conflicts
|
|
1751
|
+
});
|
|
1752
|
+
return revision.application;
|
|
1753
|
+
}
|
|
1754
|
+
const application = await applyWorkspacePlanWithSecretProvider(plan, workspaceRoot, runtimeSecretsOverlay(this.config.runtimeSecrets, this.profileSecretProvider), {
|
|
1755
|
+
ownership: this.subprocessIdentity(),
|
|
1756
|
+
...agentDir === void 0 ? {} : { agentDir }
|
|
1757
|
+
});
|
|
1758
|
+
this.logger.debug("materialized profile resources", {
|
|
1759
|
+
harness,
|
|
1760
|
+
files: plan.files.length,
|
|
1761
|
+
flags: plan.flags.length,
|
|
1762
|
+
environment: Object.keys(plan.env).length
|
|
1763
|
+
});
|
|
1764
|
+
return application;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Publish the profile as an executor-sealed revision for this session's
|
|
1768
|
+
* subprocess identity, and record the receipt that binds it to the turn.
|
|
1769
|
+
*/
|
|
1770
|
+
async applySecretProfileRevision(plan, secretProfileRevisionRoot, workspaceRoot, ownerId, sessionId) {
|
|
1771
|
+
if (!this.supportsSecretProfileRevisions || !ownerId || !sessionId || !this.config.subprocessUser) throw new Error("Secret concurrent profile needs an opted-in adapter, a session, and per-turn runtime secrets only");
|
|
1772
|
+
const namingKey = readSecretProfileNamingKey(this.config.secretProfileNamingKeyPath);
|
|
1773
|
+
const roots = ensurePublicConcurrentProfileRoots(secretProfileRevisionRoot);
|
|
1774
|
+
const revision = await prepareSecretConcurrentProfileRevision({
|
|
1775
|
+
plan,
|
|
1776
|
+
providerConfig: this.secretProfileProviderConfig(),
|
|
1777
|
+
...roots,
|
|
1778
|
+
sharedWorkspaceDir: workspaceRoot,
|
|
1779
|
+
ownerId,
|
|
1780
|
+
subprocessUid: this.config.subprocessUser.uid,
|
|
1781
|
+
namingKey,
|
|
1782
|
+
secretProvider: runtimeSecretsOverlay(this.config.runtimeSecrets, this.secretProfileRuntimeKeyProvider)
|
|
1783
|
+
});
|
|
1784
|
+
const launchDigest = revision.manifest.launchDigest;
|
|
1785
|
+
if (!launchDigest) throw new Error("Secret concurrent profile launch digest is absent");
|
|
1786
|
+
const executorReceipt = `hmac-sha256:${createHmac("sha256", namingKey).update(ownerId).update("\0").update(sessionId).update("\0").update(String(this.config.subprocessUser.uid)).update("\0").update(revision.manifest.workspacePlanDigest).update("\0").update(revision.manifest.materializedDigest).update("\0").update(launchDigest).digest("hex")}`;
|
|
1787
|
+
this.secretProfileReferenceValues.set(revision.application, revision.secretReferences);
|
|
1788
|
+
this.secretProfileMaterializedDigests.set(revision.application, revision.manifest.materializedDigest);
|
|
1789
|
+
profileRevisionReceipts(this).secretProfileRevisionReceipts.set(revision.application, {
|
|
1790
|
+
workspacePlanDigest: revision.manifest.workspacePlanDigest,
|
|
1791
|
+
executorReceipt,
|
|
1792
|
+
conflicts: revision.manifest.conflicts.map((conflict) => ({ ...conflict }))
|
|
1793
|
+
});
|
|
1794
|
+
return revision.application;
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Prepare the same provider-composed native plan that execute() will check.
|
|
1798
|
+
* This may publish an immutable public revision, but never starts a model turn.
|
|
1799
|
+
*/
|
|
1800
|
+
async preparePublicProfileRevision(input) {
|
|
1801
|
+
if (!this.config.publicProfileRevisionRoot) throw new Error("Concurrent public profile materialization is disabled");
|
|
1802
|
+
const sessionScope = input.userId ?? input.sessionId;
|
|
1803
|
+
if (!sessionScope) throw new Error("Concurrent public profile preparation needs a session scope");
|
|
1804
|
+
const application = await this.materializeProfileResources(this.resolveExecutionProfileForInput(input), input.workspaceRoot ?? this.storagePath, sessionScope, input.sessionId);
|
|
1805
|
+
const receipt = application ? profileRevisionReceipts(this).publicProfileRevisionReceipts.get(application) : void 0;
|
|
1806
|
+
if (!receipt) throw new Error("Concurrent public profile preparation produced no native plan");
|
|
1807
|
+
return receipt;
|
|
1808
|
+
}
|
|
1809
|
+
/** Prepare the private native plan and return only an opaque executor receipt. */
|
|
1810
|
+
async prepareSecretProfileRevision(input) {
|
|
1811
|
+
if (!this.config.secretProfileRevisionRoot) throw new Error("Secret concurrent profile materialization is disabled");
|
|
1812
|
+
const sessionScope = input.userId ?? input.sessionId;
|
|
1813
|
+
if (!sessionScope) throw new Error("Secret profile preparation needs a session scope");
|
|
1814
|
+
const startedAt = performance.now();
|
|
1815
|
+
const application = await this.materializeProfileResources(this.resolveExecutionProfileForInput(input), input.workspaceRoot ?? this.storagePath, sessionScope, input.sessionId);
|
|
1816
|
+
const materializedAt = performance.now();
|
|
1817
|
+
const receipt = application ? profileRevisionReceipts(this).secretProfileRevisionReceipts.get(application) : void 0;
|
|
1818
|
+
if (!application || !receipt) throw new Error("Secret profile preparation produced no native plan");
|
|
1819
|
+
const materializedDigest = this.secretProfileMaterializedDigests.get(application);
|
|
1820
|
+
if (!materializedDigest) throw new Error("Secret profile preparation produced no revision digest");
|
|
1821
|
+
const verification = `${this.secretProfileVerificationScope()}\0${materializedDigest}`;
|
|
1822
|
+
const verifyCached = verifiedSecretProfileRevisions.has(verification);
|
|
1823
|
+
if (!verifyCached) await this.verifyPreparedSecretProfileRevision(input, application);
|
|
1824
|
+
rememberVerifiedSecretProfileRevision(verification);
|
|
1825
|
+
this.logger.info("prepared secret profile revision", {
|
|
1826
|
+
materializeMs: Math.round(materializedAt - startedAt),
|
|
1827
|
+
verifyMs: Math.round(performance.now() - materializedAt),
|
|
1828
|
+
verifyCached
|
|
1829
|
+
});
|
|
1830
|
+
return receipt;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* What the native health check's verdict depends on besides a revision's
|
|
1834
|
+
* bytes: the executable that loads them. A provider whose check runs
|
|
1835
|
+
* through a configurable binary names it here.
|
|
1836
|
+
*/
|
|
1837
|
+
secretProfileVerificationScope() {
|
|
1838
|
+
return this.provider;
|
|
1839
|
+
}
|
|
1840
|
+
/** A secret receipt is issued only after the provider can load this revision. */
|
|
1841
|
+
async verifyPreparedSecretProfileRevision(_input, _application) {
|
|
1842
|
+
throw new Error(`${this.provider} has no native secret profile health check`);
|
|
1843
|
+
}
|
|
1844
|
+
/** Resolve the canonical profile used for this execution. */
|
|
1845
|
+
resolveExecutionProfile(requestPrompt) {
|
|
1846
|
+
return profileForExecution(this.config.profile, requestPrompt);
|
|
1847
|
+
}
|
|
1848
|
+
/** Select the profile materialized for this exact turn. */
|
|
1849
|
+
resolveExecutionProfileForInput(input) {
|
|
1850
|
+
return this.resolveExecutionProfile(input.systemPrompt);
|
|
1851
|
+
}
|
|
1852
|
+
/** Compose the provider's user-message channel for this exact turn. */
|
|
1853
|
+
composePromptText(promptText, input) {
|
|
1854
|
+
return withEphemeralContext(promptText, input);
|
|
1855
|
+
}
|
|
1856
|
+
withProfilePlan(config, profilePlan) {
|
|
1857
|
+
if (!profilePlan || profilePlan.spawn.flags.length === 0) return config;
|
|
1858
|
+
const contract = this.sessionContract();
|
|
1859
|
+
if (contract) assertFlagsHonourSessionContract(this.provider, contract, profilePlan.spawn.flags, "profile flags");
|
|
1860
|
+
return {
|
|
1861
|
+
...config,
|
|
1862
|
+
extraFlags: [...config.extraFlags ?? [], ...profilePlan.spawn.flags]
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
resolveProfileSystemPrompt(requestPrompt, profilePlan) {
|
|
1866
|
+
return resolveSystemPrompt(requestPrompt, profilePlan?.spawn.systemPrompt);
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The plan's additive prompt intent for this spawn. No request-level
|
|
1870
|
+
* counterpart exists (`AgentExecutionInput` carries only the replacement),
|
|
1871
|
+
* so unlike {@link resolveProfileSystemPrompt} there is nothing to
|
|
1872
|
+
* conflict-check against.
|
|
1873
|
+
*/
|
|
1874
|
+
resolveProfileAppendSystemPrompt(profilePlan) {
|
|
1875
|
+
return profilePlan?.spawn.appendSystemPrompt;
|
|
1876
|
+
}
|
|
1877
|
+
resolveProfileModel(requestModel) {
|
|
1878
|
+
return resolveModel(requestModel, this.config.profile?.model?.default, this.config.model, this.config.profile?.model?.provider);
|
|
1879
|
+
}
|
|
1880
|
+
mergeProfilePlanEnvironment(environment, profilePlan, preserveClearedVariables = false) {
|
|
1881
|
+
if (!profilePlan || Object.keys(profilePlan.spawn.env).length === 0) return environment;
|
|
1882
|
+
const merged = { ...environment };
|
|
1883
|
+
for (const [name, value] of Object.entries(profilePlan.spawn.env)) {
|
|
1884
|
+
const existing = merged[name];
|
|
1885
|
+
if (existing !== void 0 && existing !== value || preserveClearedVariables && existing === void 0 && Object.hasOwn(environment, name) && !this.profilePlanMayFillClearedEnvironmentVariable(name, value)) throw new Error(`profile environment conflicts with provider environment: ${name}`);
|
|
1886
|
+
merged[name] = value;
|
|
1887
|
+
}
|
|
1888
|
+
return merged;
|
|
1889
|
+
}
|
|
1890
|
+
/** Provider-owned allowlist for a generated revision control after env scrub. */
|
|
1891
|
+
profilePlanMayFillClearedEnvironmentVariable(_name, _value) {
|
|
1892
|
+
return false;
|
|
1893
|
+
}
|
|
1894
|
+
async preparePromptInput(parts, workspaceRoot, sessionId, options) {
|
|
1895
|
+
return preparePromptInputParts(parts, workspaceRoot, sessionId, {
|
|
1896
|
+
nativeAttachmentKinds: options?.nativeAttachmentKinds ?? this.nativeAttachmentKinds(),
|
|
1897
|
+
...options?.inlineImageBudgetBytes !== void 0 ? { inlineImageBudgetBytes: options.inlineImageBudgetBytes } : {}
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Attachment kinds this adapter forwards natively via buildExecArgs (argv
|
|
1902
|
+
* flag / stdin content block) instead of the `[Image: ...]` / `[File: ...]`
|
|
1903
|
+
* text marker. The base adapter omits those kinds' markers from the prompt
|
|
1904
|
+
* text — an adapter that opts a kind in MUST deliver every attachment of
|
|
1905
|
+
* that kind itself, or the model never sees it.
|
|
1906
|
+
*/
|
|
1907
|
+
nativeAttachmentKinds() {
|
|
1908
|
+
return [];
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
//#endregion
|
|
1912
|
+
//#region src/native-session-enforcement.ts
|
|
1913
|
+
/** Store location and identity deadlines apply once a receipt proves the contract. */
|
|
1914
|
+
function sessionContractFullyEnforced(provider) {
|
|
1915
|
+
return !Object.hasOwn(HARNESS_CONFORMANCE_PENDING, provider);
|
|
1916
|
+
}
|
|
1917
|
+
//#endregion
|
|
1918
|
+
//#region src/adapter-sessions.ts
|
|
1919
|
+
/** Providers without a repair tool share one empty set. */
|
|
1920
|
+
const NO_REPAIR_TOOL_NAMES$1 = /* @__PURE__ */ new Set();
|
|
1921
|
+
/** Owns durable transcripts, provider-native session bindings and the turn hooks providers override. */
|
|
1922
|
+
var CliAdapterSessions = class extends CliAdapterProfile {
|
|
1923
|
+
async createSession(options) {
|
|
1924
|
+
this.ensureInitialized();
|
|
1925
|
+
const session = await this.getSessionStore().create({ workspaceRoot: options?.workspaceRoot ?? this.storagePath });
|
|
1926
|
+
this.logger.debug("Created session", { sessionId: session.id });
|
|
1927
|
+
return session.id;
|
|
1928
|
+
}
|
|
1929
|
+
async deleteSession(sessionId) {
|
|
1930
|
+
this.ensureInitialized();
|
|
1931
|
+
await this.getMessageStore().clear(sessionId);
|
|
1932
|
+
const deleted = await this.getSessionStore().delete(sessionId);
|
|
1933
|
+
this.logger.debug("Deleted session", {
|
|
1934
|
+
sessionId,
|
|
1935
|
+
deleted
|
|
1936
|
+
});
|
|
1937
|
+
return deleted;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Choose the native ID before its argv check, durable binding, and
|
|
1941
|
+
* reservation. `input` and `timeoutMs` are the turn's, for a harness that
|
|
1942
|
+
* decides here whether a process started earlier can serve it.
|
|
1943
|
+
*/
|
|
1944
|
+
mintNativeSessionId(_context) {
|
|
1945
|
+
return randomUUID();
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Make a pre-minted native session durable before the CLI starts, for a
|
|
1949
|
+
* harness that would otherwise write nothing until its first model reply
|
|
1950
|
+
* (Pi). Called after the argv check and before spawn.
|
|
1951
|
+
*/
|
|
1952
|
+
reserveNativeSession(_nativeSessionId, _context) {}
|
|
1953
|
+
/**
|
|
1954
|
+
* Verify that the provider can resume the native session binding. By
|
|
1955
|
+
* default a session in a per-session store is resumable only when one of
|
|
1956
|
+
* its declared files exists under the runtime HOME; a pre-minted id whose
|
|
1957
|
+
* first turn died before the CLI wrote anything is not.
|
|
1958
|
+
*/
|
|
1959
|
+
providerSessionIsResumable(runtimeHome, providerSessionId) {
|
|
1960
|
+
const contract = this.sessionContract();
|
|
1961
|
+
if (!contract) return true;
|
|
1962
|
+
if (!sessionContractFullyEnforced(this.provider) && contract.identity.kind !== "pre-minted") return true;
|
|
1963
|
+
return declaredSessionExists(contract, runtimeHome, providerSessionId);
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Resolve the provider-native session id the next turn can resume, or
|
|
1967
|
+
* undefined when no usable binding exists. Provider session data lives
|
|
1968
|
+
* under the runtime home that minted it. A different home or missing
|
|
1969
|
+
* provider storage cannot resume that session. A record without a home
|
|
1970
|
+
* stamp predates the field, so treat it as same-home.
|
|
1971
|
+
*/
|
|
1972
|
+
async resumableProviderSessionId(session) {
|
|
1973
|
+
if (!session.providerSessionId) return void 0;
|
|
1974
|
+
const currentHome = this.config.runtimeHome?.trim() || void 0;
|
|
1975
|
+
const mintedHome = session.providerSessionHome?.trim() || void 0;
|
|
1976
|
+
if (currentHome !== void 0 && mintedHome !== void 0 && mintedHome !== currentHome) return;
|
|
1977
|
+
if (currentHome !== void 0) {
|
|
1978
|
+
if (!await this.providerSessionIsResumable(currentHome, session.providerSessionId)) return;
|
|
1979
|
+
}
|
|
1980
|
+
return session.providerSessionId;
|
|
1981
|
+
}
|
|
1982
|
+
/** Bind a checkpoint-proven native session to this provider identity before execution. */
|
|
1983
|
+
async seedNativeSessionForResume(input, nativeSessionId) {
|
|
1984
|
+
if (!input.sessionId) throw new Error("Native resume requires a named session");
|
|
1985
|
+
const runtimeHome = this.config.runtimeHome?.trim();
|
|
1986
|
+
if (!runtimeHome || !await this.providerSessionIsResumable(runtimeHome, nativeSessionId)) throw new Error("Native resume session is absent from this provider home");
|
|
1987
|
+
const store = this.getSessionStore();
|
|
1988
|
+
const existing = await store.get(input.sessionId);
|
|
1989
|
+
if (!existing) await store.create({
|
|
1990
|
+
id: input.sessionId,
|
|
1991
|
+
workspaceRoot: input.workspaceRoot ?? this.storagePath,
|
|
1992
|
+
providerSessionId: nativeSessionId,
|
|
1993
|
+
providerSessionHome: runtimeHome
|
|
1994
|
+
});
|
|
1995
|
+
else if (existing.providerSessionId !== nativeSessionId || existing.providerSessionHome !== runtimeHome) await store.update(input.sessionId, {
|
|
1996
|
+
providerSessionId: nativeSessionId,
|
|
1997
|
+
providerSessionHome: runtimeHome
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Forks the persisted session and copies its messages up to `messageId`.
|
|
2002
|
+
* The forked record carries no provider session id, so its first turn
|
|
2003
|
+
* starts a fresh CLI session and receives the copied transcript as
|
|
2004
|
+
* execution-only context. Callers that address turns by their own session
|
|
2005
|
+
* id (the sidecar) pass that id as `targetSessionId` so later turns find
|
|
2006
|
+
* the forked record.
|
|
2007
|
+
*/
|
|
2008
|
+
async forkSession(options) {
|
|
2009
|
+
this.ensureInitialized();
|
|
2010
|
+
const result = await forkSessionWithMessages(this.getSessionStore(), this.getMessageStore(), options.sessionId, {
|
|
2011
|
+
id: options.targetSessionId,
|
|
2012
|
+
messageId: options.messageId,
|
|
2013
|
+
workspaceRoot: options.workspaceRoot
|
|
2014
|
+
});
|
|
2015
|
+
this.logger.debug("Forked session", {
|
|
2016
|
+
parentId: options.sessionId,
|
|
2017
|
+
newId: result.session.id,
|
|
2018
|
+
messagesCopied: result.messagesCopied
|
|
2019
|
+
});
|
|
2020
|
+
return result.session.id;
|
|
2021
|
+
}
|
|
2022
|
+
async getMessages(options) {
|
|
2023
|
+
this.ensureInitialized();
|
|
2024
|
+
return (await this.getMessageStore().list(options.sessionId, {
|
|
2025
|
+
limit: options.limit,
|
|
2026
|
+
offset: options.offset
|
|
2027
|
+
})).map((msg) => this.toBackendMessage(msg));
|
|
2028
|
+
}
|
|
2029
|
+
toBackendMessage(msg) {
|
|
2030
|
+
return {
|
|
2031
|
+
id: msg.id,
|
|
2032
|
+
role: msg.role,
|
|
2033
|
+
parts: msg.parts,
|
|
2034
|
+
timestamp: msg.timestamp.toISOString(),
|
|
2035
|
+
metadata: msg.metadata
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
buildAssistantParts(state) {
|
|
2039
|
+
const parts = [];
|
|
2040
|
+
for (const reasoning of state.reasoningBuffer) parts.push({
|
|
2041
|
+
type: "reasoning",
|
|
2042
|
+
text: reasoning
|
|
2043
|
+
});
|
|
2044
|
+
if (state.textBuffer) parts.push({
|
|
2045
|
+
type: "text",
|
|
2046
|
+
text: state.textBuffer
|
|
2047
|
+
});
|
|
2048
|
+
for (const toolCall of state.toolCalls.values()) {
|
|
2049
|
+
const inputRecord = toolCall.input ?? {};
|
|
2050
|
+
let toolState;
|
|
2051
|
+
if (toolCall.status === "completed") toolState = {
|
|
2052
|
+
status: "completed",
|
|
2053
|
+
input: inputRecord,
|
|
2054
|
+
output: toolCall.output
|
|
2055
|
+
};
|
|
2056
|
+
else if (toolCall.status === "failed") toolState = {
|
|
2057
|
+
status: "failed",
|
|
2058
|
+
input: inputRecord,
|
|
2059
|
+
error: toolCall.output ? String(toolCall.output) : void 0
|
|
2060
|
+
};
|
|
2061
|
+
else if (toolCall.status === "running") toolState = {
|
|
2062
|
+
status: "running",
|
|
2063
|
+
input: inputRecord
|
|
2064
|
+
};
|
|
2065
|
+
else toolState = {
|
|
2066
|
+
status: "pending",
|
|
2067
|
+
input: inputRecord
|
|
2068
|
+
};
|
|
2069
|
+
parts.push({
|
|
2070
|
+
type: "tool",
|
|
2071
|
+
tool: toolCall.name,
|
|
2072
|
+
callID: toolCall.id,
|
|
2073
|
+
state: toolState
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
for (const file of state.fileParts ?? []) parts.push({
|
|
2077
|
+
type: "file",
|
|
2078
|
+
id: file.id,
|
|
2079
|
+
filename: file.filename,
|
|
2080
|
+
mediaType: file.mediaType,
|
|
2081
|
+
url: file.url
|
|
2082
|
+
});
|
|
2083
|
+
return parts;
|
|
2084
|
+
}
|
|
2085
|
+
extractToolInvocations(state) {
|
|
2086
|
+
const invocations = [];
|
|
2087
|
+
for (const toolCall of state.toolCalls.values()) invocations.push({
|
|
2088
|
+
toolName: toolCall.name,
|
|
2089
|
+
input: toolCall.input,
|
|
2090
|
+
result: toolCall.output,
|
|
2091
|
+
...toolCall.status === "failed" ? { isError: true } : {}
|
|
2092
|
+
});
|
|
2093
|
+
return invocations;
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Names of tools this provider substitutes for a model tool call it could
|
|
2097
|
+
* not parse into any real tool.
|
|
2098
|
+
*
|
|
2099
|
+
* Default: none. A provider that has one must say so, because these calls
|
|
2100
|
+
* do not present as failures. OpenCode's `invalid` reports the parse error
|
|
2101
|
+
* as its output and completes, so the repeated-failure guard reads a run of
|
|
2102
|
+
* them as progress and clears its count on every one.
|
|
2103
|
+
*/
|
|
2104
|
+
toolCallRepairToolNames() {
|
|
2105
|
+
return NO_REPAIR_TOOL_NAMES$1;
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Decide whether a non-zero exit should be treated as success.
|
|
2109
|
+
*
|
|
2110
|
+
* Default: no — any non-zero exit is a failure.
|
|
2111
|
+
*
|
|
2112
|
+
* Providers override this when their CLI has a known "exits non-zero
|
|
2113
|
+
* on a successful run" quirk (e.g. kimi writes its resume trailer to
|
|
2114
|
+
* stderr and exits non-zero even when the task completed cleanly).
|
|
2115
|
+
*
|
|
2116
|
+
* The decision is made AFTER the stream has drained and the exit
|
|
2117
|
+
* code is known, so `state` reflects the full set of events the
|
|
2118
|
+
* parser emitted (including any terminal `result` / `done` event).
|
|
2119
|
+
* A safe heuristic is: "non-zero exit + stderr matches known trailer
|
|
2120
|
+
* pattern + at least one assistant part was recorded".
|
|
2121
|
+
*/
|
|
2122
|
+
isBenignNonZeroExit(_params) {
|
|
2123
|
+
return false;
|
|
2124
|
+
}
|
|
2125
|
+
/** Observe settled process output before success/failure classification. */
|
|
2126
|
+
async afterCliProcessExit(_params) {}
|
|
2127
|
+
executionOutcome(_state, _input) {
|
|
2128
|
+
return { type: "completed" };
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Format a message for stdin input.
|
|
2132
|
+
*/
|
|
2133
|
+
formatStdinMessage(message) {
|
|
2134
|
+
return message;
|
|
2135
|
+
}
|
|
2136
|
+
};
|
|
2137
|
+
/**
|
|
2138
|
+
* Whether any file below `root` (relative path) matches one of `matchers`.
|
|
2139
|
+
* Bounded: native stores nest a few levels and hold one file per session.
|
|
2140
|
+
*/
|
|
2141
|
+
async function anyFileMatches(root, matchers) {
|
|
2142
|
+
const pending = [""];
|
|
2143
|
+
let visited = 0;
|
|
2144
|
+
while (pending.length > 0 && visited < 2e4) {
|
|
2145
|
+
const relative = pending.pop();
|
|
2146
|
+
if (relative === void 0) break;
|
|
2147
|
+
let entries;
|
|
2148
|
+
try {
|
|
2149
|
+
entries = await readdir(path.join(root, relative), { withFileTypes: true });
|
|
2150
|
+
} catch {
|
|
2151
|
+
continue;
|
|
2152
|
+
}
|
|
2153
|
+
for (const entry of entries) {
|
|
2154
|
+
visited++;
|
|
2155
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
2156
|
+
if (entry.isDirectory()) pending.push(child);
|
|
2157
|
+
else if (entry.isFile() && matchers.some((matcher) => matcher.test(child))) return true;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
return false;
|
|
2161
|
+
}
|
|
2162
|
+
/** Whether a per-session store holds a declared file for this id. */
|
|
2163
|
+
async function declaredSessionExists(contract, runtimeHome, nativeSessionId) {
|
|
2164
|
+
for (const store of contract.stores) {
|
|
2165
|
+
if (store.shared) return true;
|
|
2166
|
+
const matchers = store.files.map((glob) => compileSessionStoreGlob(glob, nativeSessionId));
|
|
2167
|
+
if (await anyFileMatches(path.join(runtimeHome, store.root), matchers)) return true;
|
|
2168
|
+
}
|
|
2169
|
+
return false;
|
|
2170
|
+
}
|
|
2171
|
+
//#endregion
|
|
2172
|
+
//#region src/cli-exit-diagnostics.ts
|
|
2173
|
+
/** Characters of stderr a failure message carries, taken from the tail. */
|
|
2174
|
+
const FAILURE_STDERR_TAIL_LIMIT = 600;
|
|
2175
|
+
/**
|
|
2176
|
+
* Attach the process-level diagnostics this run already collected to a failure
|
|
2177
|
+
* message.
|
|
2178
|
+
*
|
|
2179
|
+
* A provider that reports its OWN terminal failure — an empty event stream, an
|
|
2180
|
+
* unfinished model step — explains what it saw but not how the process ended,
|
|
2181
|
+
* and those are different defects: a CLI that exited 0 having emitted nothing
|
|
2182
|
+
* is misconfigured, one killed on the timeout is stuck, one that exited 9 with
|
|
2183
|
+
* stderr crashed. The exit status and the CLI's stderr are in hand at the
|
|
2184
|
+
* point the error is built; without this they reached only a log line and the
|
|
2185
|
+
* caller got a bare sentence to debug from.
|
|
2186
|
+
*
|
|
2187
|
+
* Each part is skipped when the message already states it, so a message
|
|
2188
|
+
* derived FROM stderr or from the exit code is never doubled.
|
|
2189
|
+
*/
|
|
2190
|
+
function withCliExitDiagnostics(message, exit, stderr) {
|
|
2191
|
+
const parts = [];
|
|
2192
|
+
if (exit.stdinError && !message.includes(exit.stdinError)) parts.push(`stdin failed: ${exit.stdinError}`);
|
|
2193
|
+
if (exit.timedOut) {
|
|
2194
|
+
const timeoutDetail = exit.timeoutDiagnostic;
|
|
2195
|
+
if (timeoutDetail) {
|
|
2196
|
+
if (!message.includes(timeoutDetail)) parts.push(timeoutDetail);
|
|
2197
|
+
} else if (!/timed out/i.test(message)) parts.push("timed out");
|
|
2198
|
+
}
|
|
2199
|
+
if (exit.code !== null) {
|
|
2200
|
+
if (!message.includes(`code ${exit.code}`)) parts.push(`exit code ${exit.code}`);
|
|
2201
|
+
} else if (exit.signal) parts.push(`killed by ${exit.signal}`);
|
|
2202
|
+
const trimmedStderr = stderr.trim();
|
|
2203
|
+
if (trimmedStderr && !message.includes(trimmedStderr)) parts.push(`stderr: ${trimmedStderr.length > FAILURE_STDERR_TAIL_LIMIT ? `…${trimmedStderr.slice(-FAILURE_STDERR_TAIL_LIMIT)}` : trimmedStderr}`);
|
|
2204
|
+
return parts.length === 0 ? message : `${message} (${parts.join("; ")})`;
|
|
2205
|
+
}
|
|
2206
|
+
//#endregion
|
|
2207
|
+
//#region src/mcp-commands.ts
|
|
2208
|
+
/** Execute one prepared MCP registration command. Serialization belongs to the adapter. */
|
|
2209
|
+
async function addCliMcp(options) {
|
|
2210
|
+
const { name, config, run, logger } = options;
|
|
2211
|
+
logger.debug("Adding MCP server", {
|
|
2212
|
+
name,
|
|
2213
|
+
type: config.type
|
|
2214
|
+
});
|
|
2215
|
+
const result = await run();
|
|
2216
|
+
if (!result.exit.success) throw new Error(`Failed to add MCP server "${name}": ${result.stderr || `exit code ${result.exit.code}`}`);
|
|
2217
|
+
logger.info("Added MCP server", { name });
|
|
2218
|
+
}
|
|
2219
|
+
/** List native MCP servers with the provider's parser and command budget. */
|
|
2220
|
+
async function listCliMcps(options) {
|
|
2221
|
+
const { provider, args, run, logger, parse } = options;
|
|
2222
|
+
if (!args) {
|
|
2223
|
+
logger.warn(`getMcpStatus: ${provider} CLI does not support listing MCP servers`);
|
|
2224
|
+
return { servers: {} };
|
|
2225
|
+
}
|
|
2226
|
+
const result = await run(args);
|
|
2227
|
+
if (!result.exit.success) throw new Error(withCliExitDiagnostics(`${provider} failed to list MCP servers`, result.exit, result.stderr));
|
|
2228
|
+
return parse(result.stdout ?? "");
|
|
2229
|
+
}
|
|
2230
|
+
const MCP_STATUSES = [
|
|
2231
|
+
"connected",
|
|
2232
|
+
"disconnected",
|
|
2233
|
+
"error",
|
|
2234
|
+
"unknown"
|
|
2235
|
+
];
|
|
2236
|
+
const MCP_TYPES = [
|
|
2237
|
+
"local",
|
|
2238
|
+
"remote",
|
|
2239
|
+
"stdio",
|
|
2240
|
+
"http"
|
|
2241
|
+
];
|
|
2242
|
+
function pick(known, value, fallback) {
|
|
2243
|
+
return known.includes(value) ? value : fallback;
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Read an `mcp list` report: a JSON array of `{name, status, type}`, a JSON
|
|
2247
|
+
* object keyed by name, or a whitespace table whose first column is the name
|
|
2248
|
+
* and whose next columns may hold the type and the status.
|
|
2249
|
+
*/
|
|
2250
|
+
function parseMcpListReport(output) {
|
|
2251
|
+
const servers = {};
|
|
2252
|
+
const add = (name, status, type) => {
|
|
2253
|
+
servers[name] = {
|
|
2254
|
+
name,
|
|
2255
|
+
status: pick(MCP_STATUSES, status, "unknown"),
|
|
2256
|
+
type: pick(MCP_TYPES, type, "stdio")
|
|
2257
|
+
};
|
|
2258
|
+
};
|
|
2259
|
+
let data;
|
|
2260
|
+
try {
|
|
2261
|
+
data = JSON.parse(output);
|
|
2262
|
+
} catch {
|
|
2263
|
+
for (const line of output.trim().split("\n")) {
|
|
2264
|
+
if (!line.trim() || line.includes("NAME") || line.startsWith("-")) continue;
|
|
2265
|
+
const [name = "", type, status] = line.trim().split(/\s{2,}/);
|
|
2266
|
+
add(name, status, type);
|
|
2267
|
+
}
|
|
2268
|
+
return { servers };
|
|
2269
|
+
}
|
|
2270
|
+
if (Array.isArray(data)) for (const server of data) add(server.name, server.status ?? "connected", server.type);
|
|
2271
|
+
else if (typeof data === "object" && data !== null) for (const [name, info] of Object.entries(data)) add(name, info.status, void 0);
|
|
2272
|
+
return { servers };
|
|
2273
|
+
}
|
|
2274
|
+
/** A local MCP server's argv: the `command` array, or `command` then `args`. */
|
|
2275
|
+
function localMcpArgv(config) {
|
|
2276
|
+
const local = config;
|
|
2277
|
+
return Array.isArray(local.command) ? local.command : [local.command, ...local.args ?? []];
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* A local MCP server's environment, from `environment` (agent-backend-types)
|
|
2281
|
+
* or `env` (agent-interface): reading one drops the other family's variables.
|
|
2282
|
+
*/
|
|
2283
|
+
function localMcpEnv(config) {
|
|
2284
|
+
return config.environment ?? config.env;
|
|
2285
|
+
}
|
|
2286
|
+
const isLocalMcpConfig = (c) => c.type === "local" || c.type === "stdio";
|
|
2287
|
+
/** Build MCP add command arguments. Override for CLI-specific syntax. */
|
|
2288
|
+
function defaultMcpAddArgs(name, config) {
|
|
2289
|
+
if (isLocalMcpConfig(config)) {
|
|
2290
|
+
const env = Object.entries(localMcpEnv(config) ?? {});
|
|
2291
|
+
return [
|
|
2292
|
+
"mcp",
|
|
2293
|
+
"add",
|
|
2294
|
+
name,
|
|
2295
|
+
"--",
|
|
2296
|
+
...localMcpArgv(config),
|
|
2297
|
+
...env.flatMap(([key, value]) => ["--env", `${key}=${value}`])
|
|
2298
|
+
];
|
|
2299
|
+
}
|
|
2300
|
+
const headers = Object.entries(config.headers ?? {});
|
|
2301
|
+
return [
|
|
2302
|
+
"mcp",
|
|
2303
|
+
"add",
|
|
2304
|
+
name,
|
|
2305
|
+
config.url,
|
|
2306
|
+
...headers.flatMap(([key, value]) => ["--header", `${key}: ${value}`])
|
|
2307
|
+
];
|
|
2308
|
+
}
|
|
2309
|
+
//#endregion
|
|
2310
|
+
//#region src/adapter-mcp.ts
|
|
2311
|
+
/** Owns serialized MCP mutations and provider-native MCP status commands. */
|
|
2312
|
+
var CliAdapterMcp = class extends CliAdapterSessions {
|
|
2313
|
+
/** Budget for one `mcp list` run, CLI startup and every server's handshake. */
|
|
2314
|
+
mcpStatusTimeoutMs = 1e4;
|
|
2315
|
+
mcpMutationChain = Promise.resolve();
|
|
2316
|
+
async addMcp(name, config) {
|
|
2317
|
+
const run = this.mcpMutationChain.then(() => this.addMcpSerialized(name, config));
|
|
2318
|
+
this.mcpMutationChain = run.catch(() => void 0);
|
|
2319
|
+
return run;
|
|
2320
|
+
}
|
|
2321
|
+
async addMcpSerialized(name, config) {
|
|
2322
|
+
this.ensureInitialized();
|
|
2323
|
+
const args = this.buildMcpAddArgs(name, config);
|
|
2324
|
+
return addCliMcp({
|
|
2325
|
+
name,
|
|
2326
|
+
config,
|
|
2327
|
+
logger: this.logger,
|
|
2328
|
+
run: () => this.runAuxiliaryCli(args, 3e4)
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
async getMcpStatus() {
|
|
2332
|
+
this.ensureInitialized();
|
|
2333
|
+
return listCliMcps({
|
|
2334
|
+
provider: this.provider,
|
|
2335
|
+
args: this.buildMcpListArgs(),
|
|
2336
|
+
logger: this.logger,
|
|
2337
|
+
run: (args) => this.runAuxiliaryCli(args, this.mcpStatusTimeoutMs),
|
|
2338
|
+
parse: (output) => this.parseMcpListOutput(output)
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
async ensureMcps(required) {
|
|
2342
|
+
const currentStatus = await this.getMcpStatus();
|
|
2343
|
+
const existingServers = new Set(Object.keys(currentStatus.servers));
|
|
2344
|
+
for (const [name, config] of Object.entries(required)) {
|
|
2345
|
+
if (existingServers.has(name)) {
|
|
2346
|
+
this.logger.debug("MCP server already exists, skipping", { name });
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2349
|
+
await this.addMcp(name, config);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
/** Build MCP add command arguments. Override for CLI-specific syntax. */
|
|
2353
|
+
buildMcpAddArgs(name, config) {
|
|
2354
|
+
return defaultMcpAddArgs(name, config);
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Build MCP list command arguments. Return null if not supported.
|
|
2358
|
+
*/
|
|
2359
|
+
buildMcpListArgs() {
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
/** Parse MCP list output. Override for CLI-specific output format. */
|
|
2363
|
+
parseMcpListOutput(output) {
|
|
2364
|
+
return parseMcpListReport(output);
|
|
2365
|
+
}
|
|
2366
|
+
};
|
|
2367
|
+
//#endregion
|
|
2368
|
+
//#region src/execution-state.ts
|
|
2369
|
+
/**
|
|
2370
|
+
* Creates a new execution state.
|
|
2371
|
+
*/
|
|
2372
|
+
function createExecutionState(sessionId, messageId, workspaceRoot) {
|
|
2373
|
+
return {
|
|
2374
|
+
sessionId,
|
|
2375
|
+
messageId: messageId ?? randomUUID(),
|
|
2376
|
+
workspaceRoot,
|
|
2377
|
+
toolCalls: /* @__PURE__ */ new Map(),
|
|
2378
|
+
textBuffer: "",
|
|
2379
|
+
reasoningBuffer: []
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
//#endregion
|
|
2383
|
+
//#region src/session-preparation.ts
|
|
2384
|
+
async function prepareExecutionSession(context, input) {
|
|
2385
|
+
let sessionId = input.sessionId;
|
|
2386
|
+
let providerSessionId;
|
|
2387
|
+
let transcriptReplay;
|
|
2388
|
+
if (sessionId) {
|
|
2389
|
+
const session = await context.getSessionStore().get(sessionId);
|
|
2390
|
+
if (session) {
|
|
2391
|
+
providerSessionId = await context.resumableProviderSessionId(session);
|
|
2392
|
+
if (session.parentId !== void 0 && session.providerSessionId === void 0) transcriptReplay = "fork";
|
|
2393
|
+
else if (context.config.secretProfileRevisionRoot && session.providerSessionId === void 0 && (await context.getMessageStore().list(sessionId)).length > 0) transcriptReplay = "restored";
|
|
2394
|
+
else if (session.providerSessionId && providerSessionId === void 0) {
|
|
2395
|
+
context.logger.info("Provider session data is not resumable; replaying durable history into a fresh provider session", {
|
|
2396
|
+
sessionId,
|
|
2397
|
+
providerSessionId: session.providerSessionId
|
|
2398
|
+
});
|
|
2399
|
+
transcriptReplay = "restored";
|
|
2400
|
+
}
|
|
2401
|
+
await context.getSessionStore().update(sessionId, {});
|
|
2402
|
+
} else {
|
|
2403
|
+
if (context.config.secretProfileRevisionRoot) try {
|
|
2404
|
+
transcriptReplay = (await context.getMessageStore().list(sessionId, {
|
|
2405
|
+
requireComplete: true,
|
|
2406
|
+
allowAbsent: true
|
|
2407
|
+
})).length > 0 ? "restored" : void 0;
|
|
2408
|
+
} catch (error) {
|
|
2409
|
+
throw new SessionRehydrationError(`Session ${sessionId} has incomplete persisted history`, error);
|
|
2410
|
+
}
|
|
2411
|
+
else transcriptReplay = "restored";
|
|
2412
|
+
await context.getSessionStore().create({
|
|
2413
|
+
id: sessionId,
|
|
2414
|
+
workspaceRoot: input.workspaceRoot ?? context.storagePath
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
} else sessionId = await context.createSession({ workspaceRoot: input.workspaceRoot ?? context.storagePath });
|
|
2418
|
+
return {
|
|
2419
|
+
sessionId,
|
|
2420
|
+
providerSessionId,
|
|
2421
|
+
transcriptReplay
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
//#endregion
|
|
2425
|
+
//#region src/turn-execution.ts
|
|
2426
|
+
/**
|
|
2427
|
+
* Turn idempotency: if the caller supplied a turnId and a previously-
|
|
2428
|
+
* completed turn with that id exists, return its cached result without
|
|
2429
|
+
* re-issuing the upstream LLM call. Both `findCompletedTurn` and
|
|
2430
|
+
* `setTurnId` are optional on the recorder interface so providers using
|
|
2431
|
+
* a no-op recorder (sdk-backend) still typecheck.
|
|
2432
|
+
*/
|
|
2433
|
+
async function findCachedTurn(recorder, input, logger, sessionId) {
|
|
2434
|
+
if (!(input.turnId && recorder.findCompletedTurn)) return void 0;
|
|
2435
|
+
const cached = await recorder.findCompletedTurn(input.turnId);
|
|
2436
|
+
if (!cached) return void 0;
|
|
2437
|
+
logger.info("Returning cached turn result (idempotent retry)", {
|
|
2438
|
+
turnId: input.turnId,
|
|
2439
|
+
sessionId
|
|
2440
|
+
});
|
|
2441
|
+
const outcome = cached.outcome === void 0 ? { type: "completed" } : AgentExecutionOutcomeSchema.parse(cached.outcome);
|
|
2442
|
+
return {
|
|
2443
|
+
...cached,
|
|
2444
|
+
outcome
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2448
|
+
* The persisted transcript, read before this turn's user message is appended
|
|
2449
|
+
* so the block never contains the current message. Execution-only: it reaches
|
|
2450
|
+
* the model prompt and stays out of both stores.
|
|
2451
|
+
*/
|
|
2452
|
+
async function buildTranscriptContext(params) {
|
|
2453
|
+
const { provider, sessionId, transcriptReplay, secretProfileRevisionRoot } = params;
|
|
2454
|
+
if (!transcriptReplay) return void 0;
|
|
2455
|
+
try {
|
|
2456
|
+
return (secretProfileRevisionRoot ? buildCompleteForkContextBlock : buildForkContextBlock)(await params.getMessageStore().list(sessionId, { requireComplete: Boolean(secretProfileRevisionRoot) }), transcriptReplay === "restored" ? {
|
|
2457
|
+
preamble: RESTORED_SESSION_CONTEXT_PREAMBLE,
|
|
2458
|
+
tag: RESTORED_SESSION_CONTEXT_TAG,
|
|
2459
|
+
...secretProfileRevisionRoot ? { maxChars: MAX_SECRET_PROFILE_REPLAY_CHARS } : {}
|
|
2460
|
+
} : secretProfileRevisionRoot ? { maxChars: MAX_SECRET_PROFILE_REPLAY_CHARS } : void 0);
|
|
2461
|
+
} catch (error) {
|
|
2462
|
+
throw new SessionRehydrationError(`Session ${sessionId} has persisted history that could not be read for replay into a fresh ${provider} session: ${error instanceof Error ? error.message : String(error)}`, error);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* A harness that can only report its id in an event must do so promptly; a
|
|
2467
|
+
* turn whose native session cannot be named is not traceable. Returns the
|
|
2468
|
+
* deadline timer, or undefined when the contract sets none.
|
|
2469
|
+
*/
|
|
2470
|
+
function armNativeSessionDeadline(params) {
|
|
2471
|
+
const { provider, sessionId, sessionContract, state, observer, logger } = params;
|
|
2472
|
+
const identity = sessionContract?.identity;
|
|
2473
|
+
if (identity?.kind !== "first-event" || !sessionContractFullyEnforced(provider)) return;
|
|
2474
|
+
const deadline = setTimeout(() => {
|
|
2475
|
+
if (observer.nativeSessionObserved || state.terminalStatus !== void 0) return;
|
|
2476
|
+
const detail = new HarnessSessionContractError("native_session_id_unobserved", `${provider} reported no native session id within ${identity.deadlineMs} ms`).message;
|
|
2477
|
+
state.terminalStatus = "failed";
|
|
2478
|
+
state.lastDiagnostic = detail;
|
|
2479
|
+
logger.error("Native session id unobserved; stopping turn", { sessionId });
|
|
2480
|
+
params.kill();
|
|
2481
|
+
}, identity.deadlineMs);
|
|
2482
|
+
deadline.unref();
|
|
2483
|
+
return deadline;
|
|
2484
|
+
}
|
|
2485
|
+
/** Accumulate a turn process's stderr as text. */
|
|
2486
|
+
function collectStderr(cliProcess) {
|
|
2487
|
+
let text = "";
|
|
2488
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
2489
|
+
cliProcess.stderr?.on("data", (chunk) => {
|
|
2490
|
+
text += stderrDecoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2491
|
+
});
|
|
2492
|
+
cliProcess.stderr?.on("end", () => {
|
|
2493
|
+
text += stderrDecoder.end();
|
|
2494
|
+
});
|
|
2495
|
+
return { get text() {
|
|
2496
|
+
return text;
|
|
2497
|
+
} };
|
|
2498
|
+
}
|
|
2499
|
+
/** The message a failed turn reports, from the strongest available cause. */
|
|
2500
|
+
function describeCliFailure(params) {
|
|
2501
|
+
const { provider, state, exitResult, stderr, command, nixBinPath } = params;
|
|
2502
|
+
const spawnFailure = exitResult.spawnError ? `Failed to spawn "${command}": ${exitResult.spawnError}` + (exitResult.spawnError.includes("ENOENT") ? ` — binary not found in PATH${nixBinPath ? ` (NIX_BIN_PATH=${nixBinPath})` : " (NIX_BIN_PATH unset — is the Nix profile mounted?)"}` : "") : null;
|
|
2503
|
+
const failureCause = state.failure?.message ?? state.lastDiagnostic ?? (exitResult.timedOut ? exitResult.timeoutDiagnostic ?? "Execution timed out" : void 0) ?? (stderr.trim() || (state.terminalStatus === "failed" ? `${provider} reported a failed terminal status` : `Process exited with code ${exitResult.code}`));
|
|
2504
|
+
return spawnFailure ?? withCliExitDiagnostics(failureCause, exitResult, stderr);
|
|
2505
|
+
}
|
|
2506
|
+
/** The error a failed turn throws, carrying the stream's failure details. */
|
|
2507
|
+
function buildTurnFailure(params) {
|
|
2508
|
+
const { provider, errorMessage, state, startTime, endTime } = params;
|
|
2509
|
+
const failure = new AgentExecutionError(`${provider} execution failed: ${errorMessage}`, {
|
|
2510
|
+
...state.tokenUsage ? { tokenUsage: state.tokenUsage } : {},
|
|
2511
|
+
timing: {
|
|
2512
|
+
startedAt: startTime,
|
|
2513
|
+
completedAt: endTime,
|
|
2514
|
+
durationMs: endTime - startTime
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
if (state.failure) Object.assign(failure, {
|
|
2518
|
+
...state.failure.code ? { code: state.failure.code } : {},
|
|
2519
|
+
...state.failure.statusCode === void 0 ? {} : {
|
|
2520
|
+
statusCode: state.failure.statusCode,
|
|
2521
|
+
status: state.failure.statusCode
|
|
2522
|
+
}
|
|
2523
|
+
});
|
|
2524
|
+
return failure;
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Best-effort: stamp the assistant message interrupted so reattach can
|
|
2528
|
+
* surface "this turn died" without inferring from absence. Swallow any
|
|
2529
|
+
* metadata-write error — the upstream failure is the one the caller
|
|
2530
|
+
* needs to see. The coalesced part updates drain first so no stale
|
|
2531
|
+
* snapshot lands after the terminal stamp.
|
|
2532
|
+
*/
|
|
2533
|
+
async function recordTurnInterruption(params) {
|
|
2534
|
+
const { recorder, observer, logger, errorMessage, exitResult } = params;
|
|
2535
|
+
await observer.flushPartUpdates();
|
|
2536
|
+
if (recorder.markTurnInterrupted) try {
|
|
2537
|
+
await recorder.markTurnInterrupted({
|
|
2538
|
+
reason: errorMessage,
|
|
2539
|
+
exitCode: exitResult.code,
|
|
2540
|
+
timedOut: exitResult.timedOut,
|
|
2541
|
+
failureReceipt: params.failure.receipt
|
|
2542
|
+
});
|
|
2543
|
+
} catch (markError) {
|
|
2544
|
+
logger.warn("markTurnInterrupted failed (suppressed to preserve original error)", { error: markError instanceof Error ? markError.message : String(markError) });
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
/** The result of a turn that completed. */
|
|
2548
|
+
function buildExecutionResult(params) {
|
|
2549
|
+
const { state, publicProfileRevision, startTime, endTime } = params;
|
|
2550
|
+
return {
|
|
2551
|
+
outcome: params.outcome,
|
|
2552
|
+
text: state.textBuffer,
|
|
2553
|
+
toolInvocations: params.toolInvocations,
|
|
2554
|
+
reasoning: state.reasoningBuffer.length > 0 ? state.reasoningBuffer : void 0,
|
|
2555
|
+
sessionId: params.sessionId,
|
|
2556
|
+
...publicProfileRevision ? { metadata: { publicProfileRevision } } : {},
|
|
2557
|
+
tokenUsage: state.tokenUsage ? {
|
|
2558
|
+
inputTokens: state.tokenUsage.inputTokens ?? 0,
|
|
2559
|
+
outputTokens: state.tokenUsage.outputTokens ?? 0,
|
|
2560
|
+
totalTokens: state.tokenUsage.totalTokens,
|
|
2561
|
+
reasoningTokens: state.tokenUsage.reasoningTokens,
|
|
2562
|
+
cacheReadInputTokens: state.tokenUsage.cacheReadInputTokens,
|
|
2563
|
+
cacheCreationInputTokens: state.tokenUsage.cacheCreationInputTokens,
|
|
2564
|
+
cost: state.tokenUsage.cost
|
|
2565
|
+
} : void 0,
|
|
2566
|
+
timing: {
|
|
2567
|
+
startedAt: startTime,
|
|
2568
|
+
completedAt: endTime,
|
|
2569
|
+
durationMs: endTime - startTime
|
|
2570
|
+
}
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
/** A turn whose provider session record failed to persist ends failed. */
|
|
2574
|
+
function failOnProviderSessionPersistence(observer, state) {
|
|
2575
|
+
if (!observer.providerSessionPersistenceError) return;
|
|
2576
|
+
const message = observer.providerSessionPersistenceError instanceof Error ? observer.providerSessionPersistenceError.message : String(observer.providerSessionPersistenceError);
|
|
2577
|
+
state.terminalStatus = "failed";
|
|
2578
|
+
state.lastDiagnostic = `Provider session persistence failed: ${message}`;
|
|
2579
|
+
}
|
|
2580
|
+
/** Log a non-zero exit the provider accepted, and the stderr a successful turn left. */
|
|
2581
|
+
function logAcceptedCliExit(logger, exitResult, stderr) {
|
|
2582
|
+
if (!exitResult.success) logger.info("CLI process exited non-zero but was treated as success by provider", {
|
|
2583
|
+
exitCode: exitResult.code,
|
|
2584
|
+
stderr: stderr.slice(0, 500)
|
|
2585
|
+
});
|
|
2586
|
+
if (stderr.trim()) logger.debug("CLI process stderr (success)", { stderr: stderr.slice(0, 1e3) });
|
|
2587
|
+
}
|
|
2588
|
+
//#endregion
|
|
2589
|
+
//#region src/part-update-queue.ts
|
|
2590
|
+
const MAX_PENDING_PART_UPDATES = 256;
|
|
2591
|
+
/**
|
|
2592
|
+
* Per-chunk persistence backpressure. Accumulated-text providers re-emit
|
|
2593
|
+
* the whole part on every token, and each update joins the recorder's
|
|
2594
|
+
* serialized write chain — if disk latency exceeds the token rate, the
|
|
2595
|
+
* chain grows for the turn's lifetime. Coalescing keeps only the newest
|
|
2596
|
+
* pending snapshot per partId (a stale one is a strict prefix of it), so
|
|
2597
|
+
* the queue stays O(distinct parts); a single drain loop preserves write
|
|
2598
|
+
* order. The terminal paths flush the drain before their final writes so
|
|
2599
|
+
* no stale snapshot can land after the consolidated parts.
|
|
2600
|
+
*/
|
|
2601
|
+
var PartUpdateQueue = class {
|
|
2602
|
+
pending = /* @__PURE__ */ new Map();
|
|
2603
|
+
drain = null;
|
|
2604
|
+
constructor(recorder, logger) {
|
|
2605
|
+
this.recorder = recorder;
|
|
2606
|
+
this.logger = logger;
|
|
2607
|
+
}
|
|
2608
|
+
schedule(part, delta) {
|
|
2609
|
+
const partId = part.id;
|
|
2610
|
+
if (!partId) {
|
|
2611
|
+
this.recorder.recordAssistantPartUpdate?.(part, delta)?.catch((cause) => {
|
|
2612
|
+
this.logger.warn("Per-chunk persistence failed (continuing stream)", { error: cause instanceof Error ? cause.message : String(cause) });
|
|
2613
|
+
});
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
this.pending.set(partId, {
|
|
2617
|
+
part,
|
|
2618
|
+
delta
|
|
2619
|
+
});
|
|
2620
|
+
if (this.pending.size > MAX_PENDING_PART_UPDATES) {
|
|
2621
|
+
const oldestPending = this.pending.keys().next();
|
|
2622
|
+
if (!oldestPending.done) this.pending.delete(oldestPending.value);
|
|
2623
|
+
this.logger.warn("Part-update persist backlog overflow, dropped oldest pending snapshot", { pendingLimit: MAX_PENDING_PART_UPDATES });
|
|
2624
|
+
}
|
|
2625
|
+
if (!this.drain) this.drain = this.drainPending().finally(() => {
|
|
2626
|
+
this.drain = null;
|
|
2627
|
+
});
|
|
2628
|
+
}
|
|
2629
|
+
async flush() {
|
|
2630
|
+
while (this.drain) await this.drain;
|
|
2631
|
+
}
|
|
2632
|
+
async drainPending() {
|
|
2633
|
+
for (;;) {
|
|
2634
|
+
const oldest = this.pending.keys().next();
|
|
2635
|
+
if (oldest.done) break;
|
|
2636
|
+
const update = this.pending.get(oldest.value);
|
|
2637
|
+
this.pending.delete(oldest.value);
|
|
2638
|
+
if (!update) continue;
|
|
2639
|
+
try {
|
|
2640
|
+
await this.recorder.recordAssistantPartUpdate?.(update.part, update.delta);
|
|
2641
|
+
} catch (err) {
|
|
2642
|
+
this.logger.warn("Per-chunk persistence failed (continuing stream)", { error: err instanceof Error ? err.message : String(err) });
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
};
|
|
2647
|
+
//#endregion
|
|
2648
|
+
//#region src/repeated-tool-failure.ts
|
|
2649
|
+
const NO_REPAIR_TOOL_NAMES = /* @__PURE__ */ new Set();
|
|
2650
|
+
/** A part in one of these states has not resolved, so it decides nothing. */
|
|
2651
|
+
function isUnresolved(state) {
|
|
2652
|
+
return state.status === "pending" || state.status === "running";
|
|
2653
|
+
}
|
|
2654
|
+
function readString(input, key) {
|
|
2655
|
+
const value = input[key];
|
|
2656
|
+
return typeof value === "string" && value.trim() !== "" ? value : void 0;
|
|
2657
|
+
}
|
|
2658
|
+
/** The tool's own error text, for the states that carry one. */
|
|
2659
|
+
function readStateError(state) {
|
|
2660
|
+
if (state.status !== "error" && state.status !== "failed") return void 0;
|
|
2661
|
+
return state.error?.trim() ? state.error : void 0;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Describe a resolved tool part as a failure, or return `undefined` when it
|
|
2665
|
+
* produced something.
|
|
2666
|
+
*
|
|
2667
|
+
* A repair tool reports the call it stood in for through its input, so the
|
|
2668
|
+
* trip names `write` rather than the substitute. That is the tool the reader
|
|
2669
|
+
* has to go and look at.
|
|
2670
|
+
*/
|
|
2671
|
+
function describeFailure(part, repairToolNames) {
|
|
2672
|
+
const { state } = part;
|
|
2673
|
+
if (isUnresolved(state)) return void 0;
|
|
2674
|
+
if (repairToolNames.has(part.tool)) return {
|
|
2675
|
+
tool: readString(state.input, "tool") ?? part.tool,
|
|
2676
|
+
detail: readString(state.input, "error") ?? readStateError(state) ?? `${part.tool} reported a tool call it could not parse`
|
|
2677
|
+
};
|
|
2678
|
+
if (state.status !== "error" && state.status !== "failed") return void 0;
|
|
2679
|
+
return {
|
|
2680
|
+
tool: part.tool,
|
|
2681
|
+
detail: readStateError(state) ?? `${part.tool} failed without reporting a reason`
|
|
2682
|
+
};
|
|
2683
|
+
}
|
|
2684
|
+
function createRepeatedToolFailureGuard(options) {
|
|
2685
|
+
const { limit } = options;
|
|
2686
|
+
if (!Number.isInteger(limit) || limit < 1) throw new Error(`Repeated tool-failure limit must be a positive whole number (got ${limit})`);
|
|
2687
|
+
const repairToolNames = options.repairToolNames ?? NO_REPAIR_TOOL_NAMES;
|
|
2688
|
+
let signature;
|
|
2689
|
+
let previousInput;
|
|
2690
|
+
let count = 0;
|
|
2691
|
+
let lastScoredPartId;
|
|
2692
|
+
let tripped = false;
|
|
2693
|
+
return { observe(part) {
|
|
2694
|
+
if (tripped) return void 0;
|
|
2695
|
+
if (isUnresolved(part.state)) return void 0;
|
|
2696
|
+
if (part.id === lastScoredPartId) return void 0;
|
|
2697
|
+
lastScoredPartId = part.id;
|
|
2698
|
+
const failure = describeFailure(part, repairToolNames);
|
|
2699
|
+
if (!failure) {
|
|
2700
|
+
signature = void 0;
|
|
2701
|
+
previousInput = void 0;
|
|
2702
|
+
count = 0;
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
const next = JSON.stringify([failure.tool, failure.detail]);
|
|
2706
|
+
count = next === signature && isDeepStrictEqual(part.state.input, previousInput) ? count + 1 : 1;
|
|
2707
|
+
previousInput = structuredClone(part.state.input);
|
|
2708
|
+
signature = next;
|
|
2709
|
+
if (count < limit) return void 0;
|
|
2710
|
+
tripped = true;
|
|
2711
|
+
return {
|
|
2712
|
+
...failure,
|
|
2713
|
+
count
|
|
2714
|
+
};
|
|
2715
|
+
} };
|
|
2716
|
+
}
|
|
2717
|
+
//#endregion
|
|
2718
|
+
//#region src/turn-liveness.ts
|
|
2719
|
+
/**
|
|
2720
|
+
* How long the provider stream stays quiet before the adapter emits a liveness
|
|
2721
|
+
* event, and how often it re-checks that quiet window.
|
|
2722
|
+
*
|
|
2723
|
+
* A model that reasons for a minute and a run that died look the same on the
|
|
2724
|
+
* wire: an open stream with nothing on it. A consumer that must show progress
|
|
2725
|
+
* resolves the ambiguity with an inactivity timeout. The Blueprint Agent
|
|
2726
|
+
* frontend allows 20 s (blueprint-agent
|
|
2727
|
+
* apps/web/src/ui-provider/devcontainer-events/hooks/useStreamWatchdog.ts).
|
|
2728
|
+
* It then aborts a turn that the CLI is still running.
|
|
2729
|
+
*
|
|
2730
|
+
* A ping on quiet feeds that timeout without inventing content. The check
|
|
2731
|
+
* interval bounds the longest silence at quiet plus check, so these values put
|
|
2732
|
+
* the worst case at 6 s. A provider that emits more often than the quiet
|
|
2733
|
+
* window never trips the ticker, so nothing double-emits.
|
|
2734
|
+
*/
|
|
2735
|
+
const STREAM_LIVENESS_QUIET_MS = 5e3;
|
|
2736
|
+
const STREAM_LIVENESS_CHECK_MS = 1e3;
|
|
2737
|
+
/**
|
|
2738
|
+
* How long the adapter vouches for an unbroken silence.
|
|
2739
|
+
*
|
|
2740
|
+
* A ping says the CLI process runs. It cannot say the model makes progress.
|
|
2741
|
+
* An unbounded ping stream would hide a CLI that starts and then hangs.
|
|
2742
|
+
*
|
|
2743
|
+
* No model in use here emits nothing for ten minutes. Past that the ticker
|
|
2744
|
+
* stops so the consumer can observe the silence. A later provider event
|
|
2745
|
+
* restarts the window.
|
|
2746
|
+
*/
|
|
2747
|
+
const STREAM_LIVENESS_MAX_QUIET_MS = 6e5;
|
|
2748
|
+
/**
|
|
2749
|
+
* The clocks and running-tool bookkeeping behind the stream liveness ping.
|
|
2750
|
+
*
|
|
2751
|
+
* `lastProviderEventAt` moves only on a real provider event, and it bounds
|
|
2752
|
+
* how long one silence can run. `lastLivenessAt` also moves on each ping, and
|
|
2753
|
+
* it paces the pings.
|
|
2754
|
+
*/
|
|
2755
|
+
var TurnLiveness = class {
|
|
2756
|
+
lastProviderEventAt = Date.now();
|
|
2757
|
+
lastLivenessAt = this.lastProviderEventAt;
|
|
2758
|
+
runningToolParts = /* @__PURE__ */ new Map();
|
|
2759
|
+
constructor(state, logger, onEvent) {
|
|
2760
|
+
this.state = state;
|
|
2761
|
+
this.logger = logger;
|
|
2762
|
+
this.onEvent = onEvent;
|
|
2763
|
+
}
|
|
2764
|
+
/** A real provider event arrived: reset both clocks. */
|
|
2765
|
+
noteProviderEvent() {
|
|
2766
|
+
this.lastProviderEventAt = Date.now();
|
|
2767
|
+
this.lastLivenessAt = this.lastProviderEventAt;
|
|
2768
|
+
}
|
|
2769
|
+
/** Track a tool part by its reported state; call after `noteProviderEvent`. */
|
|
2770
|
+
noteToolPart(id, tool, status) {
|
|
2771
|
+
if (status === "running") {
|
|
2772
|
+
if (!this.runningToolParts.has(id)) this.runningToolParts.set(id, {
|
|
2773
|
+
toolName: tool,
|
|
2774
|
+
startedAt: this.lastProviderEventAt
|
|
2775
|
+
});
|
|
2776
|
+
} else this.runningToolParts.delete(id);
|
|
2777
|
+
}
|
|
2778
|
+
start(executionSignal, startTime) {
|
|
2779
|
+
const livenessTicker = setInterval(() => {
|
|
2780
|
+
const now = Date.now();
|
|
2781
|
+
if (executionSignal.aborted || this.state.terminalStatus !== void 0 || now - this.lastLivenessAt < STREAM_LIVENESS_QUIET_MS || now - this.lastProviderEventAt > STREAM_LIVENESS_MAX_QUIET_MS) return;
|
|
2782
|
+
this.lastLivenessAt = now;
|
|
2783
|
+
const oldestRunningTool = this.runningToolParts.entries().next();
|
|
2784
|
+
const ping = oldestRunningTool.done ? {
|
|
2785
|
+
type: "model-processing",
|
|
2786
|
+
phase: "thinking",
|
|
2787
|
+
elapsedMs: now - startTime
|
|
2788
|
+
} : {
|
|
2789
|
+
type: "tool-heartbeat",
|
|
2790
|
+
toolName: oldestRunningTool.value[1].toolName,
|
|
2791
|
+
partId: oldestRunningTool.value[0],
|
|
2792
|
+
elapsedMs: now - oldestRunningTool.value[1].startedAt
|
|
2793
|
+
};
|
|
2794
|
+
try {
|
|
2795
|
+
this.onEvent?.(ping);
|
|
2796
|
+
} catch (error) {
|
|
2797
|
+
this.logger.warn("Stream liveness ping failed (continuing turn)", { error: error instanceof Error ? error.message : String(error) });
|
|
2798
|
+
}
|
|
2799
|
+
}, STREAM_LIVENESS_CHECK_MS);
|
|
2800
|
+
livenessTicker.unref();
|
|
2801
|
+
return livenessTicker;
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
//#endregion
|
|
2805
|
+
//#region src/turn-observer.ts
|
|
2806
|
+
/**
|
|
2807
|
+
* How many times a tool call may be rejected identically in a row before the
|
|
2808
|
+
* adapter kills the turn.
|
|
2809
|
+
*
|
|
2810
|
+
* Three separates a model that recovers from one that cannot. The first
|
|
2811
|
+
* attempt is the mistake. The second is the model reading the error. A third
|
|
2812
|
+
* identical attempt says the error does not reach it. Against a staging run
|
|
2813
|
+
* that made 76 attempts, this cuts the turn on the third.
|
|
2814
|
+
*/
|
|
2815
|
+
const REPEATED_TOOL_FAILURE_LIMIT = 3;
|
|
2816
|
+
/**
|
|
2817
|
+
* Observes one turn's provider stream: keeps the execution state current,
|
|
2818
|
+
* persists parts and the provider session id as they arrive, stops a turn
|
|
2819
|
+
* that repeats a rejected tool call, and pings while the stream is quiet.
|
|
2820
|
+
*/
|
|
2821
|
+
var TurnObserver = class {
|
|
2822
|
+
providerSessionPersistence = Promise.resolve();
|
|
2823
|
+
providerSessionPersistenceError;
|
|
2824
|
+
repeatedToolFailureTripped = false;
|
|
2825
|
+
nativeSessionObserved = false;
|
|
2826
|
+
partUpdates;
|
|
2827
|
+
liveness;
|
|
2828
|
+
repeatedToolFailureGuard;
|
|
2829
|
+
killForRepeatedToolFailure;
|
|
2830
|
+
constructor(options) {
|
|
2831
|
+
this.options = options;
|
|
2832
|
+
const { state, logger, recorder, repairToolNames, onEvent } = options;
|
|
2833
|
+
this.partUpdates = new PartUpdateQueue(recorder, logger);
|
|
2834
|
+
this.liveness = new TurnLiveness(state, logger, onEvent);
|
|
2835
|
+
this.repeatedToolFailureGuard = createRepeatedToolFailureGuard({
|
|
2836
|
+
limit: REPEATED_TOOL_FAILURE_LIMIT,
|
|
2837
|
+
repairToolNames
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
flushPartUpdates() {
|
|
2841
|
+
return this.partUpdates.flush();
|
|
2842
|
+
}
|
|
2843
|
+
attachProcess(process) {
|
|
2844
|
+
this.killForRepeatedToolFailure = () => process.kill();
|
|
2845
|
+
}
|
|
2846
|
+
startLiveness(executionSignal, startTime) {
|
|
2847
|
+
return this.liveness.start(executionSignal, startTime);
|
|
2848
|
+
}
|
|
2849
|
+
onEvent = (event) => {
|
|
2850
|
+
const { provider, state, recorder } = this.options;
|
|
2851
|
+
this.liveness.noteProviderEvent();
|
|
2852
|
+
if (event.type === "message.part.updated" && event.part.type === "tool") this.observeToolPart(event.part);
|
|
2853
|
+
const providerSessionId = event.type === "native.session.observed" && event.provider === provider ? event.nativeSessionId : event.type === "session.updated" ? event.sessionId : void 0;
|
|
2854
|
+
if (event.type === "native.session.observed" && event.provider === provider) this.nativeSessionObserved = true;
|
|
2855
|
+
if (providerSessionId) {
|
|
2856
|
+
state.sessionId = providerSessionId;
|
|
2857
|
+
this.persistProviderSession(providerSessionId);
|
|
2858
|
+
}
|
|
2859
|
+
if (event.type === "warning") state.lastDiagnostic = event.message;
|
|
2860
|
+
if (event.type === "status" && (event.status === "completed" || event.status === "failed")) {
|
|
2861
|
+
state.terminalStatus = event.status;
|
|
2862
|
+
if (event.status === "failed" && event.detail) state.lastDiagnostic = event.detail;
|
|
2863
|
+
}
|
|
2864
|
+
if (event.type === "message.part.updated" && recorder.recordAssistantPartUpdate) this.partUpdates.schedule(event.part, event.delta);
|
|
2865
|
+
this.options.onEvent?.(event);
|
|
2866
|
+
};
|
|
2867
|
+
observeToolPart(part) {
|
|
2868
|
+
const { provider, state, logger, sessionId } = this.options;
|
|
2869
|
+
const { id, tool, state: toolState } = part;
|
|
2870
|
+
this.liveness.noteToolPart(id, tool, toolState.status);
|
|
2871
|
+
const repeated = this.repeatedToolFailureGuard.observe({
|
|
2872
|
+
id,
|
|
2873
|
+
tool,
|
|
2874
|
+
state: toolState
|
|
2875
|
+
});
|
|
2876
|
+
if (repeated) {
|
|
2877
|
+
const detail = `${provider} sent the same ${repeated.tool} call ${repeated.count} times and every one was rejected. The turn stopped before it ran the model again: ${repeated.detail}`;
|
|
2878
|
+
this.repeatedToolFailureTripped = true;
|
|
2879
|
+
state.terminalStatus = "failed";
|
|
2880
|
+
state.lastDiagnostic = detail;
|
|
2881
|
+
logger.error("Repeated tool-call rejection stopped the turn", {
|
|
2882
|
+
tool: repeated.tool,
|
|
2883
|
+
attempts: repeated.count,
|
|
2884
|
+
reason: repeated.detail,
|
|
2885
|
+
sessionId
|
|
2886
|
+
});
|
|
2887
|
+
this.killForRepeatedToolFailure?.();
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
persistProviderSession(providerSessionId) {
|
|
2891
|
+
const { getRuntimeHome, getSessionStore, sessionId } = this.options;
|
|
2892
|
+
this.providerSessionPersistence = this.providerSessionPersistence.then(async () => {
|
|
2893
|
+
const runtimeHome = getRuntimeHome();
|
|
2894
|
+
if (!await getSessionStore().update(sessionId, {
|
|
2895
|
+
providerSessionId,
|
|
2896
|
+
...runtimeHome?.trim() ? { providerSessionHome: runtimeHome.trim() } : {}
|
|
2897
|
+
})) throw new Error(`Session ${sessionId} disappeared before its provider session could be persisted`);
|
|
2898
|
+
}).catch((error) => {
|
|
2899
|
+
this.providerSessionPersistenceError ??= error;
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
//#endregion
|
|
2904
|
+
//#region src/turn-process.ts
|
|
2905
|
+
/** How one turn's argv runs: cwd, environment, identity or launcher, stdio. */
|
|
2906
|
+
function cliSpawnConfig(params) {
|
|
2907
|
+
const { execArgs, launcher, subprocessUser } = params;
|
|
2908
|
+
const stdio = !execArgs.useStdin ? { stdio: [
|
|
2909
|
+
"ignore",
|
|
2910
|
+
"pipe",
|
|
2911
|
+
"pipe"
|
|
2912
|
+
] } : {};
|
|
2913
|
+
return {
|
|
2914
|
+
command: execArgs.command,
|
|
2915
|
+
args: execArgs.args,
|
|
2916
|
+
cwd: execArgs.cwd ?? params.workspaceRoot,
|
|
2917
|
+
env: params.env,
|
|
2918
|
+
inheritEnv: params.inheritEnv,
|
|
2919
|
+
...params.ownProcessGroup ? { ownProcessGroup: true } : {},
|
|
2920
|
+
...launcher ? {
|
|
2921
|
+
launcher,
|
|
2922
|
+
spawnOptions: stdio
|
|
2923
|
+
} : {
|
|
2924
|
+
...subprocessUser ? { isolatedIdentity: subprocessUser } : {},
|
|
2925
|
+
spawnOptions: {
|
|
2926
|
+
...params.subprocessSpawnOptions,
|
|
2927
|
+
...stdio
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
2932
|
+
/** A started CLI as the turn drives it, timing its spawn and first output. */
|
|
2933
|
+
function cliTurnProcess(child, logger, spawnStarted) {
|
|
2934
|
+
if (logger.observePhase) {
|
|
2935
|
+
child.process.once("spawn", () => {
|
|
2936
|
+
logger.observePhase?.("cli_spawn", performance.now() - spawnStarted);
|
|
2937
|
+
});
|
|
2938
|
+
child.process.stdout?.once("data", () => {
|
|
2939
|
+
logger.observePhase?.("cli_boot", performance.now() - spawnStarted);
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
return {
|
|
2943
|
+
pid: child.pid,
|
|
2944
|
+
stderr: child.process.stderr ?? void 0,
|
|
2945
|
+
write: child.write,
|
|
2946
|
+
closeInput: child.closeInput,
|
|
2947
|
+
kill: child.kill,
|
|
2948
|
+
exitPromise: child.exitPromise,
|
|
2949
|
+
beginRawCapture: child.beginRawCapture
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
//#endregion
|
|
2953
|
+
//#region src/base-adapter.ts
|
|
2954
|
+
/**
|
|
2955
|
+
* Base adapter for CLI-based AI agent providers.
|
|
2956
|
+
*
|
|
2957
|
+
* Subclasses must implement:
|
|
2958
|
+
* - provider: string identifier
|
|
2959
|
+
* - defaultCliPath: default CLI executable name
|
|
2960
|
+
* - buildExecArgs(): build CLI execution arguments
|
|
2961
|
+
* - buildExecEnv(): build environment variables
|
|
2962
|
+
* - createEventParser(): create JSONL event parser
|
|
2963
|
+
* - mapEventToStreamEvents(): map parsed events to StreamEvents
|
|
2964
|
+
*
|
|
2965
|
+
* The message log directory is the registry spec's `messageStorageDir` for
|
|
2966
|
+
* `provider` (cli-agent-registry `messageStorageDirOf`); only an adapter for
|
|
2967
|
+
* a provider the registry does not know sets `defaultStorageDir`.
|
|
2968
|
+
*
|
|
2969
|
+
* Optional overrides:
|
|
2970
|
+
* - buildMcpAddArgs(): MCP add command arguments
|
|
2971
|
+
* - buildMcpListArgs(): MCP list command arguments (if supported)
|
|
2972
|
+
* - parseMcpListOutput(): parse MCP list output
|
|
2973
|
+
*/
|
|
2974
|
+
var CliProviderAdapterBase = class extends CliAdapterMcp {
|
|
2975
|
+
supportsManagedQuote = false;
|
|
2976
|
+
async execute(input, services, onEvent) {
|
|
2977
|
+
this.ensureInitialized();
|
|
2978
|
+
const quoteVersion = input.managedQuoteVersion;
|
|
2979
|
+
if (quoteVersion !== void 0 && !this.supportsManagedQuote) throw new Error(`${this.provider} does not support the managed-quote native model transport`);
|
|
2980
|
+
const correlationHeaders = executionCorrelationHeaders(input.interactionBinding, input.traceId, quoteVersion);
|
|
2981
|
+
const execution = beginExecution(this, input.abortSignal);
|
|
2982
|
+
const result = this.executeInternal(input, services, onEvent, execution.controller.signal, correlationHeaders);
|
|
2983
|
+
result.then(() => finishExecution(this, execution), () => {
|
|
2984
|
+
execution.controller.abort();
|
|
2985
|
+
finishExecution(this, execution);
|
|
2986
|
+
});
|
|
2987
|
+
return result;
|
|
2988
|
+
}
|
|
2989
|
+
async executeInternal(input, services, onEvent, executionSignal, correlationHeaders) {
|
|
2990
|
+
const startTime = Date.now();
|
|
2991
|
+
const recorder = services.recorder;
|
|
2992
|
+
const opened = await this.openTurnSession(input, recorder, onEvent);
|
|
2993
|
+
if (opened.cached) return opened.cached;
|
|
2994
|
+
const { sessionId, providerSessionId, providerMessageIds } = opened;
|
|
2995
|
+
const { executionProfile, profilePlan } = await this.materializeTurnProfile(input, sessionId).catch(rejectNotLaunched);
|
|
2996
|
+
const { preparedInput, promptText } = await this.recordTurnPrompt(input, opened, recorder).catch(rejectNotLaunched);
|
|
2997
|
+
onEvent?.({
|
|
2998
|
+
type: "status",
|
|
2999
|
+
status: "started"
|
|
3000
|
+
});
|
|
3001
|
+
const launch = await this.planLaunch({
|
|
3002
|
+
input,
|
|
3003
|
+
sessionId,
|
|
3004
|
+
providerSessionId,
|
|
3005
|
+
executionSignal,
|
|
3006
|
+
correlationHeaders,
|
|
3007
|
+
executionProfile,
|
|
3008
|
+
profilePlan,
|
|
3009
|
+
attachments: preparedInput.attachments,
|
|
3010
|
+
promptText
|
|
3011
|
+
});
|
|
3012
|
+
const { workspaceRoot } = launch;
|
|
3013
|
+
const state = createExecutionState(sessionId, providerMessageIds?.assistantMessageId, workspaceRoot);
|
|
3014
|
+
const observer = new TurnObserver({
|
|
3015
|
+
provider: this.provider,
|
|
3016
|
+
state,
|
|
3017
|
+
sessionId,
|
|
3018
|
+
getRuntimeHome: () => this.config.runtimeHome,
|
|
3019
|
+
recorder,
|
|
3020
|
+
getSessionStore: () => this.getSessionStore(),
|
|
3021
|
+
logger: this.logger,
|
|
3022
|
+
repairToolNames: this.toolCallRepairToolNames(),
|
|
3023
|
+
onEvent
|
|
3024
|
+
});
|
|
3025
|
+
await this.bindNativeSession({
|
|
3026
|
+
launch,
|
|
3027
|
+
providerSessionId,
|
|
3028
|
+
observer
|
|
3029
|
+
});
|
|
3030
|
+
const { exitResult, stderr } = await this.runCliTurn({
|
|
3031
|
+
input,
|
|
3032
|
+
sessionId,
|
|
3033
|
+
providerSessionId,
|
|
3034
|
+
executionSignal,
|
|
3035
|
+
launch,
|
|
3036
|
+
promptText,
|
|
3037
|
+
state,
|
|
3038
|
+
observer,
|
|
3039
|
+
startTime
|
|
3040
|
+
});
|
|
3041
|
+
await observer.providerSessionPersistence;
|
|
3042
|
+
failOnProviderSessionPersistence(observer, state);
|
|
3043
|
+
const endTime = Date.now();
|
|
3044
|
+
const benignNonZeroExit = !exitResult.success && !exitResult.timedOut && exitResult.stdinError === void 0 && this.isBenignNonZeroExit({
|
|
3045
|
+
exitCode: exitResult.code,
|
|
3046
|
+
stderr,
|
|
3047
|
+
state
|
|
3048
|
+
});
|
|
3049
|
+
if (!exitResult.success && !benignNonZeroExit || state.terminalStatus === "failed" || observer.repeatedToolFailureTripped) return this.failCliTurn({
|
|
3050
|
+
onEvent,
|
|
3051
|
+
recorder,
|
|
3052
|
+
observer,
|
|
3053
|
+
state,
|
|
3054
|
+
exitResult,
|
|
3055
|
+
stderr,
|
|
3056
|
+
launch,
|
|
3057
|
+
startTime,
|
|
3058
|
+
endTime
|
|
3059
|
+
});
|
|
3060
|
+
logAcceptedCliExit(this.logger, exitResult, stderr);
|
|
3061
|
+
return this.completeTurn({
|
|
3062
|
+
input,
|
|
3063
|
+
sessionId,
|
|
3064
|
+
state,
|
|
3065
|
+
observer,
|
|
3066
|
+
recorder,
|
|
3067
|
+
providerMessageIds,
|
|
3068
|
+
profilePlan,
|
|
3069
|
+
startTime,
|
|
3070
|
+
endTime
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Record a successful turn: the provider session id, the assistant message,
|
|
3075
|
+
* and the result, cached for a same-turnId retry.
|
|
3076
|
+
*/
|
|
3077
|
+
async completeTurn(params) {
|
|
3078
|
+
const { input, sessionId, state, recorder, profilePlan } = params;
|
|
3079
|
+
if (state.sessionId && sessionId && state.sessionId !== sessionId) await this.getSessionStore().update(sessionId, {
|
|
3080
|
+
providerSessionId: state.sessionId,
|
|
3081
|
+
...this.config.runtimeHome?.trim() ? { providerSessionHome: this.config.runtimeHome.trim() } : {}
|
|
3082
|
+
});
|
|
3083
|
+
const assistantParts = this.buildAssistantParts(state);
|
|
3084
|
+
await params.observer?.flushPartUpdates();
|
|
3085
|
+
await recorder.appendAssistantParts(assistantParts);
|
|
3086
|
+
await this.getMessageStore().append({
|
|
3087
|
+
sessionId,
|
|
3088
|
+
...params.providerMessageIds ? { id: params.providerMessageIds.assistantMessageId } : {},
|
|
3089
|
+
role: "assistant",
|
|
3090
|
+
parts: assistantParts,
|
|
3091
|
+
tokenUsage: state.tokenUsage ? {
|
|
3092
|
+
inputTokens: state.tokenUsage.inputTokens,
|
|
3093
|
+
outputTokens: state.tokenUsage.outputTokens
|
|
3094
|
+
} : void 0
|
|
3095
|
+
});
|
|
3096
|
+
const publicProfileRevision = profilePlan ? profileRevisionReceipts(this).publicProfileRevisionReceipts.get(profilePlan) : void 0;
|
|
3097
|
+
const result = buildExecutionResult({
|
|
3098
|
+
outcome: await this.executionOutcome(state, input),
|
|
3099
|
+
state,
|
|
3100
|
+
toolInvocations: this.extractToolInvocations(state),
|
|
3101
|
+
sessionId,
|
|
3102
|
+
publicProfileRevision,
|
|
3103
|
+
startTime: params.startTime,
|
|
3104
|
+
endTime: params.endTime
|
|
3105
|
+
});
|
|
3106
|
+
if (recorder.markTurnCompleted) await recorder.markTurnCompleted({ result });
|
|
3107
|
+
return result;
|
|
3108
|
+
}
|
|
3109
|
+
/** Report and record a failed turn, then throw its error. */
|
|
3110
|
+
async failCliTurn(params) {
|
|
3111
|
+
const { onEvent, recorder, observer, state, exitResult, stderr } = params;
|
|
3112
|
+
const { launch, startTime, endTime } = params;
|
|
3113
|
+
const { env, execArgs } = launch;
|
|
3114
|
+
const errorMessage = describeCliFailure({
|
|
3115
|
+
provider: this.provider,
|
|
3116
|
+
state,
|
|
3117
|
+
exitResult,
|
|
3118
|
+
stderr,
|
|
3119
|
+
command: execArgs.command,
|
|
3120
|
+
nixBinPath: env.NIX_BIN_PATH
|
|
3121
|
+
});
|
|
3122
|
+
this.logger.error("CLI process failed", {
|
|
3123
|
+
exitCode: exitResult.code,
|
|
3124
|
+
timedOut: exitResult.timedOut,
|
|
3125
|
+
timeoutReason: exitResult.timeoutReason,
|
|
3126
|
+
timeoutDiagnostic: exitResult.timeoutDiagnostic,
|
|
3127
|
+
spawnError: exitResult.spawnError,
|
|
3128
|
+
stdinError: exitResult.stdinError,
|
|
3129
|
+
stderr: stderr.slice(0, 2e3),
|
|
3130
|
+
diagnostic: state.lastDiagnostic,
|
|
3131
|
+
durationMs: endTime - startTime,
|
|
3132
|
+
command: execArgs.command
|
|
3133
|
+
});
|
|
3134
|
+
if (exitResult.timedOut) onEvent?.({
|
|
3135
|
+
type: "warning",
|
|
3136
|
+
code: "TIMEOUT",
|
|
3137
|
+
message: exitResult.timeoutDiagnostic ?? "Execution timed out"
|
|
3138
|
+
});
|
|
3139
|
+
onEvent?.({
|
|
3140
|
+
type: "status",
|
|
3141
|
+
status: "failed",
|
|
3142
|
+
detail: errorMessage
|
|
3143
|
+
});
|
|
3144
|
+
const failure = buildTurnFailure({
|
|
3145
|
+
provider: this.provider,
|
|
3146
|
+
errorMessage,
|
|
3147
|
+
state,
|
|
3148
|
+
startTime,
|
|
3149
|
+
endTime
|
|
3150
|
+
});
|
|
3151
|
+
await recordTurnInterruption({
|
|
3152
|
+
recorder,
|
|
3153
|
+
observer,
|
|
3154
|
+
logger: this.logger,
|
|
3155
|
+
errorMessage,
|
|
3156
|
+
exitResult,
|
|
3157
|
+
failure
|
|
3158
|
+
});
|
|
3159
|
+
throw failure;
|
|
3160
|
+
}
|
|
3161
|
+
/**
|
|
3162
|
+
* Open the turn's session (sidecar/provider persistence only; native CLI
|
|
3163
|
+
* startup is measured separately). A same-turnId retry of a completed turn
|
|
3164
|
+
* gets that turn's cached result instead of running again.
|
|
3165
|
+
*/
|
|
3166
|
+
async openTurnSession(input, recorder, onEvent) {
|
|
3167
|
+
const sessionPrepareStarted = performance.now();
|
|
3168
|
+
const adapter = this;
|
|
3169
|
+
const prepared = await prepareExecutionSession({
|
|
3170
|
+
get config() {
|
|
3171
|
+
return adapter.config;
|
|
3172
|
+
},
|
|
3173
|
+
get logger() {
|
|
3174
|
+
return adapter.logger;
|
|
3175
|
+
},
|
|
3176
|
+
get storagePath() {
|
|
3177
|
+
return adapter.storagePath;
|
|
3178
|
+
},
|
|
3179
|
+
get getSessionStore() {
|
|
3180
|
+
return adapter.getSessionStore.bind(adapter);
|
|
3181
|
+
},
|
|
3182
|
+
get getMessageStore() {
|
|
3183
|
+
return adapter.getMessageStore.bind(adapter);
|
|
3184
|
+
},
|
|
3185
|
+
get resumableProviderSessionId() {
|
|
3186
|
+
return adapter.resumableProviderSessionId.bind(adapter);
|
|
3187
|
+
},
|
|
3188
|
+
get createSession() {
|
|
3189
|
+
return adapter.createSession.bind(adapter);
|
|
3190
|
+
}
|
|
3191
|
+
}, input).catch(rejectNotLaunched);
|
|
3192
|
+
this.logger.observePhase?.("session_prepare", performance.now() - sessionPrepareStarted);
|
|
3193
|
+
this.logger.observePhase?.(prepared.transcriptReplay ? "history_replay" : prepared.providerSessionId ? "native_resume" : "native_session_new", 0);
|
|
3194
|
+
const providerMessageIds = readProviderMessageIds(input);
|
|
3195
|
+
const cached = await findCachedTurn(recorder, input, this.logger, prepared.sessionId);
|
|
3196
|
+
if (cached) {
|
|
3197
|
+
onEvent?.({
|
|
3198
|
+
type: "status",
|
|
3199
|
+
status: "completed"
|
|
3200
|
+
});
|
|
3201
|
+
return {
|
|
3202
|
+
...prepared,
|
|
3203
|
+
providerMessageIds,
|
|
3204
|
+
cached
|
|
3205
|
+
};
|
|
3206
|
+
}
|
|
3207
|
+
if (input.turnId && recorder.setTurnId) recorder.setTurnId(input.turnId);
|
|
3208
|
+
return {
|
|
3209
|
+
...prepared,
|
|
3210
|
+
providerMessageIds,
|
|
3211
|
+
cached: void 0
|
|
3212
|
+
};
|
|
3213
|
+
}
|
|
3214
|
+
/** Provision the profile's cwd-native dimensions for this turn. */
|
|
3215
|
+
async materializeTurnProfile(input, sessionId) {
|
|
3216
|
+
const executionProfile = this.resolveExecutionProfileForInput(input);
|
|
3217
|
+
const profilePlan = await this.materializeProfileResources(executionProfile, input.workspaceRoot ?? this.storagePath, input.userId ?? sessionId, sessionId);
|
|
3218
|
+
assertExecutionProfileRevision(this, () => this.config, input, profilePlan);
|
|
3219
|
+
return {
|
|
3220
|
+
executionProfile,
|
|
3221
|
+
profilePlan
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
/**
|
|
3225
|
+
* Prepare the prompt and record the caller's own parts. `promptText`
|
|
3226
|
+
* additionally carries the replayed transcript and this turn's ephemeral
|
|
3227
|
+
* context, which are execution-only and stay out of both stores.
|
|
3228
|
+
*/
|
|
3229
|
+
async recordTurnPrompt(input, session, recorder) {
|
|
3230
|
+
const { sessionId, providerMessageIds } = session;
|
|
3231
|
+
const preparedInput = await this.preparePromptInput(normalizeInputParts(input), input.workspaceRoot ?? this.storagePath, sessionId);
|
|
3232
|
+
const transcriptContext = await buildTranscriptContext({
|
|
3233
|
+
provider: this.provider,
|
|
3234
|
+
sessionId,
|
|
3235
|
+
transcriptReplay: session.transcriptReplay,
|
|
3236
|
+
secretProfileRevisionRoot: this.config.secretProfileRevisionRoot,
|
|
3237
|
+
getMessageStore: () => this.getMessageStore()
|
|
3238
|
+
});
|
|
3239
|
+
await recorder.recordUserMessage(preparedInput.persistedParts);
|
|
3240
|
+
await this.getMessageStore().append({
|
|
3241
|
+
sessionId,
|
|
3242
|
+
...providerMessageIds ? { id: providerMessageIds.userMessageId } : {},
|
|
3243
|
+
role: "user",
|
|
3244
|
+
parts: preparedInput.persistedParts
|
|
3245
|
+
});
|
|
3246
|
+
return {
|
|
3247
|
+
preparedInput,
|
|
3248
|
+
promptText: this.composePromptText(prependPromptSection(transcriptContext, preparedInput.promptText), input)
|
|
3249
|
+
};
|
|
3250
|
+
}
|
|
3251
|
+
/** Resolve this turn's config, environment and argv, and check them against the session contract. */
|
|
3252
|
+
async planLaunch(params) {
|
|
3253
|
+
const { input, sessionId, providerSessionId, executionSignal, correlationHeaders, executionProfile, profilePlan, attachments, promptText } = params;
|
|
3254
|
+
const workspaceRoot = input.workspaceRoot ?? this.storagePath;
|
|
3255
|
+
const turnConfig = withTurnInteractions(this.config, input);
|
|
3256
|
+
const executionConfig = this.withProfilePlan(turnConfig, profilePlan);
|
|
3257
|
+
const sessionContract = this.sessionContract();
|
|
3258
|
+
const executionModel = this.resolveProfileModel(input.model?.model);
|
|
3259
|
+
const env = this.buildTurnEnvironment(executionConfig, profilePlan, workspaceRoot, correlationHeaders);
|
|
3260
|
+
const buildExecArgs = (mintedSessionId) => this.buildExecArgs({
|
|
3261
|
+
message: promptText,
|
|
3262
|
+
config: executionConfig,
|
|
3263
|
+
correlationHeaders,
|
|
3264
|
+
model: executionModel,
|
|
3265
|
+
sessionId: providerSessionId,
|
|
3266
|
+
...mintedSessionId ? { mintedSessionId } : {},
|
|
3267
|
+
workspaceRoot,
|
|
3268
|
+
...keepsHarnessSystemPrompt(executionProfile, this.profileHarness) ? {
|
|
3269
|
+
systemPrompt: void 0,
|
|
3270
|
+
appendSystemPrompt: joinSystemPromptAdditions(this.resolveProfileAppendSystemPrompt(profilePlan), input.systemPrompt, input.appendSystemPrompt)
|
|
3271
|
+
} : {
|
|
3272
|
+
systemPrompt: this.resolveProfileSystemPrompt(input.systemPrompt, profilePlan),
|
|
3273
|
+
appendSystemPrompt: input.appendSystemPrompt ? joinSystemPromptAdditions(this.resolveProfileAppendSystemPrompt(profilePlan), input.appendSystemPrompt) : this.resolveProfileAppendSystemPrompt(profilePlan)
|
|
3274
|
+
},
|
|
3275
|
+
attachments,
|
|
3276
|
+
profilePlan
|
|
3277
|
+
});
|
|
3278
|
+
const timeoutMs = input.timeoutMs;
|
|
3279
|
+
const mintedSessionId = sessionContract?.identity.kind === "pre-minted" && !providerSessionId ? params.reserveHeldProcess === false ? randomUUID() : await this.mintNativeSessionId({
|
|
3280
|
+
sessionId,
|
|
3281
|
+
workspaceRoot,
|
|
3282
|
+
config: executionConfig,
|
|
3283
|
+
model: executionModel,
|
|
3284
|
+
env,
|
|
3285
|
+
signal: executionSignal,
|
|
3286
|
+
buildExecArgs,
|
|
3287
|
+
input,
|
|
3288
|
+
...timeoutMs === void 0 ? {} : { timeoutMs }
|
|
3289
|
+
}) : void 0;
|
|
3290
|
+
const execArgs = buildExecArgs(mintedSessionId);
|
|
3291
|
+
if (sessionContract) {
|
|
3292
|
+
assertArgvHonoursSessionContract(this.provider, sessionContract, execArgs.args, { ...mintedSessionId ? { mintedId: mintedSessionId } : {} });
|
|
3293
|
+
const runtimeHome = this.config.runtimeHome?.trim();
|
|
3294
|
+
if (runtimeHome && sessionContractFullyEnforced(this.provider)) assertStoreLocationHonoursSessionContract(this.provider, sessionContract, {
|
|
3295
|
+
runtimeHome,
|
|
3296
|
+
cwd: workspaceRoot,
|
|
3297
|
+
env,
|
|
3298
|
+
argv: execArgs.args
|
|
3299
|
+
});
|
|
3300
|
+
}
|
|
3301
|
+
this.logger.debug("CLI execution", {
|
|
3302
|
+
command: execArgs.command,
|
|
3303
|
+
cwd: workspaceRoot,
|
|
3304
|
+
hasSessionId: !!providerSessionId
|
|
3305
|
+
});
|
|
3306
|
+
return {
|
|
3307
|
+
workspaceRoot,
|
|
3308
|
+
executionConfig,
|
|
3309
|
+
executionModel,
|
|
3310
|
+
env,
|
|
3311
|
+
sessionContract,
|
|
3312
|
+
timeoutMs,
|
|
3313
|
+
mintedSessionId,
|
|
3314
|
+
execArgs
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
/** Reserve and announce the native session id a pre-minted harness already has. */
|
|
3318
|
+
async bindNativeSession(params) {
|
|
3319
|
+
const { launch, providerSessionId, observer } = params;
|
|
3320
|
+
const { workspaceRoot, sessionContract, mintedSessionId } = launch;
|
|
3321
|
+
const nativeBindStarted = performance.now();
|
|
3322
|
+
if (mintedSessionId) await this.reserveNativeSession(mintedSessionId, { workspaceRoot });
|
|
3323
|
+
const knownNativeSessionId = sessionContract?.identity.kind === "pre-minted" ? mintedSessionId ?? providerSessionId : void 0;
|
|
3324
|
+
if (knownNativeSessionId) {
|
|
3325
|
+
observer.onEvent({
|
|
3326
|
+
type: "native.session.observed",
|
|
3327
|
+
provider: this.provider,
|
|
3328
|
+
nativeSessionId: knownNativeSessionId
|
|
3329
|
+
});
|
|
3330
|
+
await observer.providerSessionPersistence;
|
|
3331
|
+
if (observer.providerSessionPersistenceError) throw observer.providerSessionPersistenceError;
|
|
3332
|
+
}
|
|
3333
|
+
if (knownNativeSessionId) this.logger.observePhase?.("native_session_bind", performance.now() - nativeBindStarted);
|
|
3334
|
+
}
|
|
3335
|
+
/** Spawn the turn's process, feed its input, and wait for it to exit. */
|
|
3336
|
+
async runCliTurn(params) {
|
|
3337
|
+
const { input, sessionId, providerSessionId, executionSignal } = params;
|
|
3338
|
+
const { launch, promptText, state, observer, startTime } = params;
|
|
3339
|
+
const { execArgs, workspaceRoot, env } = launch;
|
|
3340
|
+
const parserConfig = this.createEventParser(state, observer.onEvent);
|
|
3341
|
+
const spawnedProcess = this.spawnExecutionProcess({
|
|
3342
|
+
input,
|
|
3343
|
+
sessionId,
|
|
3344
|
+
providerSessionId,
|
|
3345
|
+
config: launch.executionConfig,
|
|
3346
|
+
execArgs,
|
|
3347
|
+
workspaceRoot,
|
|
3348
|
+
env,
|
|
3349
|
+
signal: executionSignal,
|
|
3350
|
+
timeoutMs: launch.timeoutMs,
|
|
3351
|
+
parser: parserConfig
|
|
3352
|
+
});
|
|
3353
|
+
const cliProcess = spawnedProcess instanceof Promise ? await spawnedProcess : spawnedProcess;
|
|
3354
|
+
this.activeProcesses.add(cliProcess);
|
|
3355
|
+
observer.attachProcess(cliProcess);
|
|
3356
|
+
const identityDeadline = armNativeSessionDeadline({
|
|
3357
|
+
provider: this.provider,
|
|
3358
|
+
sessionId,
|
|
3359
|
+
sessionContract: launch.sessionContract,
|
|
3360
|
+
state,
|
|
3361
|
+
observer,
|
|
3362
|
+
logger: this.logger,
|
|
3363
|
+
kill: () => cliProcess.kill()
|
|
3364
|
+
});
|
|
3365
|
+
const stderr = collectStderr(cliProcess);
|
|
3366
|
+
const livenessTicker = observer.startLiveness(executionSignal, startTime);
|
|
3367
|
+
let exitResult;
|
|
3368
|
+
try {
|
|
3369
|
+
if (execArgs.useStdin) {
|
|
3370
|
+
const stdinMessage = execArgs.stdin && execArgs.stdin.length > 0 ? execArgs.stdin : promptText;
|
|
3371
|
+
cliProcess.write(this.formatStdinMessage(stdinMessage));
|
|
3372
|
+
cliProcess.closeInput();
|
|
3373
|
+
}
|
|
3374
|
+
exitResult = await cliProcess.exitPromise;
|
|
3375
|
+
} catch (error) {
|
|
3376
|
+
cliProcess.kill();
|
|
3377
|
+
await cliProcess.exitPromise;
|
|
3378
|
+
throw error;
|
|
3379
|
+
} finally {
|
|
3380
|
+
clearInterval(livenessTicker);
|
|
3381
|
+
if (identityDeadline) clearTimeout(identityDeadline);
|
|
3382
|
+
this.activeProcesses.delete(cliProcess);
|
|
3383
|
+
}
|
|
3384
|
+
await this.afterCliProcessExit({
|
|
3385
|
+
state,
|
|
3386
|
+
stderr: stderr.text,
|
|
3387
|
+
exit: exitResult,
|
|
3388
|
+
workspaceRoot,
|
|
3389
|
+
env,
|
|
3390
|
+
onEvent: observer.onEvent
|
|
3391
|
+
});
|
|
3392
|
+
return {
|
|
3393
|
+
exitResult,
|
|
3394
|
+
stderr: stderr.text
|
|
3395
|
+
};
|
|
3396
|
+
}
|
|
3397
|
+
/** How one turn's argv runs: cwd, environment, identity or launcher, stdio. */
|
|
3398
|
+
turnSpawnConfig(execArgs, workspaceRoot, env) {
|
|
3399
|
+
return cliSpawnConfig({
|
|
3400
|
+
execArgs,
|
|
3401
|
+
workspaceRoot,
|
|
3402
|
+
env,
|
|
3403
|
+
inheritEnv: this.inheritProcessEnv,
|
|
3404
|
+
launcher: this.launcher,
|
|
3405
|
+
ownProcessGroup: this.ownProcessGroup,
|
|
3406
|
+
subprocessUser: this.config.subprocessUser,
|
|
3407
|
+
subprocessSpawnOptions: this.subprocessSpawnOptions()
|
|
3408
|
+
});
|
|
3409
|
+
}
|
|
3410
|
+
/**
|
|
3411
|
+
* The launch one turn would make, without starting it: the spawn config
|
|
3412
|
+
* (argv, cwd, environment, identity or launcher), the stdin payload, and
|
|
3413
|
+
* the profile files written into the workspace. Session and message
|
|
3414
|
+
* stores are untouched and no held process is reserved, so a caller that
|
|
3415
|
+
* runs the process itself (`execCli`) owns its output and lifecycle.
|
|
3416
|
+
*/
|
|
3417
|
+
async prepareLaunch(input, options = {}) {
|
|
3418
|
+
this.ensureInitialized();
|
|
3419
|
+
const sessionId = input.sessionId ?? randomUUID();
|
|
3420
|
+
const { executionProfile, profilePlan } = await this.materializeTurnProfile(input, sessionId);
|
|
3421
|
+
const workspaceRoot = input.workspaceRoot ?? this.storagePath;
|
|
3422
|
+
const preparedInput = await this.preparePromptInput(normalizeInputParts(input), workspaceRoot, sessionId);
|
|
3423
|
+
const promptText = this.composePromptText(preparedInput.promptText, input);
|
|
3424
|
+
const launch = await this.planLaunch({
|
|
3425
|
+
input,
|
|
3426
|
+
sessionId,
|
|
3427
|
+
providerSessionId: options.providerSessionId,
|
|
3428
|
+
executionSignal: input.abortSignal ?? new AbortController().signal,
|
|
3429
|
+
correlationHeaders: executionCorrelationHeaders(input.interactionBinding, input.traceId, void 0),
|
|
3430
|
+
executionProfile,
|
|
3431
|
+
profilePlan,
|
|
3432
|
+
attachments: preparedInput.attachments,
|
|
3433
|
+
promptText,
|
|
3434
|
+
reserveHeldProcess: false
|
|
3435
|
+
});
|
|
3436
|
+
const { execArgs } = launch;
|
|
3437
|
+
return {
|
|
3438
|
+
spawn: this.turnSpawnConfig(execArgs, launch.workspaceRoot, launch.env),
|
|
3439
|
+
...execArgs.useStdin ? { stdin: this.formatStdinMessage(execArgs.stdin && execArgs.stdin.length > 0 ? execArgs.stdin : promptText) } : {},
|
|
3440
|
+
...launch.mintedSessionId ? { nativeSessionId: launch.mintedSessionId } : {},
|
|
3441
|
+
...profilePlan ? { workspacePlan: profilePlan.receipt } : {}
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
/** Override only when the provider has a turn transport besides a CLI child. */
|
|
3445
|
+
spawnExecutionProcess(request) {
|
|
3446
|
+
const { execArgs, workspaceRoot, env, signal, timeoutMs, parser } = request;
|
|
3447
|
+
const spawnStarted = performance.now();
|
|
3448
|
+
const config = {
|
|
3449
|
+
...this.turnSpawnConfig(execArgs, workspaceRoot, env),
|
|
3450
|
+
signal,
|
|
3451
|
+
...globalThis.process.getuid?.() === 0 && this.config.runtimeHome?.trim() ? {
|
|
3452
|
+
rawCapturePath: resolveRawCapturePath(this.config.runtimeHome.trim()),
|
|
3453
|
+
...request.deferRawCapture ? { rawCaptureDeferred: true } : {}
|
|
3454
|
+
} : {},
|
|
3455
|
+
...timeoutMs === void 0 ? {} : { timeoutMs }
|
|
3456
|
+
};
|
|
3457
|
+
const turnProcess = (process) => cliTurnProcess(process, this.logger, spawnStarted);
|
|
3458
|
+
return this.launcher ? launchCliProcess(config, parser).then(turnProcess) : turnProcess(spawnCliProcess(config, parser));
|
|
3459
|
+
}
|
|
3460
|
+
};
|
|
3461
|
+
//#endregion
|
|
3462
|
+
//#region src/acp-session-adapter.ts
|
|
3463
|
+
const DEFAULT_PROMPT_TIMEOUT_MS = 6e5;
|
|
3464
|
+
const DEFAULT_BOOTSTRAP_TIMEOUT_MS = 12e4;
|
|
3465
|
+
const DEFAULT_BRIDGE_IDLE_MS = 300 * 1e3;
|
|
3466
|
+
/**
|
|
3467
|
+
* Grace between `session/cancel` and closing the bridge: long enough for the
|
|
3468
|
+
* agent to answer the in-flight `session/prompt` with `stopReason:
|
|
3469
|
+
* "cancelled"`, short enough that abort stays prompt.
|
|
3470
|
+
*/
|
|
3471
|
+
const ABORT_GRACE_MS = 250;
|
|
3472
|
+
/**
|
|
3473
|
+
* Shared lifecycle of a provider that drives an Agent Client Protocol agent
|
|
3474
|
+
* over a JSON-RPC stdio bridge: one cached process and native ACP session per
|
|
3475
|
+
* Tangle session, `initialize` then `session/new` (or `session/load` for a
|
|
3476
|
+
* provider that resumes), serialized `session/prompt` turns, the cancel
|
|
3477
|
+
* sequence (`session/cancel`, grace, close), `session/update` translation,
|
|
3478
|
+
* idle reaping and eviction on process exit or turn failure.
|
|
3479
|
+
*
|
|
3480
|
+
* A provider supplies only its translation: how a turn resolves its config,
|
|
3481
|
+
* how the process starts, the prompt blocks, and its own failure signals.
|
|
3482
|
+
*/
|
|
3483
|
+
var AcpProviderAdapterBase = class extends CliProviderAdapterBase {
|
|
3484
|
+
acpSessions = /* @__PURE__ */ new Map();
|
|
3485
|
+
/** Spawn the bridge. Tests override it to swap the child process. */
|
|
3486
|
+
spawnBridge(request) {
|
|
3487
|
+
return spawnAcpBridge(request);
|
|
3488
|
+
}
|
|
3489
|
+
/** Refuse an agent version beyond the provider's own floor. */
|
|
3490
|
+
checkAgentProtocolVersion(_advertised, _turn) {}
|
|
3491
|
+
/**
|
|
3492
|
+
* Configure a native session the process just opened or loaded, before its
|
|
3493
|
+
* first prompt (e.g. `session/set_model`). A failure fails the acquire.
|
|
3494
|
+
*/
|
|
3495
|
+
async onSessionOpened(_bridge, _acpSessionId, _turn, _options) {}
|
|
3496
|
+
/** Install reverse-call handlers on a new bridge, before `initialize`. */
|
|
3497
|
+
onBridgeSpawned(_bridge, _sessionId, _turn) {}
|
|
3498
|
+
/** Start per-turn state; the returned function ends it on every exit. */
|
|
3499
|
+
beginTurn(_sessionId, _input, _onEvent) {
|
|
3500
|
+
return () => {};
|
|
3501
|
+
}
|
|
3502
|
+
/** Inspect a failed tool's output; returned events precede its part update. */
|
|
3503
|
+
onToolFailed(_sessionId, _failureText) {
|
|
3504
|
+
return [];
|
|
3505
|
+
}
|
|
3506
|
+
/**
|
|
3507
|
+
* A failure the provider recorded for this session during the turn,
|
|
3508
|
+
* cleared on read. It outranks the error the turn surfaced as.
|
|
3509
|
+
*/
|
|
3510
|
+
takeTurnFailure(_sessionId) {}
|
|
3511
|
+
/** Release provider state tied to a session's process. */
|
|
3512
|
+
onSessionEvicted(_sessionId) {}
|
|
3513
|
+
/** Extra fields for the turn-failure log line. */
|
|
3514
|
+
turnLogContext(_turn) {
|
|
3515
|
+
return {};
|
|
3516
|
+
}
|
|
3517
|
+
/**
|
|
3518
|
+
* One turn through the base turn pipeline: session open with transcript
|
|
3519
|
+
* replay and cached-turn replay, then input and result recording around the
|
|
3520
|
+
* bridge's `session/prompt`. The agent owns its profile, so the base's
|
|
3521
|
+
* profile materialization does not run.
|
|
3522
|
+
*/
|
|
3523
|
+
async execute(input, services, onEvent) {
|
|
3524
|
+
this.ensureInitialized();
|
|
3525
|
+
const turn = this.resolveTurn(input);
|
|
3526
|
+
const { config } = turn;
|
|
3527
|
+
const { recorder } = services;
|
|
3528
|
+
const startTime = Date.now();
|
|
3529
|
+
const workspaceRoot = input.workspaceRoot ?? this.storagePath;
|
|
3530
|
+
const turnInput = input.sessionId ? input : {
|
|
3531
|
+
...input,
|
|
3532
|
+
sessionId: input.interactionBinding?.sessionId
|
|
3533
|
+
};
|
|
3534
|
+
const opened = await this.openTurnSession(turnInput, recorder, onEvent);
|
|
3535
|
+
if (opened.cached) return opened.cached;
|
|
3536
|
+
const { sessionId, providerSessionId, providerMessageIds } = opened;
|
|
3537
|
+
const endTurn = this.beginTurn(sessionId, input, onEvent);
|
|
3538
|
+
try {
|
|
3539
|
+
const { preparedInput, promptText } = await this.recordTurnPrompt(turnInput, opened, recorder);
|
|
3540
|
+
onEvent?.({
|
|
3541
|
+
type: "status",
|
|
3542
|
+
status: "started"
|
|
3543
|
+
});
|
|
3544
|
+
const state = createExecutionState(sessionId, providerMessageIds?.assistantMessageId);
|
|
3545
|
+
let entry;
|
|
3546
|
+
try {
|
|
3547
|
+
entry = await this.acquireAcpSession({
|
|
3548
|
+
sessionId,
|
|
3549
|
+
...this.loadsNativeSessions && providerSessionId ? { providerSessionId } : {},
|
|
3550
|
+
turn,
|
|
3551
|
+
workspaceRoot,
|
|
3552
|
+
onEvent
|
|
3553
|
+
});
|
|
3554
|
+
} catch (err) {
|
|
3555
|
+
onEvent?.({
|
|
3556
|
+
type: "status",
|
|
3557
|
+
status: "failed",
|
|
3558
|
+
detail: err instanceof Error ? err.message : String(err)
|
|
3559
|
+
});
|
|
3560
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
3561
|
+
}
|
|
3562
|
+
const { bridge, acpSessionId } = entry;
|
|
3563
|
+
onEvent?.({
|
|
3564
|
+
type: "session.updated",
|
|
3565
|
+
sessionId
|
|
3566
|
+
});
|
|
3567
|
+
onEvent?.({
|
|
3568
|
+
type: "native.session.observed",
|
|
3569
|
+
provider: this.provider,
|
|
3570
|
+
nativeSessionId: acpSessionId
|
|
3571
|
+
});
|
|
3572
|
+
try {
|
|
3573
|
+
const promptResult = await this.promptAcpSession(entry, {
|
|
3574
|
+
prompt: this.promptBlocks(input, {
|
|
3575
|
+
...preparedInput,
|
|
3576
|
+
promptText
|
|
3577
|
+
}, turn),
|
|
3578
|
+
timeoutMs: config.promptTimeoutMs ?? DEFAULT_PROMPT_TIMEOUT_MS,
|
|
3579
|
+
abortSignal: input.abortSignal,
|
|
3580
|
+
onUpdate: (params) => {
|
|
3581
|
+
for (const event of this.mapAcpUpdate(params, state, sessionId)) onEvent?.(event);
|
|
3582
|
+
}
|
|
3583
|
+
});
|
|
3584
|
+
const recorded = this.takeTurnFailure(sessionId);
|
|
3585
|
+
if (recorded) throw recorded;
|
|
3586
|
+
const finalStatus = mapAcpStopReason(promptResult.stopReason);
|
|
3587
|
+
onEvent?.(finalStatus.detail ? {
|
|
3588
|
+
type: "status",
|
|
3589
|
+
status: finalStatus.status,
|
|
3590
|
+
detail: finalStatus.detail
|
|
3591
|
+
} : {
|
|
3592
|
+
type: "status",
|
|
3593
|
+
status: finalStatus.status
|
|
3594
|
+
});
|
|
3595
|
+
await this.getSessionStore().update(sessionId, { providerSessionId: acpSessionId });
|
|
3596
|
+
return await this.completeTurn({
|
|
3597
|
+
input: turnInput,
|
|
3598
|
+
sessionId,
|
|
3599
|
+
state,
|
|
3600
|
+
recorder,
|
|
3601
|
+
providerMessageIds,
|
|
3602
|
+
startTime,
|
|
3603
|
+
endTime: Date.now()
|
|
3604
|
+
});
|
|
3605
|
+
} catch (err) {
|
|
3606
|
+
return this.failAcpTurn(err, sessionId, turn, bridge, onEvent);
|
|
3607
|
+
} finally {
|
|
3608
|
+
this.scheduleIdleReap(sessionId, config.bridgeIdleMs);
|
|
3609
|
+
}
|
|
3610
|
+
} finally {
|
|
3611
|
+
endTurn();
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
/** Report a failed turn, retire its process, and throw its error. */
|
|
3615
|
+
failAcpTurn(err, sessionId, turn, bridge, onEvent) {
|
|
3616
|
+
const finalError = this.takeTurnFailure(sessionId) ?? err;
|
|
3617
|
+
const detail = this.formatBridgeError(finalError);
|
|
3618
|
+
this.logger.error(`${this.provider} execute failed`, {
|
|
3619
|
+
...this.turnLogContext(turn),
|
|
3620
|
+
error: detail,
|
|
3621
|
+
stderrTail: bridge.getStderrTail().slice(-500)
|
|
3622
|
+
});
|
|
3623
|
+
onEvent?.({
|
|
3624
|
+
type: "status",
|
|
3625
|
+
status: "failed",
|
|
3626
|
+
detail
|
|
3627
|
+
});
|
|
3628
|
+
this.evictAcpSession(sessionId, "execute-error");
|
|
3629
|
+
if (finalError instanceof JsonRpcError && detail !== finalError.message) throw new Error(detail, { cause: finalError });
|
|
3630
|
+
throw finalError instanceof Error ? finalError : new Error(detail);
|
|
3631
|
+
}
|
|
3632
|
+
async acquireAcpSession(opts) {
|
|
3633
|
+
const { sessionId, turn, workspaceRoot, onEvent } = opts;
|
|
3634
|
+
const existing = this.acpSessions.get(sessionId);
|
|
3635
|
+
if (existing && !existing.bridge.closed) {
|
|
3636
|
+
if (existing.processKey === turn.processKey) {
|
|
3637
|
+
this.cancelIdleReap(existing);
|
|
3638
|
+
return existing;
|
|
3639
|
+
}
|
|
3640
|
+
this.logger.warn(`${this.provider}: cached session was started for another process configuration — respawning`, { sessionId });
|
|
3641
|
+
this.evictAcpSession(sessionId, "process-change");
|
|
3642
|
+
}
|
|
3643
|
+
const spec = await this.processSpec(turn, {
|
|
3644
|
+
sessionId,
|
|
3645
|
+
workspaceRoot
|
|
3646
|
+
});
|
|
3647
|
+
onEvent?.({
|
|
3648
|
+
type: "status",
|
|
3649
|
+
status: "processing",
|
|
3650
|
+
detail: spec.statusDetail
|
|
3651
|
+
});
|
|
3652
|
+
let bridge;
|
|
3653
|
+
const spawnStarted = performance.now();
|
|
3654
|
+
try {
|
|
3655
|
+
const runtimeHome = this.config.runtimeHome?.trim();
|
|
3656
|
+
bridge = this.spawnBridge({
|
|
3657
|
+
command: spec.command,
|
|
3658
|
+
args: spec.args,
|
|
3659
|
+
cwd: workspaceRoot,
|
|
3660
|
+
env: withAgentEnvironment(spec.env, this.agentEnvironmentValues()),
|
|
3661
|
+
...this.subprocessSpawnOptions(),
|
|
3662
|
+
...globalThis.process.getuid?.() === 0 && runtimeHome ? { rawCapturePath: resolveRawCapturePath(runtimeHome) } : {}
|
|
3663
|
+
});
|
|
3664
|
+
} catch (err) {
|
|
3665
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3666
|
+
throw new Error(this.spawnFailureMessage({
|
|
3667
|
+
message,
|
|
3668
|
+
missingBinary: message.includes("ENOENT") || message.includes("not found")
|
|
3669
|
+
}, turn));
|
|
3670
|
+
}
|
|
3671
|
+
bridge.process?.once("spawn", () => {
|
|
3672
|
+
this.logger.observePhase?.("cli_spawn", performance.now() - spawnStarted);
|
|
3673
|
+
});
|
|
3674
|
+
this.onBridgeSpawned(bridge, sessionId, turn);
|
|
3675
|
+
const timeoutMs = turn.config.bootstrapTimeoutMs ?? DEFAULT_BOOTSTRAP_TIMEOUT_MS;
|
|
3676
|
+
let acpSessionId;
|
|
3677
|
+
const initializeStarted = performance.now();
|
|
3678
|
+
try {
|
|
3679
|
+
const requested = turn.config.protocolVersion ?? 1;
|
|
3680
|
+
const init = await bridge.request("initialize", {
|
|
3681
|
+
protocolVersion: requested,
|
|
3682
|
+
clientCapabilities: {}
|
|
3683
|
+
}, { timeoutMs });
|
|
3684
|
+
this.checkProtocolVersion(init, requested, turn);
|
|
3685
|
+
acpSessionId = await this.openNativeSession(bridge, {
|
|
3686
|
+
providerSessionId: opts.providerSessionId,
|
|
3687
|
+
cwd: workspaceRoot,
|
|
3688
|
+
timeoutMs
|
|
3689
|
+
});
|
|
3690
|
+
await this.onSessionOpened(bridge, acpSessionId, turn, { timeoutMs });
|
|
3691
|
+
this.logger.observePhase?.("cli_initialize", performance.now() - initializeStarted);
|
|
3692
|
+
} catch (err) {
|
|
3693
|
+
try {
|
|
3694
|
+
await bridge.close();
|
|
3695
|
+
} catch {}
|
|
3696
|
+
throw this.sessionSetupError(err, bridge);
|
|
3697
|
+
}
|
|
3698
|
+
const unsubscribeClose = bridge.onClose((reason) => {
|
|
3699
|
+
this.logger.warn(`${this.provider}: agent process closed unexpectedly — evicting`, {
|
|
3700
|
+
sessionId,
|
|
3701
|
+
reason: reason.reason,
|
|
3702
|
+
code: reason.code,
|
|
3703
|
+
signal: reason.signal
|
|
3704
|
+
});
|
|
3705
|
+
this.evictAcpSession(sessionId, "process-exit");
|
|
3706
|
+
});
|
|
3707
|
+
const entry = {
|
|
3708
|
+
bridge,
|
|
3709
|
+
acpSessionId,
|
|
3710
|
+
processKey: turn.processKey,
|
|
3711
|
+
inflight: Promise.resolve(),
|
|
3712
|
+
unsubscribeClose
|
|
3713
|
+
};
|
|
3714
|
+
this.acpSessions.set(sessionId, entry);
|
|
3715
|
+
return entry;
|
|
3716
|
+
}
|
|
3717
|
+
checkProtocolVersion(init, requested, turn) {
|
|
3718
|
+
const advertised = init.protocolVersion;
|
|
3719
|
+
if (typeof advertised !== "number") throw new Error(`${this.provider}: initialize response missing protocolVersion (got ${JSON.stringify(init)})`);
|
|
3720
|
+
if (advertised > requested) throw new Error(`${this.provider}: agent advertises protocolVersion ${advertised} but client speaks at most ${requested}. Upgrade the provider package or pin an older agent binary.`);
|
|
3721
|
+
if (advertised < 1) throw new Error(`${this.provider}: agent advertises protocolVersion ${advertised} but client requires at least 1. The backing agent binary is too old; upgrade it.`);
|
|
3722
|
+
this.checkAgentProtocolVersion(advertised, turn);
|
|
3723
|
+
}
|
|
3724
|
+
/**
|
|
3725
|
+
* Open the native ACP session. A recorded session is loaded when the agent
|
|
3726
|
+
* can; an agent that refuses the load starts a new native session, and the
|
|
3727
|
+
* Tangle transcript stays the record of the earlier turns.
|
|
3728
|
+
*/
|
|
3729
|
+
async openNativeSession(bridge, opts) {
|
|
3730
|
+
const params = {
|
|
3731
|
+
cwd: opts.cwd,
|
|
3732
|
+
mcpServers: []
|
|
3733
|
+
};
|
|
3734
|
+
if (opts.providerSessionId) try {
|
|
3735
|
+
return (await bridge.request("session/load", {
|
|
3736
|
+
sessionId: opts.providerSessionId,
|
|
3737
|
+
...params
|
|
3738
|
+
}, { timeoutMs: opts.timeoutMs }))?.sessionId ?? opts.providerSessionId;
|
|
3739
|
+
} catch (err) {
|
|
3740
|
+
this.logger.debug("session/load failed, creating new", { error: err instanceof Error ? err.message : String(err) });
|
|
3741
|
+
}
|
|
3742
|
+
return (await bridge.request("session/new", params, { timeoutMs: opts.timeoutMs })).sessionId;
|
|
3743
|
+
}
|
|
3744
|
+
/**
|
|
3745
|
+
* One `session/prompt` turn. Abort sends `session/cancel` before closing so
|
|
3746
|
+
* the agent can settle the turn; the bridge's own signal handling would
|
|
3747
|
+
* close first and lose the notify.
|
|
3748
|
+
*/
|
|
3749
|
+
async promptAcpSession(entry, turn) {
|
|
3750
|
+
const { bridge, acpSessionId } = entry;
|
|
3751
|
+
const unsubscribe = bridge.onNotification("session/update", (params) => {
|
|
3752
|
+
if (!params || params.sessionId !== acpSessionId) return;
|
|
3753
|
+
turn.onUpdate(params);
|
|
3754
|
+
});
|
|
3755
|
+
const onAbort = () => {
|
|
3756
|
+
try {
|
|
3757
|
+
bridge.notify("session/cancel", { sessionId: acpSessionId });
|
|
3758
|
+
} catch (err) {
|
|
3759
|
+
this.logger.warn(`${this.provider}: session/cancel notify failed on abort`, { error: err instanceof Error ? err.message : String(err) });
|
|
3760
|
+
}
|
|
3761
|
+
setTimeout(() => {
|
|
3762
|
+
bridge.close().catch(() => {});
|
|
3763
|
+
}, ABORT_GRACE_MS).unref();
|
|
3764
|
+
};
|
|
3765
|
+
const { abortSignal } = turn;
|
|
3766
|
+
if (abortSignal) if (abortSignal.aborted) onAbort();
|
|
3767
|
+
else abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
3768
|
+
const previous = entry.inflight;
|
|
3769
|
+
let releaseLock;
|
|
3770
|
+
const lock = new Promise((resolve) => {
|
|
3771
|
+
releaseLock = resolve;
|
|
3772
|
+
});
|
|
3773
|
+
entry.inflight = previous.then(() => lock).catch(() => lock);
|
|
3774
|
+
const readyWaitStarted = performance.now();
|
|
3775
|
+
await previous.catch(() => {});
|
|
3776
|
+
this.logger.observePhase?.("cli_ready_wait", performance.now() - readyWaitStarted);
|
|
3777
|
+
try {
|
|
3778
|
+
return await bridge.request("session/prompt", {
|
|
3779
|
+
sessionId: acpSessionId,
|
|
3780
|
+
prompt: turn.prompt
|
|
3781
|
+
}, { timeoutMs: turn.timeoutMs });
|
|
3782
|
+
} finally {
|
|
3783
|
+
unsubscribe();
|
|
3784
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
3785
|
+
releaseLock();
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
scheduleIdleReap(sessionId, bridgeIdleMs) {
|
|
3789
|
+
const entry = this.acpSessions.get(sessionId);
|
|
3790
|
+
if (!entry) return;
|
|
3791
|
+
const idleMs = bridgeIdleMs ?? DEFAULT_BRIDGE_IDLE_MS;
|
|
3792
|
+
if (idleMs <= 0) {
|
|
3793
|
+
this.evictAcpSession(sessionId, "idle-disabled");
|
|
3794
|
+
return;
|
|
3795
|
+
}
|
|
3796
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
3797
|
+
entry.idleTimer = setTimeout(() => {
|
|
3798
|
+
this.evictAcpSession(sessionId, "idle-reap");
|
|
3799
|
+
}, idleMs);
|
|
3800
|
+
entry.idleTimer.unref();
|
|
3801
|
+
}
|
|
3802
|
+
cancelIdleReap(entry) {
|
|
3803
|
+
if (entry.idleTimer) {
|
|
3804
|
+
clearTimeout(entry.idleTimer);
|
|
3805
|
+
entry.idleTimer = void 0;
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
/** Close a session's agent process and drop it. Idempotent. */
|
|
3809
|
+
evictAcpSession(sessionId, reason) {
|
|
3810
|
+
const entry = this.acpSessions.get(sessionId);
|
|
3811
|
+
if (!entry) return;
|
|
3812
|
+
this.acpSessions.delete(sessionId);
|
|
3813
|
+
this.onSessionEvicted(sessionId);
|
|
3814
|
+
this.cancelIdleReap(entry);
|
|
3815
|
+
try {
|
|
3816
|
+
entry.unsubscribeClose();
|
|
3817
|
+
} catch {}
|
|
3818
|
+
if (!entry.bridge.closed) entry.bridge.close().catch((err) => {
|
|
3819
|
+
this.logger.warn(`${this.provider}: bridge.close threw during evict`, {
|
|
3820
|
+
sessionId,
|
|
3821
|
+
reason,
|
|
3822
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3823
|
+
});
|
|
3824
|
+
});
|
|
3825
|
+
}
|
|
3826
|
+
/** Evict every cached session whose process key matches. */
|
|
3827
|
+
evictAcpSessionsWhere(matches, reason) {
|
|
3828
|
+
for (const [sessionId, entry] of [...this.acpSessions]) if (matches(entry.processKey)) this.evictAcpSession(sessionId, reason);
|
|
3829
|
+
}
|
|
3830
|
+
/** Translate one `session/update` notification into stream events. */
|
|
3831
|
+
mapAcpUpdate(params, state, sessionId) {
|
|
3832
|
+
const update = params.update;
|
|
3833
|
+
if (!update || typeof update !== "object") return [];
|
|
3834
|
+
const kind = update.sessionUpdate;
|
|
3835
|
+
if (kind === "agent_message_chunk" || kind === "agent_thought_chunk") return acpMessageChunkEvents(kind, update, state);
|
|
3836
|
+
if (kind === "tool_call") return acpToolCallEvents(update, state);
|
|
3837
|
+
if (kind === "tool_call_update") {
|
|
3838
|
+
const change = update;
|
|
3839
|
+
const existing = state.toolCalls.get(change.toolCallId);
|
|
3840
|
+
if (!existing) return [];
|
|
3841
|
+
const mapped = this.mapToolStatus(change.status);
|
|
3842
|
+
const outputText = applyAcpToolCallUpdate(existing, mapped, change);
|
|
3843
|
+
return [...mapped === "failed" ? this.onToolFailed(sessionId, typeof existing.output === "string" ? existing.output : outputText || void 0) : [], ...acpToolCallUpdateEvents(existing, state)];
|
|
3844
|
+
}
|
|
3845
|
+
if (kind === "plan") return acpPlanEvents(update, state, this.provider);
|
|
3846
|
+
if (kind === "available_commands_update") return [{
|
|
3847
|
+
type: "raw",
|
|
3848
|
+
backend: this.provider,
|
|
3849
|
+
event: {
|
|
3850
|
+
sessionUpdate: "available_commands_update",
|
|
3851
|
+
availableCommands: update.availableCommands ?? []
|
|
3852
|
+
}
|
|
3853
|
+
}];
|
|
3854
|
+
return [{
|
|
3855
|
+
type: "raw",
|
|
3856
|
+
backend: this.provider,
|
|
3857
|
+
event: update
|
|
3858
|
+
}];
|
|
3859
|
+
}
|
|
3860
|
+
mapToolStatus(status) {
|
|
3861
|
+
switch (status) {
|
|
3862
|
+
case "completed": return "completed";
|
|
3863
|
+
case "failed": return "failed";
|
|
3864
|
+
case "in_progress": return "running";
|
|
3865
|
+
case "pending":
|
|
3866
|
+
case void 0: return "pending";
|
|
3867
|
+
default:
|
|
3868
|
+
this.logger.warn(`${this.provider}: unknown tool_call_update status`, {
|
|
3869
|
+
status,
|
|
3870
|
+
mappedTo: "running"
|
|
3871
|
+
});
|
|
3872
|
+
return "running";
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
formatBridgeError(raw) {
|
|
3876
|
+
if (raw instanceof BridgeClosedError) return `${this.provider} bridge closed unexpectedly: ${raw.message}`;
|
|
3877
|
+
if (raw instanceof RpcRequestTimeoutError) return `${this.provider} request "${raw.method}" timed out after ${raw.timeoutMs}ms`;
|
|
3878
|
+
if (raw instanceof JsonRpcError) return jsonRpcErrorText(raw);
|
|
3879
|
+
if (raw instanceof Error) return raw.message;
|
|
3880
|
+
return String(raw);
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* The error a failed process start surfaces. An agent that exits before its
|
|
3884
|
+
* session opens reports why only on stderr, and an agent's JSON-RPC error
|
|
3885
|
+
* carries its cause in `data`; a bare "process-exit" or "Internal error"
|
|
3886
|
+
* hides both from the caller.
|
|
3887
|
+
*/
|
|
3888
|
+
sessionSetupError(err, bridge) {
|
|
3889
|
+
if (err instanceof BridgeClosedError) {
|
|
3890
|
+
const stderr = bridge.getStderrTail().trim().slice(-500);
|
|
3891
|
+
return new Error(`${this.provider}: agent exited before its session opened (${err.message})${stderr ? `; stderr: ${stderr}` : ""}`, { cause: err });
|
|
3892
|
+
}
|
|
3893
|
+
if (err instanceof JsonRpcError) return new Error(`${this.provider}: ${jsonRpcErrorText(err)}`, { cause: err });
|
|
3894
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
3895
|
+
}
|
|
3896
|
+
async healthCheck() {
|
|
3897
|
+
return true;
|
|
3898
|
+
}
|
|
3899
|
+
async shutdown() {
|
|
3900
|
+
this.evictAcpSessionsWhere(() => true, "adapter-shutdown");
|
|
3901
|
+
await super.shutdown();
|
|
3902
|
+
}
|
|
3903
|
+
async addMcp(_name, _config) {
|
|
3904
|
+
throw new Error(`${this.provider}: addMcp is not supported — an ACP agent takes MCP servers through session/new`);
|
|
3905
|
+
}
|
|
3906
|
+
async getMcpStatus() {
|
|
3907
|
+
return { servers: {} };
|
|
3908
|
+
}
|
|
3909
|
+
buildExecArgs() {
|
|
3910
|
+
throw new Error(`${this.provider}: buildExecArgs is not used — ACP turns run over the RPC bridge`);
|
|
3911
|
+
}
|
|
3912
|
+
buildExecEnv(_config) {
|
|
3913
|
+
return { ...process.env };
|
|
3914
|
+
}
|
|
3915
|
+
createEventParser() {
|
|
3916
|
+
throw new Error(`${this.provider}: createEventParser is not used — session/update notifications arrive over the RPC bridge`);
|
|
3917
|
+
}
|
|
3918
|
+
buildMcpAddArgs(_name, _config) {
|
|
3919
|
+
throw new Error(`${this.provider}: buildMcpAddArgs is not used`);
|
|
3920
|
+
}
|
|
3921
|
+
};
|
|
3922
|
+
//#endregion
|
|
3923
|
+
//#region src/event-parsers.ts
|
|
3924
|
+
/**
|
|
3925
|
+
* Parser for harnesses that print one JSON event per line (claude-code,
|
|
3926
|
+
* codex, amp, factory-droids). Values that fail `accept` are logged and
|
|
3927
|
+
* dropped; unparseable lines become a `PARSE_ERROR` warning. The first event
|
|
3928
|
+
* `isReady` accepts reports the CLI's own start time as `cli_initialize`,
|
|
3929
|
+
* measured from parser creation, which immediately precedes the spawn.
|
|
3930
|
+
*/
|
|
3931
|
+
function jsonEventParser(logger, onEvent, spec) {
|
|
3932
|
+
const startedAt = performance.now();
|
|
3933
|
+
let readyReported = false;
|
|
3934
|
+
return {
|
|
3935
|
+
transform: (raw) => {
|
|
3936
|
+
if (spec.accept(raw)) return raw;
|
|
3937
|
+
logger.warn(`Received non-${spec.label} event`, { raw });
|
|
3938
|
+
return null;
|
|
3939
|
+
},
|
|
3940
|
+
onEvent: (event) => {
|
|
3941
|
+
if (!readyReported && spec.isReady?.(event)) {
|
|
3942
|
+
readyReported = true;
|
|
3943
|
+
logger.observePhase?.("cli_initialize", performance.now() - startedAt);
|
|
3944
|
+
}
|
|
3945
|
+
for (const stream of spec.map(event)) onEvent?.(stream);
|
|
3946
|
+
},
|
|
3947
|
+
onError: (error, line) => {
|
|
3948
|
+
logger.warn("Failed to parse event", {
|
|
3949
|
+
error: error.message,
|
|
3950
|
+
line
|
|
3951
|
+
});
|
|
3952
|
+
onEvent?.({
|
|
3953
|
+
type: "warning",
|
|
3954
|
+
code: "PARSE_ERROR",
|
|
3955
|
+
message: `Failed to parse event: ${error.message}`
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
};
|
|
3959
|
+
}
|
|
3960
|
+
/**
|
|
3961
|
+
* Parser for harnesses that print their reply as plain stdout lines (hermes,
|
|
3962
|
+
* nanoclaw, forge). Lines classify into provider events; the reply is
|
|
3963
|
+
* emitted as one text part when stdout closes. A turn that printed errors
|
|
3964
|
+
* but no reply ends `failed` with the last error instead of an empty
|
|
3965
|
+
* `completed`.
|
|
3966
|
+
*/
|
|
3967
|
+
function plaintextResponseParser(state, onEvent, spec) {
|
|
3968
|
+
const lines = [];
|
|
3969
|
+
let errorSeen = false;
|
|
3970
|
+
let lastError;
|
|
3971
|
+
const response = {
|
|
3972
|
+
text: (line) => {
|
|
3973
|
+
lines.push(line);
|
|
3974
|
+
},
|
|
3975
|
+
error: (message) => {
|
|
3976
|
+
errorSeen = true;
|
|
3977
|
+
lastError = message;
|
|
3978
|
+
}
|
|
3979
|
+
};
|
|
3980
|
+
return {
|
|
3981
|
+
parseAs: "lines",
|
|
3982
|
+
transform: (raw) => spec.classify(String(raw)),
|
|
3983
|
+
onEvent: (event) => {
|
|
3984
|
+
for (const stream of spec.map(event, response)) onEvent?.(stream);
|
|
3985
|
+
},
|
|
3986
|
+
onComplete: () => {
|
|
3987
|
+
const text = lines.join("\n").trimEnd();
|
|
3988
|
+
if (text.length > 0) {
|
|
3989
|
+
const reply = finalTextEvent(state, text);
|
|
3990
|
+
onEvent?.(reply);
|
|
3991
|
+
onEvent?.({
|
|
3992
|
+
type: "status",
|
|
3993
|
+
status: "completed"
|
|
3994
|
+
});
|
|
3995
|
+
} else if (errorSeen) onEvent?.({
|
|
3996
|
+
type: "status",
|
|
3997
|
+
status: "failed",
|
|
3998
|
+
detail: lastError ?? spec.emptyFailureDetail
|
|
3999
|
+
});
|
|
4000
|
+
}
|
|
4001
|
+
};
|
|
4002
|
+
}
|
|
4003
|
+
//#endregion
|
|
4004
|
+
//#region src/interaction-mcp-server.ts
|
|
4005
|
+
var InteractionMcpServer = class extends InteractionMcpServer$1 {
|
|
4006
|
+
constructor(config) {
|
|
4007
|
+
super({
|
|
4008
|
+
...config,
|
|
4009
|
+
onStop: config.onStop ?? (() => void 0)
|
|
4010
|
+
});
|
|
4011
|
+
}
|
|
4012
|
+
};
|
|
4013
|
+
//#endregion
|
|
4014
|
+
//#region src/terminal-plan-support.ts
|
|
4015
|
+
/** One-shot durable plan submission for a single provider turn. */
|
|
4016
|
+
var TerminalPlanSupport = class {
|
|
4017
|
+
committed;
|
|
4018
|
+
submittedBody;
|
|
4019
|
+
inFlight;
|
|
4020
|
+
confirmInFlight;
|
|
4021
|
+
confirmed = false;
|
|
4022
|
+
constructor(options) {
|
|
4023
|
+
this.options = options;
|
|
4024
|
+
}
|
|
4025
|
+
get plan() {
|
|
4026
|
+
return this.committed;
|
|
4027
|
+
}
|
|
4028
|
+
/** Wait for a submission already accepted by this turn before failure cleanup. */
|
|
4029
|
+
async settleSubmission() {
|
|
4030
|
+
await this.inFlight?.catch(() => void 0);
|
|
4031
|
+
}
|
|
4032
|
+
async submit(body, sourceToolCallId) {
|
|
4033
|
+
const normalized = body.trim();
|
|
4034
|
+
if (!normalized) throw new Error("Plan body must not be empty");
|
|
4035
|
+
if (this.submittedBody && this.submittedBody !== normalized) throw new Error(this.committed ? "This turn already submitted a different plan" : "This turn is already submitting a different plan");
|
|
4036
|
+
if (this.committed) return this.committed;
|
|
4037
|
+
if (this.inFlight) return this.inFlight;
|
|
4038
|
+
this.submittedBody = normalized;
|
|
4039
|
+
this.inFlight = this.options.planHost.submit({
|
|
4040
|
+
body: normalized,
|
|
4041
|
+
sourceToolCallId,
|
|
4042
|
+
providerState: this.options.providerState()
|
|
4043
|
+
}).then(async (plan) => {
|
|
4044
|
+
this.committed = plan;
|
|
4045
|
+
await this.options.onCommitted?.(plan);
|
|
4046
|
+
return plan;
|
|
4047
|
+
}).catch((error) => {
|
|
4048
|
+
this.inFlight = void 0;
|
|
4049
|
+
if (!this.committed) this.submittedBody = void 0;
|
|
4050
|
+
throw error;
|
|
4051
|
+
});
|
|
4052
|
+
return this.inFlight;
|
|
4053
|
+
}
|
|
4054
|
+
async confirm() {
|
|
4055
|
+
if (!this.committed) throw new Error("No durable plan has been committed by this turn");
|
|
4056
|
+
if (this.confirmed) return this.committed;
|
|
4057
|
+
if (this.confirmInFlight) return this.confirmInFlight;
|
|
4058
|
+
const committed = this.committed;
|
|
4059
|
+
this.confirmInFlight = this.options.planHost.confirm(committed.id).then(() => {
|
|
4060
|
+
this.confirmed = true;
|
|
4061
|
+
try {
|
|
4062
|
+
this.options.emit?.({
|
|
4063
|
+
type: "plan.submitted",
|
|
4064
|
+
plan: committed
|
|
4065
|
+
});
|
|
4066
|
+
} catch {}
|
|
4067
|
+
return committed;
|
|
4068
|
+
}).catch((error) => {
|
|
4069
|
+
this.confirmInFlight = void 0;
|
|
4070
|
+
throw error;
|
|
4071
|
+
});
|
|
4072
|
+
return this.confirmInFlight;
|
|
4073
|
+
}
|
|
4074
|
+
async withdraw(reason) {
|
|
4075
|
+
if (!this.committed) return;
|
|
4076
|
+
await this.options.planHost.withdraw(this.committed.id, reason);
|
|
4077
|
+
}
|
|
4078
|
+
};
|
|
4079
|
+
//#endregion
|
|
4080
|
+
//#region src/interaction-support.ts
|
|
4081
|
+
/**
|
|
4082
|
+
* Per-execution lifecycle wrapper for an MCP-capable harness. Permission and
|
|
4083
|
+
* question tools use the live {@link InteractionBroker}; plan tools commit
|
|
4084
|
+
* through {@link TerminalPlanSupport} and end the provider turn.
|
|
4085
|
+
*
|
|
4086
|
+
* Mirrors the claude-code permission-server pattern, but for the GENERIC
|
|
4087
|
+
* `request_permission` / `ask_user` / `request_plan` tools the agent calls
|
|
4088
|
+
* itself — there is no harness-specific reply contract, so any MCP-capable CLI
|
|
4089
|
+
* (factory-droids, …) composes this with ~10 lines: declare the capability
|
|
4090
|
+
* kinds, start/stop around the spawn, inject the server via the CLI's MCP
|
|
4091
|
+
* mechanism, and forward `respondToInteraction` here.
|
|
4092
|
+
*
|
|
4093
|
+
* Unlike claude-code's `--permission-prompt-tool`, these generic tools fire
|
|
4094
|
+
* only when the model chooses to call them. Permission is therefore advisory,
|
|
4095
|
+
* while plan-capable adapters separately prove the terminal turn boundary.
|
|
4096
|
+
*/
|
|
4097
|
+
var McpInteractionSupport = class {
|
|
4098
|
+
/** The shared emit → block → respond → fail-closed round-trip. */
|
|
4099
|
+
broker = new InteractionBroker$1();
|
|
4100
|
+
activeServer;
|
|
4101
|
+
terminalPlan;
|
|
4102
|
+
constructor(serverName, kinds) {
|
|
4103
|
+
this.serverName = serverName;
|
|
4104
|
+
this.kinds = kinds;
|
|
4105
|
+
}
|
|
4106
|
+
/** The interaction kinds to advertise from `getCapabilities().interactions`. */
|
|
4107
|
+
capabilityKinds() {
|
|
4108
|
+
return [...this.kinds];
|
|
4109
|
+
}
|
|
4110
|
+
/** The active per-execution server (its `url`/`token`), once started. */
|
|
4111
|
+
get server() {
|
|
4112
|
+
return this.activeServer;
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Start a per-execution server bound to 127.0.0.1 with an ephemeral bearer
|
|
4116
|
+
* token, registering only the enabled tools. `emit` is the live event sink;
|
|
4117
|
+
* broker-backed tools block on an answer, while plans emit only after their
|
|
4118
|
+
* terminal confirmation. Returns the server for CLI injection.
|
|
4119
|
+
*/
|
|
4120
|
+
async start(sessionId, binding, emit, plan) {
|
|
4121
|
+
const tools = brokerInteractionTools$1(this.broker, {
|
|
4122
|
+
sessionId,
|
|
4123
|
+
binding,
|
|
4124
|
+
emit
|
|
4125
|
+
});
|
|
4126
|
+
const has = (kind) => this.kinds.includes(kind);
|
|
4127
|
+
if (has(InteractionKind.Plan) && !plan) throw new Error("Plan interaction requires a durable plan host and provider resume state");
|
|
4128
|
+
this.terminalPlan = plan ? new TerminalPlanSupport({
|
|
4129
|
+
...plan,
|
|
4130
|
+
emit
|
|
4131
|
+
}) : void 0;
|
|
4132
|
+
const server = new InteractionMcpServer$1({
|
|
4133
|
+
serverName: this.serverName,
|
|
4134
|
+
onStop: tools.onStop,
|
|
4135
|
+
...has(InteractionKind.Permission) ? { requestPermission: tools.requestPermission } : {},
|
|
4136
|
+
...has(InteractionKind.Question) ? { askUser: tools.askUser } : {},
|
|
4137
|
+
...has(InteractionKind.Plan) && this.terminalPlan ? { requestPlan: ({ plan: body, sourceToolCallId }) => this.terminalPlan.submit(body, sourceToolCallId) } : {}
|
|
4138
|
+
});
|
|
4139
|
+
await server.start();
|
|
4140
|
+
this.activeServer = server;
|
|
4141
|
+
return server;
|
|
4142
|
+
}
|
|
4143
|
+
async confirmSubmittedPlan() {
|
|
4144
|
+
if (!this.terminalPlan) throw new Error("No terminal plan support is active for this turn");
|
|
4145
|
+
return this.terminalPlan.confirm();
|
|
4146
|
+
}
|
|
4147
|
+
async withdrawSubmittedPlan(reason) {
|
|
4148
|
+
await this.terminalPlan?.withdraw(reason);
|
|
4149
|
+
}
|
|
4150
|
+
/** Close the request boundary before inspecting or withdrawing its plan. */
|
|
4151
|
+
async drainSubmittedPlan(sessionId) {
|
|
4152
|
+
this.broker.failSession(sessionId);
|
|
4153
|
+
let stopError;
|
|
4154
|
+
try {
|
|
4155
|
+
await this.activeServer?.stop();
|
|
4156
|
+
this.activeServer = void 0;
|
|
4157
|
+
} catch (error) {
|
|
4158
|
+
stopError = error;
|
|
4159
|
+
}
|
|
4160
|
+
await this.terminalPlan?.settleSubmission();
|
|
4161
|
+
if (stopError) throw stopError;
|
|
4162
|
+
}
|
|
4163
|
+
/**
|
|
4164
|
+
* Fail-closed every pending interaction for the session, then stop the server.
|
|
4165
|
+
* `failSession` runs FIRST so any request still blocked in the server resolves
|
|
4166
|
+
* (deny) before close, and the whole thing swallows errors — it runs in adapter
|
|
4167
|
+
* `finally` blocks and must never mask the turn's real error.
|
|
4168
|
+
*/
|
|
4169
|
+
async stop(sessionId) {
|
|
4170
|
+
try {
|
|
4171
|
+
this.broker.failSession(sessionId);
|
|
4172
|
+
const server = this.activeServer;
|
|
4173
|
+
this.activeServer = void 0;
|
|
4174
|
+
await server?.stop();
|
|
4175
|
+
} catch {}
|
|
4176
|
+
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Route an inbound live `/interactions` response for permission or question.
|
|
4179
|
+
* Plans are durable records and never enter this response channel.
|
|
4180
|
+
*/
|
|
4181
|
+
respond(response) {
|
|
4182
|
+
if (this.broker.respond(response)) return true;
|
|
4183
|
+
return this.broker.respondQuestion(response);
|
|
4184
|
+
}
|
|
4185
|
+
};
|
|
4186
|
+
//#endregion
|
|
4187
|
+
//#region src/metering.ts
|
|
4188
|
+
const UsageRecordSchema = z.record(z.string(), z.unknown());
|
|
4189
|
+
const UsageNumberSchema = z.union([z.number(), z.string().trim().min(1)]).transform(Number).pipe(z.number().finite().nonnegative());
|
|
4190
|
+
const TOKEN_USAGE_INPUT_KEYS = Object.freeze([
|
|
4191
|
+
"inputTokens",
|
|
4192
|
+
"input",
|
|
4193
|
+
"input_tokens",
|
|
4194
|
+
"promptTokens",
|
|
4195
|
+
"prompt_tokens"
|
|
4196
|
+
]);
|
|
4197
|
+
const TOKEN_USAGE_OUTPUT_KEYS = Object.freeze([
|
|
4198
|
+
"outputTokens",
|
|
4199
|
+
"output",
|
|
4200
|
+
"output_tokens",
|
|
4201
|
+
"completionTokens",
|
|
4202
|
+
"completion_tokens"
|
|
4203
|
+
]);
|
|
4204
|
+
const TOKEN_USAGE_TOTAL_KEYS = Object.freeze([
|
|
4205
|
+
"totalTokens",
|
|
4206
|
+
"total_tokens",
|
|
4207
|
+
"total"
|
|
4208
|
+
]);
|
|
4209
|
+
const TOKEN_USAGE_REASONING_KEYS = Object.freeze([
|
|
4210
|
+
"reasoningTokens",
|
|
4211
|
+
"reasoning_tokens",
|
|
4212
|
+
"reasoning",
|
|
4213
|
+
"reasoning_output_tokens"
|
|
4214
|
+
]);
|
|
4215
|
+
const TOKEN_USAGE_COST_KEYS = Object.freeze([
|
|
4216
|
+
"totalCostUsd",
|
|
4217
|
+
"costUsd",
|
|
4218
|
+
"total_cost_usd",
|
|
4219
|
+
"cost_usd",
|
|
4220
|
+
"cost"
|
|
4221
|
+
]);
|
|
4222
|
+
const CACHE_READ_INPUT_KEYS = Object.freeze([
|
|
4223
|
+
"cacheReadInputTokens",
|
|
4224
|
+
"cache_read_input_tokens",
|
|
4225
|
+
"cacheRead",
|
|
4226
|
+
"cache_read",
|
|
4227
|
+
"cached_input_tokens"
|
|
4228
|
+
]);
|
|
4229
|
+
const CACHE_CREATION_INPUT_KEYS = Object.freeze([
|
|
4230
|
+
"cacheCreationInputTokens",
|
|
4231
|
+
"cache_creation_input_tokens",
|
|
4232
|
+
"cacheWrite",
|
|
4233
|
+
"cache_write"
|
|
4234
|
+
]);
|
|
4235
|
+
const NESTED_COST_KEYS = Object.freeze([
|
|
4236
|
+
"total",
|
|
4237
|
+
"totalUsd",
|
|
4238
|
+
"total_usd",
|
|
4239
|
+
"usd"
|
|
4240
|
+
]);
|
|
4241
|
+
function asRecord(raw) {
|
|
4242
|
+
const parsed = UsageRecordSchema.safeParse(raw);
|
|
4243
|
+
return parsed.success ? parsed.data : void 0;
|
|
4244
|
+
}
|
|
4245
|
+
function tokenCount(raw) {
|
|
4246
|
+
const parsed = UsageNumberSchema.safeParse(raw);
|
|
4247
|
+
return parsed.success ? Math.trunc(parsed.data) : void 0;
|
|
4248
|
+
}
|
|
4249
|
+
function firstTokenCount(source, keys) {
|
|
4250
|
+
for (const key of keys) {
|
|
4251
|
+
const count = tokenCount(source[key]);
|
|
4252
|
+
if (count !== void 0) return count;
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
function firstUsageCostUsd(source, keys = TOKEN_USAGE_COST_KEYS) {
|
|
4256
|
+
for (const key of keys) {
|
|
4257
|
+
const parsed = UsageNumberSchema.safeParse(source[key]);
|
|
4258
|
+
if (parsed.success) return parsed.data;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
function usageRecord(source) {
|
|
4262
|
+
return asRecord(source.tokenUsage) ?? asRecord(source.usage) ?? source;
|
|
4263
|
+
}
|
|
4264
|
+
function readTokenUsage(source) {
|
|
4265
|
+
const usageSource = usageRecord(source);
|
|
4266
|
+
const inputTokens = firstTokenCount(usageSource, TOKEN_USAGE_INPUT_KEYS);
|
|
4267
|
+
const outputTokens = firstTokenCount(usageSource, TOKEN_USAGE_OUTPUT_KEYS);
|
|
4268
|
+
return inputTokens !== void 0 || outputTokens !== void 0 ? {
|
|
4269
|
+
inputTokens,
|
|
4270
|
+
outputTokens
|
|
4271
|
+
} : void 0;
|
|
4272
|
+
}
|
|
4273
|
+
function readTokenCostUsd(source) {
|
|
4274
|
+
const usageSource = usageRecord(source);
|
|
4275
|
+
return firstUsageCostUsd(source) ?? firstUsageCostUsd(usageSource) ?? readNestedCostUsd(source) ?? readNestedCostUsd(usageSource);
|
|
4276
|
+
}
|
|
4277
|
+
function readNestedCostUsd(source) {
|
|
4278
|
+
const cost = asRecord(source.cost);
|
|
4279
|
+
return cost ? firstUsageCostUsd(cost, NESTED_COST_KEYS) : void 0;
|
|
4280
|
+
}
|
|
4281
|
+
function readCliTokenUsage(raw) {
|
|
4282
|
+
const parsed = UsageRecordSchema.safeParse(raw);
|
|
4283
|
+
if (!parsed.success) return void 0;
|
|
4284
|
+
const source = parsed.data;
|
|
4285
|
+
const usageSource = usageRecord(source);
|
|
4286
|
+
const usage = readTokenUsage(source) ?? readTokenUsage(usageSource);
|
|
4287
|
+
const cost = readTokenCostUsd(source);
|
|
4288
|
+
const totalTokens = firstTokenCount(usageSource, TOKEN_USAGE_TOTAL_KEYS);
|
|
4289
|
+
const reasoningTokens = firstTokenCount(usageSource, TOKEN_USAGE_REASONING_KEYS);
|
|
4290
|
+
const cacheReadInputTokens = firstTokenCount(usageSource, CACHE_READ_INPUT_KEYS);
|
|
4291
|
+
const cacheCreationInputTokens = firstTokenCount(usageSource, CACHE_CREATION_INPUT_KEYS);
|
|
4292
|
+
if (!usage && totalTokens === void 0 && reasoningTokens === void 0 && cost === void 0 && cacheReadInputTokens === void 0 && cacheCreationInputTokens === void 0) return;
|
|
4293
|
+
return {
|
|
4294
|
+
inputTokens: usage?.inputTokens,
|
|
4295
|
+
outputTokens: usage?.outputTokens,
|
|
4296
|
+
totalTokens,
|
|
4297
|
+
reasoningTokens,
|
|
4298
|
+
cacheReadInputTokens,
|
|
4299
|
+
cacheCreationInputTokens,
|
|
4300
|
+
cost
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
function recordCliTokenUsage(state, ...sources) {
|
|
4304
|
+
for (const source of sources) {
|
|
4305
|
+
const tokenUsage = readCliTokenUsage(source);
|
|
4306
|
+
if (tokenUsage) {
|
|
4307
|
+
state.tokenUsage = tokenUsage;
|
|
4308
|
+
return true;
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
return false;
|
|
4312
|
+
}
|
|
4313
|
+
function addUsage(left, right) {
|
|
4314
|
+
if (left === void 0) return right;
|
|
4315
|
+
if (right === void 0) return left;
|
|
4316
|
+
const sum = left + right;
|
|
4317
|
+
return Number.isFinite(sum) ? sum : Math.max(left, right);
|
|
4318
|
+
}
|
|
4319
|
+
/**
|
|
4320
|
+
* Adds one usage report to the running total instead of replacing it.
|
|
4321
|
+
*
|
|
4322
|
+
* For a CLI that reports usage PER TURN (pi) rather than once per run,
|
|
4323
|
+
* `recordCliTokenUsage` would leave the execution billed as its final turn
|
|
4324
|
+
* alone. Use this wherever a single execution can produce more than one usage
|
|
4325
|
+
* report; use `recordCliTokenUsage` when the CLI emits one cumulative total.
|
|
4326
|
+
*
|
|
4327
|
+
* Returns true when a usage report was found in `sources` and folded in.
|
|
4328
|
+
*/
|
|
4329
|
+
function accumulateCliTokenUsage(state, ...sources) {
|
|
4330
|
+
for (const source of sources) {
|
|
4331
|
+
const tokenUsage = readCliTokenUsage(source);
|
|
4332
|
+
if (!tokenUsage) continue;
|
|
4333
|
+
const total = state.tokenUsage;
|
|
4334
|
+
state.tokenUsage = total ? {
|
|
4335
|
+
inputTokens: addUsage(total.inputTokens, tokenUsage.inputTokens),
|
|
4336
|
+
outputTokens: addUsage(total.outputTokens, tokenUsage.outputTokens),
|
|
4337
|
+
totalTokens: addUsage(total.totalTokens, tokenUsage.totalTokens),
|
|
4338
|
+
reasoningTokens: addUsage(total.reasoningTokens, tokenUsage.reasoningTokens),
|
|
4339
|
+
cacheReadInputTokens: addUsage(total.cacheReadInputTokens, tokenUsage.cacheReadInputTokens),
|
|
4340
|
+
cacheCreationInputTokens: addUsage(total.cacheCreationInputTokens, tokenUsage.cacheCreationInputTokens),
|
|
4341
|
+
cost: addUsage(total.cost, tokenUsage.cost)
|
|
4342
|
+
} : tokenUsage;
|
|
4343
|
+
return true;
|
|
4344
|
+
}
|
|
4345
|
+
return false;
|
|
4346
|
+
}
|
|
4347
|
+
function parseCliUsageLine(line) {
|
|
4348
|
+
if (!line.startsWith("usage:")) return void 0;
|
|
4349
|
+
const raw = line.slice(6).trim();
|
|
4350
|
+
if (!raw) return void 0;
|
|
4351
|
+
if (raw.startsWith("{")) return asRecord(JSON.parse(raw));
|
|
4352
|
+
const fields = {};
|
|
4353
|
+
for (const part of raw.split(/[,\s]+/)) {
|
|
4354
|
+
if (!part) continue;
|
|
4355
|
+
const separator = part.includes("=") ? "=" : part.includes(":") ? ":" : "";
|
|
4356
|
+
if (!separator) continue;
|
|
4357
|
+
const [key, value] = part.split(separator, 2);
|
|
4358
|
+
if (!key || value === void 0) continue;
|
|
4359
|
+
const numeric = Number(value);
|
|
4360
|
+
fields[key] = Number.isFinite(numeric) ? numeric : value;
|
|
4361
|
+
}
|
|
4362
|
+
return Object.keys(fields).length > 0 ? fields : void 0;
|
|
4363
|
+
}
|
|
4364
|
+
//#endregion
|
|
4365
|
+
//#region src/reasoning-effort.ts
|
|
4366
|
+
/** How each CLI takes agent-interface's native control token on its argv. */
|
|
4367
|
+
const EFFORT_FLAG = {
|
|
4368
|
+
"claude-code": (value) => ["--effort", value],
|
|
4369
|
+
codex: (value) => ["-c", `model_reasoning_effort=${value}`],
|
|
4370
|
+
openclaw: (value) => ["--thinking", value],
|
|
4371
|
+
pi: (value) => ["--thinking", value]
|
|
4372
|
+
};
|
|
4373
|
+
/** The argv carrying `nativeReasoningControl` for a profile effort, or none. */
|
|
4374
|
+
function nativeEffortArgs(harness, effort) {
|
|
4375
|
+
const value = nativeReasoningControl(harness, effort ?? null);
|
|
4376
|
+
const flag = EFFORT_FLAG[harness];
|
|
4377
|
+
return value === null || flag === void 0 ? [] : flag(value);
|
|
4378
|
+
}
|
|
4379
|
+
/**
|
|
4380
|
+
* Kimi stays local until #14839: the sandbox ships kimi-cli 1.49.0 as
|
|
4381
|
+
* `kimi-code` (binary `--thinking`/`--no-thinking`) while other paths run
|
|
4382
|
+
* kimi-code 2.x. A model whose name has "thinking" or "reason" always thinks
|
|
4383
|
+
* (kimi-cli `derive_model_capabilities`), so `off` throws there.
|
|
4384
|
+
*/
|
|
4385
|
+
function kimiThinkingArgs(level, modelId) {
|
|
4386
|
+
if (level === void 0) return [];
|
|
4387
|
+
const off = level === "none" || level === "minimal";
|
|
4388
|
+
if (!REASONING_EFFORTS.includes(level) || off && /thinking|reason/i.test(modelId ?? "")) throw new Error(`kimi-code cannot honor reasoning effort "${level}"`);
|
|
4389
|
+
return off ? ["--no-thinking"] : ["--thinking"];
|
|
4390
|
+
}
|
|
4391
|
+
//#endregion
|
|
4392
|
+
export { ACP_MIN_PROTOCOL_VERSION, ACP_PROTOCOL_VERSION, AcpProviderAdapterBase, CliProviderAdapterBase, DEFAULT_INTERACTION_MCP_SERVER_NAME, EXECUTION_TRACE_HEADER, InteractionBroker, InteractionHttpBridge, InteractionMcpServer, InvalidAttachmentError, MANAGED_QUOTE_VERSION_HEADER, MAX_INLINE_IMAGE_BYTES, MAX_TOTAL_INLINE_IMAGE_BYTES, McpInteractionSupport, PERMISSION_DECISION_TIMEOUT_MS, PROMPT_SECTION_SEPARATOR, SessionRehydrationError, TerminalPlanSupport, accumulateCliTokenUsage, acpContentText, brokerInteractionTools, buildFileUrl, createExecutionState, createRepeatedToolFailureGuard, deriveFilePart, deterministicFilePartId, enabledInteractionKinds, executionCorrelationHeaders, finalTextEvent, homeXdgEnv, inferMediaType, interactionMcpToolName, interactionToggleEnabled, isLocalMcpConfig, jsonEventParser, kimiThinkingArgs, localMcpArgv, localMcpEnv, mapAcpStopReason, nativeEffortArgs, parseCliUsageLine, partitionImageAttachments, plaintextResponseParser, preparePromptInputParts, prependPromptSection, readCliTokenUsage, readEphemeralContext, reasoningEvents, recordCliTokenUsage, rejectNotLaunched, runtimeSecretsOverlay, sessionObservedEvents, spawnAcpBridge, streamPartBase, textDeltaEvents, textSnapshotEvents, toolResultEvents, toolStartEvents, trackFilePart, trackReasoningBlock, turnInteractionEnabled, wasNeverLaunched, withEphemeralContext, withTurnInteractions };
|
|
4393
|
+
|
|
4394
|
+
//# sourceMappingURL=index.mjs.map
|