@qilin-agent/acp 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +82 -0
- package/README.md +180 -2
- package/README.zh.md +181 -0
- package/lib/index.js +1425 -0
- package/lib/types/codec.d.ts +13 -0
- package/lib/types/content.d.ts +50 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/mcp.d.ts +15 -0
- package/lib/types/model-control.d.ts +62 -0
- package/lib/types/session.d.ts +124 -0
- package/lib/types/updates.d.ts +26 -0
- package/package.json +66 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1425 @@
|
|
|
1
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { Readable, Writable } from "node:stream";
|
|
6
|
+
import Schema from "@qilin-agent/schemastery";
|
|
7
|
+
import { brandString } from "@qilin-agent/brand";
|
|
8
|
+
import { ReasoningEffortId, createUserMessage, errorChain } from "@qilin-agent/llm";
|
|
9
|
+
import { PROTOCOL_VERSION, RequestError, agent, methods, ndJsonStream } from "@agentclientprotocol/sdk";
|
|
10
|
+
import { isImageAdmissionError } from "@qilin-agent/attachment";
|
|
11
|
+
import { validateHeaderName, validateHeaderValue } from "node:http";
|
|
12
|
+
import * as McpClient from "@qilin-agent/mcp-client";
|
|
13
|
+
import { installModelSelection } from "@qilin-agent/agent";
|
|
14
|
+
//#region lib/types/content.js
|
|
15
|
+
/** ACP wire-content admission and projection owned by the ACP adapter. @module */
|
|
16
|
+
/** Raster formats shared by ACP image blocks and the core attachment vocabulary. */
|
|
17
|
+
const IMAGE_MEDIA_TYPES = [
|
|
18
|
+
"image/png",
|
|
19
|
+
"image/jpeg",
|
|
20
|
+
"image/webp",
|
|
21
|
+
"image/gif"
|
|
22
|
+
];
|
|
23
|
+
/** Canonical RFC 4648 base64, excluding whitespace and URL-safe aliases. */
|
|
24
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
25
|
+
/** Error with a stable ACP request-failure category and no raw binary payload. */
|
|
26
|
+
var AcpContentError = class extends Error {
|
|
27
|
+
/** Whether the bridge should report invalid params or an internal failure. */
|
|
28
|
+
kind;
|
|
29
|
+
/**
|
|
30
|
+
* @param message - safe protocol-facing detail without inline binary data.
|
|
31
|
+
* @param kind - request-failure category.
|
|
32
|
+
* @param options - optional causal chain for diagnostics.
|
|
33
|
+
*/
|
|
34
|
+
constructor(message, kind, options) {
|
|
35
|
+
super(message, options);
|
|
36
|
+
this.name = "AcpContentError";
|
|
37
|
+
this.kind = kind;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
/** Narrow a wire MIME string to the durable raster vocabulary. */
|
|
41
|
+
function imageMediaType(value) {
|
|
42
|
+
return IMAGE_MEDIA_TYPES.includes(value) ? value : void 0;
|
|
43
|
+
}
|
|
44
|
+
/** Strictly decode one ACP inline image without accepting base64 aliases. */
|
|
45
|
+
function decodeImage(block) {
|
|
46
|
+
const mediaType = imageMediaType(block.mimeType);
|
|
47
|
+
if (mediaType === void 0) throw new AcpContentError("image mimeType must be image/png, image/jpeg, image/webp, or image/gif", "invalid");
|
|
48
|
+
if (!CANONICAL_BASE64.test(block.data)) throw new AcpContentError("image data must be canonical base64", "invalid");
|
|
49
|
+
const data = Buffer.from(block.data, "base64");
|
|
50
|
+
if (data.toString("base64") !== block.data) throw new AcpContentError("image data must be canonical base64", "invalid");
|
|
51
|
+
return {
|
|
52
|
+
data,
|
|
53
|
+
mediaType
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Resolve the exact current route and require explicit image input support. */
|
|
57
|
+
async function assertImageRoute(ctx, route, signal) {
|
|
58
|
+
const provider = route?.provider;
|
|
59
|
+
const model = route?.model;
|
|
60
|
+
const llm = ctx.get("llm");
|
|
61
|
+
if (provider === void 0 || model === void 0 || llm === void 0) throw new AcpContentError("the current model route could not be resolved for image input", "invalid");
|
|
62
|
+
let info;
|
|
63
|
+
try {
|
|
64
|
+
info = await llm.resolveModelInfo(provider, model, signal);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new AcpContentError("the current model route could not be verified for image input", "internal", { cause: error });
|
|
67
|
+
}
|
|
68
|
+
if (info.inputModalities === void 0 || !info.inputModalities.includes("image")) throw new AcpContentError(`model "${model}" does not declare image input`, "invalid");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Determine whether initialization may truthfully advertise inline image prompts.
|
|
72
|
+
* Unknown service, route, capability, or deployment media support is negative.
|
|
73
|
+
* @param ctx - bridge context carrying optional attachment and model services.
|
|
74
|
+
* @param provider - configured provider route used for newly created sessions.
|
|
75
|
+
* @param model - configured exact model id used for newly created sessions.
|
|
76
|
+
* @returns whether this bridge can admit images at initialization time.
|
|
77
|
+
*/
|
|
78
|
+
async function supportsAcpImagePrompts(ctx, provider, model) {
|
|
79
|
+
const attachments = ctx.get("attachments");
|
|
80
|
+
const llm = ctx.get("llm");
|
|
81
|
+
if (attachments === void 0 || llm === void 0 || provider === void 0 || model === void 0) return false;
|
|
82
|
+
if (!attachments.imageLimits.mediaTypes.some((mediaType) => IMAGE_MEDIA_TYPES.includes(mediaType))) return false;
|
|
83
|
+
try {
|
|
84
|
+
return (await llm.resolveModelInfo(provider, model)).inputModalities?.includes("image") === true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Render one baseline resource link into the core's current text vocabulary. */
|
|
90
|
+
function resourceLinkText(block) {
|
|
91
|
+
return `\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Admit one ACP prompt into ordered durable core content.
|
|
95
|
+
* Every wire block and image is validated before the ordered image batch starts
|
|
96
|
+
* writing; cancellation after a successful content-addressed write may leave an
|
|
97
|
+
* unreachable object but never queues a late user message.
|
|
98
|
+
* @param ctx - bridge context carrying attachment and model services.
|
|
99
|
+
* @param route - selection pinned to the accepted prompt.
|
|
100
|
+
* @param prompt - untrusted ACP prompt blocks in wire order.
|
|
101
|
+
* @param imageEnabled - capability result advertised during initialization.
|
|
102
|
+
* @param signal - admission cancellation signal.
|
|
103
|
+
* @returns core content with durable image references in wire order.
|
|
104
|
+
*/
|
|
105
|
+
async function admitAcpPrompt(ctx, route, prompt, imageEnabled, signal) {
|
|
106
|
+
const images = [];
|
|
107
|
+
for (const block of prompt) switch (block.type) {
|
|
108
|
+
case "text":
|
|
109
|
+
case "resource_link": break;
|
|
110
|
+
case "image":
|
|
111
|
+
if (!imageEnabled) throw new AcpContentError("inline image prompts were not advertised by this connection", "invalid");
|
|
112
|
+
images.push(decodeImage(block));
|
|
113
|
+
break;
|
|
114
|
+
case "audio": throw new AcpContentError("audio prompt content is not supported", "invalid");
|
|
115
|
+
case "resource": throw new AcpContentError("embedded resource prompt content is not supported", "invalid");
|
|
116
|
+
/* v8 ignore next 2 -- ACP ContentBlock is a closed generated union. */
|
|
117
|
+
default: throw new AcpContentError("unsupported ACP prompt content", "invalid");
|
|
118
|
+
}
|
|
119
|
+
let refs = [];
|
|
120
|
+
if (images.length > 0) {
|
|
121
|
+
const attachments = ctx.get("attachments");
|
|
122
|
+
if (attachments === void 0) throw new AcpContentError("no attachment store is mounted", "invalid");
|
|
123
|
+
await assertImageRoute(ctx, route, signal);
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
try {
|
|
126
|
+
refs = await attachments.saveImages(images);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (isImageAdmissionError(error)) throw new AcpContentError(error.message, "invalid", { cause: error });
|
|
129
|
+
throw new AcpContentError("unable to persist the prompt image batch", "internal", { cause: error });
|
|
130
|
+
}
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
}
|
|
133
|
+
const content = [];
|
|
134
|
+
let pendingText = "";
|
|
135
|
+
let imageIndex = 0;
|
|
136
|
+
const flushText = () => {
|
|
137
|
+
if (pendingText.length === 0) return;
|
|
138
|
+
content.push({
|
|
139
|
+
type: "text",
|
|
140
|
+
text: pendingText
|
|
141
|
+
});
|
|
142
|
+
pendingText = "";
|
|
143
|
+
};
|
|
144
|
+
for (const block of prompt) switch (block.type) {
|
|
145
|
+
case "text":
|
|
146
|
+
pendingText += block.text;
|
|
147
|
+
break;
|
|
148
|
+
case "resource_link":
|
|
149
|
+
pendingText += resourceLinkText(block);
|
|
150
|
+
break;
|
|
151
|
+
case "image": {
|
|
152
|
+
flushText();
|
|
153
|
+
const ref = refs[imageIndex++];
|
|
154
|
+
content.push({
|
|
155
|
+
type: "image",
|
|
156
|
+
attachment: ref
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
/* v8 ignore start -- the validation pass above rejects both tags before reconstruction. */
|
|
161
|
+
case "audio":
|
|
162
|
+
case "resource": break;
|
|
163
|
+
/* v8 ignore stop */
|
|
164
|
+
/* v8 ignore next 2 -- validated by the first closed-union switch. */
|
|
165
|
+
default: break;
|
|
166
|
+
}
|
|
167
|
+
flushText();
|
|
168
|
+
if (!content.some((block) => block.type === "image" || block.type === "text" && block.text.trim().length > 0)) throw new AcpContentError("empty prompt", "invalid");
|
|
169
|
+
return content;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Translate one committed assistant block to ACP wire content.
|
|
173
|
+
* Images are re-read and integrity-verified before inline base64 delivery;
|
|
174
|
+
* unsupported core output blocks stay off the automation wire.
|
|
175
|
+
* @param ctx - bridge context carrying the authoritative attachment store.
|
|
176
|
+
* @param block - committed core assistant block.
|
|
177
|
+
* @returns ACP text/image content, or undefined for non-output blocks.
|
|
178
|
+
*/
|
|
179
|
+
async function assistantBlockToAcp(ctx, block) {
|
|
180
|
+
if (block.type === "text") return block.text.length === 0 ? void 0 : {
|
|
181
|
+
type: "text",
|
|
182
|
+
text: block.text
|
|
183
|
+
};
|
|
184
|
+
if (block.type !== "image") return void 0;
|
|
185
|
+
const attachments = ctx.get("attachments");
|
|
186
|
+
if (attachments === void 0) throw new AcpContentError("cannot deliver assistant image: no attachment store is mounted", "internal");
|
|
187
|
+
let stored;
|
|
188
|
+
try {
|
|
189
|
+
stored = await attachments.readImage(block.attachment);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new AcpContentError("cannot deliver assistant image: the attachment is unavailable or corrupt", "internal", { cause: error });
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
type: "image",
|
|
195
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
196
|
+
mimeType: stored.ref.mediaType
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region lib/types/mcp.js
|
|
201
|
+
/** Standard ACP MCP-server declarations translated into Agent-scoped QILIN MCP clients. */
|
|
202
|
+
const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
203
|
+
/** Caller-correctable MCP declaration failure. */
|
|
204
|
+
var AcpMcpConfigError = class extends Error {
|
|
205
|
+
constructor(message) {
|
|
206
|
+
super(message);
|
|
207
|
+
this.name = "AcpMcpConfigError";
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Validate and mount one session's complete standard MCP server list before Agent publication.
|
|
212
|
+
* @param agentCtx - unpublished Agent scope that owns the MCP clients and tools.
|
|
213
|
+
* @param servers - stable ACP stdio or HTTP server declarations.
|
|
214
|
+
* @param sessionCwd - canonical primary workspace used by stdio servers.
|
|
215
|
+
*/
|
|
216
|
+
async function mountAcpMcpServers(agentCtx, servers, sessionCwd) {
|
|
217
|
+
const configs = resolveMcpConfigs(servers, sessionCwd);
|
|
218
|
+
for (const config of configs) await agentCtx.plugin(McpClient, config);
|
|
219
|
+
}
|
|
220
|
+
/** Convert the stable stdio/HTTP ACP transports and reject every other transport. */
|
|
221
|
+
function resolveMcpConfigs(servers, sessionCwd) {
|
|
222
|
+
const names = /* @__PURE__ */ new Set();
|
|
223
|
+
return servers.map((server, index) => {
|
|
224
|
+
const serverName = normalizeServerName(server.name);
|
|
225
|
+
if (names.has(serverName)) throw new AcpMcpConfigError(`mcpServers contains duplicate normalized name: ${serverName}`);
|
|
226
|
+
names.add(serverName);
|
|
227
|
+
if (!("type" in server)) {
|
|
228
|
+
if (!isAbsolute(server.command)) throw new AcpMcpConfigError(`mcpServers[${index}].command must be an absolute path`);
|
|
229
|
+
const env = entriesToRecord(server.env, `mcpServers[${index}].env`, "environment");
|
|
230
|
+
return {
|
|
231
|
+
...validateClientConfig(index, () => McpClient.Config({
|
|
232
|
+
transport: "stdio",
|
|
233
|
+
serverName,
|
|
234
|
+
command: server.command,
|
|
235
|
+
args: server.args,
|
|
236
|
+
env,
|
|
237
|
+
cwd: sessionCwd,
|
|
238
|
+
failOnStartupError: true
|
|
239
|
+
})),
|
|
240
|
+
env
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (server.type === "http") {
|
|
244
|
+
assertHttpUrl(server.url, `mcpServers[${index}].url`);
|
|
245
|
+
const headers = entriesToRecord(server.headers, `mcpServers[${index}].headers`, "header");
|
|
246
|
+
return {
|
|
247
|
+
...validateClientConfig(index, () => McpClient.Config({
|
|
248
|
+
transport: "streamable-http",
|
|
249
|
+
serverName,
|
|
250
|
+
url: server.url,
|
|
251
|
+
headers,
|
|
252
|
+
failOnStartupError: true
|
|
253
|
+
})),
|
|
254
|
+
headers
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
throw new AcpMcpConfigError(`mcpServers[${index}] transport ${server.type} is not supported`);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** Convert ordered ACP name/value entries without silently accepting duplicate keys. */
|
|
261
|
+
function entriesToRecord(entries, field, kind) {
|
|
262
|
+
const result = Object.create(null);
|
|
263
|
+
const names = /* @__PURE__ */ new Set();
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (kind === "header") try {
|
|
266
|
+
validateHeaderName(entry.name);
|
|
267
|
+
validateHeaderValue(entry.name, entry.value);
|
|
268
|
+
} catch (_invalidHeader) {
|
|
269
|
+
throw new AcpMcpConfigError(`${field} contains an invalid header entry`);
|
|
270
|
+
}
|
|
271
|
+
else if (entry.name.length === 0 || entry.name.includes("=") || entry.name.includes("\0") || entry.value.includes("\0")) throw new AcpMcpConfigError(`${field} contains an invalid environment entry`);
|
|
272
|
+
const identity = kind === "header" ? entry.name.toLowerCase() : entry.name;
|
|
273
|
+
if (names.has(identity)) throw new AcpMcpConfigError(`${field} contains duplicate name: ${entry.name}`);
|
|
274
|
+
names.add(identity);
|
|
275
|
+
result[entry.name] = entry.value;
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
/** Produce a stable QILIN tool namespace from ACP's human-readable server name. */
|
|
280
|
+
function normalizeServerName(name) {
|
|
281
|
+
if (name.trim().length === 0 || /[\u0000-\u001f\u007f]/.test(name)) throw new AcpMcpConfigError("mcpServers contains an invalid server name");
|
|
282
|
+
if (VALID_SERVER_NAME.test(name)) return name;
|
|
283
|
+
return `${name.normalize("NFKD").replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 20) || "server"}_${createHash("sha256").update(name).digest("hex").slice(0, 8)}`.slice(0, 32);
|
|
284
|
+
}
|
|
285
|
+
/** Require the stable Streamable HTTP transport URL schemes. */
|
|
286
|
+
function assertHttpUrl(value, field) {
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(value);
|
|
289
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("unsupported protocol");
|
|
290
|
+
} catch (_invalidUrl) {
|
|
291
|
+
throw new AcpMcpConfigError(`${field} must be an absolute HTTP(S) URL`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Map the existing MCP provider's schema error into ACP invalid params. */
|
|
295
|
+
function validateClientConfig(index, parse) {
|
|
296
|
+
try {
|
|
297
|
+
return parse();
|
|
298
|
+
} catch (error) {
|
|
299
|
+
throw new AcpMcpConfigError(`mcpServers[${index}] is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region lib/types/model-control.js
|
|
304
|
+
/** Standard ACP session configuration over one Agent's model selection. */
|
|
305
|
+
const MODEL_CONFIG_ID = "model";
|
|
306
|
+
const REASONING_CONFIG_ID = "reasoning_effort";
|
|
307
|
+
const PROVIDER_DEFAULT_REASONING_VALUE = "";
|
|
308
|
+
/** Caller-correctable session configuration failure. */
|
|
309
|
+
var AcpModelConfigError = class extends Error {
|
|
310
|
+
constructor(message) {
|
|
311
|
+
super(message);
|
|
312
|
+
this.name = "AcpModelConfigError";
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
/** Project and mutate one Agent's provider/model/reasoning selection through ACP config options. */
|
|
316
|
+
var AcpModelControl = class {
|
|
317
|
+
llm;
|
|
318
|
+
/** Scoped selection reference consumed by Agent request assembly. */
|
|
319
|
+
selection;
|
|
320
|
+
tail = Promise.resolve();
|
|
321
|
+
selected;
|
|
322
|
+
turnSelection;
|
|
323
|
+
hasResolvedState = false;
|
|
324
|
+
constructor(llm, initial) {
|
|
325
|
+
this.llm = llm;
|
|
326
|
+
this.selected = initial;
|
|
327
|
+
const getCurrent = () => this.turnSelection?.selection ?? this.selected;
|
|
328
|
+
const setCurrent = (value) => {
|
|
329
|
+
this.selected = value;
|
|
330
|
+
};
|
|
331
|
+
this.selection = {
|
|
332
|
+
get current() {
|
|
333
|
+
return getCurrent();
|
|
334
|
+
},
|
|
335
|
+
set current(value) {
|
|
336
|
+
setCurrent(value);
|
|
337
|
+
},
|
|
338
|
+
assembled: void 0
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Install request/prompt consistency listeners in the unpublished Agent scope.
|
|
343
|
+
* @param agentCtx - Agent scope that consumes this selection.
|
|
344
|
+
*/
|
|
345
|
+
install(agentCtx) {
|
|
346
|
+
installModelSelection(agentCtx, this.selection);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Snapshot the selection attached to the next accepted ACP prompt.
|
|
350
|
+
* @returns a detached future selection, or undefined when listeners supply the route.
|
|
351
|
+
*/
|
|
352
|
+
snapshot() {
|
|
353
|
+
return this.selected === void 0 ? void 0 : { ...this.selected };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Pin one admitted ACP message's selection for every step in its turn.
|
|
357
|
+
* @param turn - admitted Agent turn.
|
|
358
|
+
* @param selection - exact prompt-admission selection.
|
|
359
|
+
*/
|
|
360
|
+
pinTurn(turn, selection) {
|
|
361
|
+
this.turnSelection = {
|
|
362
|
+
turn,
|
|
363
|
+
selection: { ...selection }
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Release only the exact completed turn's routing override.
|
|
368
|
+
* @param turn - completed Agent turn.
|
|
369
|
+
*/
|
|
370
|
+
releaseTurn(turn) {
|
|
371
|
+
if (this.turnSelection?.turn === turn) this.turnSelection = void 0;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Return the complete standard config-option state after prior mutations settle.
|
|
375
|
+
* @param signal - optional catalog and exact-model cancellation.
|
|
376
|
+
* @returns all current standard configuration options.
|
|
377
|
+
*/
|
|
378
|
+
options(signal) {
|
|
379
|
+
return this.serialize(async () => (await this.state(signal)).options);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Set one advertised option and return the complete resulting option state.
|
|
383
|
+
* @param configId - standard option id.
|
|
384
|
+
* @param value - opaque selected value returned by a previous option state.
|
|
385
|
+
* @param signal - optional catalog and exact-model cancellation.
|
|
386
|
+
* @returns all standard options after the serialized mutation.
|
|
387
|
+
*/
|
|
388
|
+
set(configId, value, signal) {
|
|
389
|
+
return this.serialize(async () => {
|
|
390
|
+
if (typeof value !== "string") throw new AcpModelConfigError(`${configId} requires a select value`);
|
|
391
|
+
const current = this.selected;
|
|
392
|
+
if (current === void 0) throw new AcpModelConfigError("this session has no model selection");
|
|
393
|
+
if (configId === MODEL_CONFIG_ID) {
|
|
394
|
+
const selected = (await this.state(signal)).choices.get(value);
|
|
395
|
+
if (selected === void 0) throw new AcpModelConfigError(`unknown model option: ${value}`);
|
|
396
|
+
await this.resolveSelection(selected, signal);
|
|
397
|
+
this.selected = selected;
|
|
398
|
+
} else if (configId === REASONING_CONFIG_ID) {
|
|
399
|
+
const info = await this.llm.resolveModelInfo(current.provider, current.model, signal);
|
|
400
|
+
const providerDefault = value === PROVIDER_DEFAULT_REASONING_VALUE && info.reasoning?.defaultEffort === void 0;
|
|
401
|
+
if (info.reasoning === void 0 || !providerDefault && !info.reasoning.efforts.some((effort) => effort.id === value)) throw new AcpModelConfigError(`unknown reasoning effort for ${current.provider}/${current.model}: ${value}`);
|
|
402
|
+
this.selected = await this.resolveSelection({
|
|
403
|
+
provider: current.provider,
|
|
404
|
+
model: current.model,
|
|
405
|
+
...providerDefault ? {} : { reasoningEffort: ReasoningEffortId(value) }
|
|
406
|
+
}, signal);
|
|
407
|
+
} else throw new AcpModelConfigError(`unknown session config option: ${configId}`);
|
|
408
|
+
return (await this.state(signal)).options;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
/** Keep concurrent client mutations in receive order without wedging after rejection. */
|
|
412
|
+
serialize(operation) {
|
|
413
|
+
const result = this.tail.then(operation);
|
|
414
|
+
this.tail = result.then(() => void 0, () => void 0);
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
/** Build detached model choices and the dependent reasoning option. */
|
|
418
|
+
async state(signal) {
|
|
419
|
+
const selected = this.selected;
|
|
420
|
+
if (selected === void 0) return {
|
|
421
|
+
choices: /* @__PURE__ */ new Map(),
|
|
422
|
+
options: []
|
|
423
|
+
};
|
|
424
|
+
let resolved;
|
|
425
|
+
let routeAvailable = true;
|
|
426
|
+
try {
|
|
427
|
+
resolved = await this.resolveSelection(selected, signal);
|
|
428
|
+
this.hasResolvedState = true;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (!this.hasResolvedState) throw error;
|
|
431
|
+
resolved = selected;
|
|
432
|
+
routeAvailable = false;
|
|
433
|
+
}
|
|
434
|
+
const choices = /* @__PURE__ */ new Map();
|
|
435
|
+
const groups = await Promise.all(this.llm.listProviders().map(async (provider) => {
|
|
436
|
+
try {
|
|
437
|
+
const entries = (await this.llm.listModels(provider.id)).map((model) => {
|
|
438
|
+
const choice = {
|
|
439
|
+
value: modelValue(provider.id, model.id),
|
|
440
|
+
selection: {
|
|
441
|
+
provider: provider.id,
|
|
442
|
+
model: model.id
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
choices.set(choice.value, choice.selection);
|
|
446
|
+
return {
|
|
447
|
+
value: choice.value,
|
|
448
|
+
name: model.name,
|
|
449
|
+
...model.description === void 0 ? {} : { description: model.description }
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
return {
|
|
453
|
+
group: provider.id,
|
|
454
|
+
name: provider.name,
|
|
455
|
+
options: entries
|
|
456
|
+
};
|
|
457
|
+
} catch (_providerCatalogUnavailable) {
|
|
458
|
+
return {
|
|
459
|
+
group: provider.id,
|
|
460
|
+
name: provider.name,
|
|
461
|
+
options: []
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}));
|
|
465
|
+
const currentValue = modelValue(resolved.provider, resolved.model);
|
|
466
|
+
if (!choices.has(currentValue)) {
|
|
467
|
+
choices.set(currentValue, {
|
|
468
|
+
provider: resolved.provider,
|
|
469
|
+
model: resolved.model
|
|
470
|
+
});
|
|
471
|
+
let group = groups.find((item) => item.group === resolved.provider);
|
|
472
|
+
if (group === void 0) {
|
|
473
|
+
group = {
|
|
474
|
+
group: resolved.provider,
|
|
475
|
+
name: resolved.provider,
|
|
476
|
+
options: []
|
|
477
|
+
};
|
|
478
|
+
groups.push(group);
|
|
479
|
+
}
|
|
480
|
+
group.options.unshift({
|
|
481
|
+
value: currentValue,
|
|
482
|
+
name: resolved.model
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
const options = [{
|
|
486
|
+
id: MODEL_CONFIG_ID,
|
|
487
|
+
name: "Model",
|
|
488
|
+
category: "model",
|
|
489
|
+
type: "select",
|
|
490
|
+
currentValue,
|
|
491
|
+
options: groups.filter((group) => group.options.length > 0)
|
|
492
|
+
}];
|
|
493
|
+
const info = routeAvailable ? await this.llm.resolveModelInfo(resolved.provider, resolved.model, signal) : void 0;
|
|
494
|
+
if (info?.reasoning !== void 0) options.push({
|
|
495
|
+
id: REASONING_CONFIG_ID,
|
|
496
|
+
name: "Reasoning effort",
|
|
497
|
+
category: "thought_level",
|
|
498
|
+
type: "select",
|
|
499
|
+
currentValue: resolved.reasoningEffort === void 0 ? PROVIDER_DEFAULT_REASONING_VALUE : String(resolved.reasoningEffort),
|
|
500
|
+
options: [...info.reasoning.defaultEffort === void 0 ? [{
|
|
501
|
+
value: PROVIDER_DEFAULT_REASONING_VALUE,
|
|
502
|
+
name: "Provider default"
|
|
503
|
+
}] : [], ...info.reasoning.efforts.map((effort) => ({
|
|
504
|
+
value: String(effort.id),
|
|
505
|
+
name: effort.name,
|
|
506
|
+
...effort.description === void 0 ? {} : { description: effort.description }
|
|
507
|
+
}))]
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
choices,
|
|
511
|
+
options
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
/** Validate an exact route and retain only Agent-owned selection fields. */
|
|
515
|
+
async resolveSelection(selection, signal) {
|
|
516
|
+
const resolved = await this.llm.resolveCallConfig(selection, signal);
|
|
517
|
+
return {
|
|
518
|
+
provider: resolved.provider,
|
|
519
|
+
model: resolved.model,
|
|
520
|
+
...resolved.reasoningEffort === void 0 ? {} : { reasoningEffort: resolved.reasoningEffort }
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
/** Opaque ACP selector value carrying the full route identity. */
|
|
525
|
+
function modelValue(provider, model) {
|
|
526
|
+
return JSON.stringify([provider, model]);
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region lib/types/codec.js
|
|
530
|
+
/**
|
|
531
|
+
* Pure translation between the harness lifecycle and the automation-only ACP wire.
|
|
532
|
+
* @module @qilin-agent/acp/codec
|
|
533
|
+
*/
|
|
534
|
+
/**
|
|
535
|
+
* Map a harness turn ending to ACP's terminal reason vocabulary.
|
|
536
|
+
* @param reason - harness turn outcome.
|
|
537
|
+
* @returns the closest legal ACP stop reason.
|
|
538
|
+
*/
|
|
539
|
+
function turnEndToStopReason(reason) {
|
|
540
|
+
switch (reason.kind) {
|
|
541
|
+
case "completed": return "end_turn";
|
|
542
|
+
case "max-tokens": return "max_tokens";
|
|
543
|
+
case "aborted": return "end_turn";
|
|
544
|
+
case "interrupted": return "cancelled";
|
|
545
|
+
case "blocked":
|
|
546
|
+
case "error": return "end_turn";
|
|
547
|
+
/* v8 ignore next 2 -- TurnEndReason is merge-extensible; every live-turn member is
|
|
548
|
+
* handled above, and seed-only variants (`forked`) never end an ACP prompt turn. */
|
|
549
|
+
default: return "end_turn";
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region lib/types/updates.js
|
|
554
|
+
/** Standard ACP updates derived from committed QILIN session events. */
|
|
555
|
+
/**
|
|
556
|
+
* Convert one committed assistant message and its context usage in block order.
|
|
557
|
+
* @param ctx - bridge context carrying attachment and token-meter services.
|
|
558
|
+
* @param session - durable session used for context pressure.
|
|
559
|
+
* @param event - committed assistant message event.
|
|
560
|
+
* @returns ordered standard thought, message, and optional usage updates.
|
|
561
|
+
*/
|
|
562
|
+
async function assistantUpdates(ctx, session, event) {
|
|
563
|
+
const updates = [];
|
|
564
|
+
for (const block of event.data.message.content) {
|
|
565
|
+
if (block.type === "reasoning") {
|
|
566
|
+
if (block.text.length > 0) updates.push({
|
|
567
|
+
sessionUpdate: "agent_thought_chunk",
|
|
568
|
+
messageId: event.data.message.id,
|
|
569
|
+
content: {
|
|
570
|
+
type: "text",
|
|
571
|
+
text: block.text
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
const content = await assistantBlockToAcp(ctx, block);
|
|
577
|
+
if (content !== void 0) updates.push({
|
|
578
|
+
sessionUpdate: "agent_message_chunk",
|
|
579
|
+
messageId: event.data.message.id,
|
|
580
|
+
content
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const usage = usageUpdate(ctx, session, event);
|
|
584
|
+
if (usage !== void 0) updates.push(usage);
|
|
585
|
+
return updates;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Start one generic ACP tool lifecycle from the durable call fact.
|
|
589
|
+
* @param event - committed QILIN tool-call event.
|
|
590
|
+
* @returns the standard generic tool-call update.
|
|
591
|
+
*/
|
|
592
|
+
function toolCallUpdate(event) {
|
|
593
|
+
return {
|
|
594
|
+
sessionUpdate: "tool_call",
|
|
595
|
+
toolCallId: event.data.callId,
|
|
596
|
+
title: event.data.name,
|
|
597
|
+
kind: "other",
|
|
598
|
+
status: "in_progress",
|
|
599
|
+
rawInput: parseToolArguments(event.data.arguments)
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Finish one generic ACP tool lifecycle from its committed model-facing result.
|
|
604
|
+
* @param ctx - bridge context carrying the attachment store.
|
|
605
|
+
* @param event - committed QILIN tool-result event.
|
|
606
|
+
* @returns the standard completed or failed tool-call update.
|
|
607
|
+
*/
|
|
608
|
+
async function toolResultUpdate(ctx, event) {
|
|
609
|
+
const message = event.data.message;
|
|
610
|
+
const content = [];
|
|
611
|
+
for (const block of message.content) {
|
|
612
|
+
const converted = await assistantBlockToAcp(ctx, block);
|
|
613
|
+
if (converted !== void 0) content.push({
|
|
614
|
+
type: "content",
|
|
615
|
+
content: converted
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
sessionUpdate: "tool_call_update",
|
|
620
|
+
toolCallId: message.toolCallId,
|
|
621
|
+
status: message.isError === true ? "failed" : "completed",
|
|
622
|
+
content
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/** Report current context occupancy only when QILIN has both usage and capacity facts. */
|
|
626
|
+
function usageUpdate(ctx, session, event) {
|
|
627
|
+
if (event.data.usage === void 0) return void 0;
|
|
628
|
+
const size = session.requestContext()?.contextWindow;
|
|
629
|
+
const meter = ctx.get("tokenMeter");
|
|
630
|
+
if (size === void 0 || meter === void 0) return void 0;
|
|
631
|
+
return {
|
|
632
|
+
sessionUpdate: "usage_update",
|
|
633
|
+
used: meter.measure(session).totalTokens,
|
|
634
|
+
size
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/** Preserve malformed model output as opaque input instead of dropping the call update. */
|
|
638
|
+
function parseToolArguments(value) {
|
|
639
|
+
try {
|
|
640
|
+
return JSON.parse(value);
|
|
641
|
+
} catch (_invalidModelJson) {
|
|
642
|
+
return value;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region lib/types/session.js
|
|
647
|
+
/** One standard ACP session's Agent, configuration, prompt, update, and teardown lifecycle. */
|
|
648
|
+
/** Standard invalid-parameter failure with protocol-safe detail. */
|
|
649
|
+
function invalidParams$1(detail) {
|
|
650
|
+
return RequestError.invalidParams(void 0, detail);
|
|
651
|
+
}
|
|
652
|
+
/** Standard internal failure with protocol-safe detail. */
|
|
653
|
+
function internalError$1(detail) {
|
|
654
|
+
return RequestError.internalError(void 0, detail);
|
|
655
|
+
}
|
|
656
|
+
/** Restore the latest logged route before falling back to deployment config. */
|
|
657
|
+
function selectionFor(logged, fallback) {
|
|
658
|
+
return logged === void 0 ? fallback : {
|
|
659
|
+
provider: logged.config.provider,
|
|
660
|
+
model: logged.config.model,
|
|
661
|
+
...logged.config.reasoningEffort === void 0 || logged.adapterDefaults?.reasoningEffort === true ? {} : { reasoningEffort: logged.config.reasoningEffort }
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Per-session ACP module. It owns the unpublished Agent composition, selected
|
|
666
|
+
* route, one-prompt admission slot, ordered standard updates, and memoized
|
|
667
|
+
* quiescent teardown.
|
|
668
|
+
*/
|
|
669
|
+
var AcpSession = class AcpSession {
|
|
670
|
+
ctx;
|
|
671
|
+
notify;
|
|
672
|
+
/** The exact top-level Agent owned by this ACP session. */
|
|
673
|
+
agent;
|
|
674
|
+
modelControl;
|
|
675
|
+
outputTail = Promise.resolve();
|
|
676
|
+
inflight;
|
|
677
|
+
closing;
|
|
678
|
+
pendingSelections = /* @__PURE__ */ new Map();
|
|
679
|
+
constructor(ctx, handle, modelControl, notify) {
|
|
680
|
+
this.ctx = ctx;
|
|
681
|
+
this.notify = notify;
|
|
682
|
+
this.agent = handle.agent;
|
|
683
|
+
this.modelControl = modelControl;
|
|
684
|
+
this.disposeAgent = () => handle.dispose();
|
|
685
|
+
}
|
|
686
|
+
disposeAgent;
|
|
687
|
+
/**
|
|
688
|
+
* Compose a fresh Agent and all requested MCP clients before publication.
|
|
689
|
+
* @param ctx - ACP plugin context with Agent, LLM, and persistence services.
|
|
690
|
+
* @param options - fresh session identity, workspace, route, MCP, and notifier.
|
|
691
|
+
* @returns the fully composed per-session module.
|
|
692
|
+
*/
|
|
693
|
+
static async create(ctx, options) {
|
|
694
|
+
const modelControl = new AcpModelControl(ctx.llm, options.fallbackSelection);
|
|
695
|
+
return new AcpSession(ctx, await ctx.agents.create({
|
|
696
|
+
sessionId: options.sessionId,
|
|
697
|
+
meta: { cwd: options.cwd },
|
|
698
|
+
agentOptions: options.agentOptions,
|
|
699
|
+
signal: options.signal,
|
|
700
|
+
setup: async (agentCtx) => {
|
|
701
|
+
modelControl.install(agentCtx);
|
|
702
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);
|
|
703
|
+
}
|
|
704
|
+
}), modelControl, options.notify);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Restore a persisted Agent and compose the request's fresh MCP connections.
|
|
708
|
+
* @param ctx - ACP plugin context with Agent, LLM, and persistence services.
|
|
709
|
+
* @param options - persisted identity, workspace, fallback route, MCP, and notifier.
|
|
710
|
+
* @returns the restored per-session module.
|
|
711
|
+
*/
|
|
712
|
+
static async resume(ctx, options) {
|
|
713
|
+
let modelControl;
|
|
714
|
+
const handle = await ctx.agents.resume({
|
|
715
|
+
resumeSessionId: options.sessionId,
|
|
716
|
+
agentOptions: options.agentOptions,
|
|
717
|
+
signal: options.signal,
|
|
718
|
+
setup: async (agentCtx, agent) => {
|
|
719
|
+
modelControl = new AcpModelControl(ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection));
|
|
720
|
+
modelControl.install(agentCtx);
|
|
721
|
+
await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
/* v8 ignore start -- a fulfilled Agent resume necessarily ran setup to completion. */
|
|
725
|
+
if (modelControl === void 0) {
|
|
726
|
+
await handle.dispose();
|
|
727
|
+
throw internalError$1("session/resume did not compose model selection");
|
|
728
|
+
}
|
|
729
|
+
/* v8 ignore stop */
|
|
730
|
+
return new AcpSession(ctx, handle, modelControl, options.notify);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Whether this module owns an exact Agent reference.
|
|
734
|
+
* @param agent - Agent observed on a scoped runtime event.
|
|
735
|
+
* @returns true only for this session's owned Agent.
|
|
736
|
+
*/
|
|
737
|
+
owns(agent) {
|
|
738
|
+
return this.agent === agent;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Whether this module owns an exact Session reference.
|
|
742
|
+
* @param session - Session observed on a durable event.
|
|
743
|
+
* @returns true only for this session's owned Session.
|
|
744
|
+
*/
|
|
745
|
+
ownsSession(session) {
|
|
746
|
+
return this.agent.session === session;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Return the complete standard model configuration state.
|
|
750
|
+
* @param signal - optional request cancellation.
|
|
751
|
+
* @returns provider-grouped model and exact-model reasoning options.
|
|
752
|
+
*/
|
|
753
|
+
configOptions(signal) {
|
|
754
|
+
this.assertActive();
|
|
755
|
+
return this.modelControl.options(signal);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Apply one standard configuration option to later ACP turns.
|
|
759
|
+
* @param configId - advertised standard option id.
|
|
760
|
+
* @param value - selected standard option value.
|
|
761
|
+
* @param signal - optional request cancellation.
|
|
762
|
+
* @returns the complete resulting option state.
|
|
763
|
+
*/
|
|
764
|
+
setConfig(configId, value, signal) {
|
|
765
|
+
this.assertActive();
|
|
766
|
+
return this.modelControl.set(configId, value, signal);
|
|
767
|
+
}
|
|
768
|
+
/** Resolve topology state off-chain, then serialize its notification without blocking execution updates. */
|
|
769
|
+
topologyChanged() {
|
|
770
|
+
if (this.closing !== void 0) return;
|
|
771
|
+
this.modelControl.options().then((configOptions) => {
|
|
772
|
+
if (this.closing !== void 0) return;
|
|
773
|
+
const previous = this.outputTail;
|
|
774
|
+
this.outputTail = previous.then(() => this.notify({
|
|
775
|
+
sessionId: this.agent.session.id,
|
|
776
|
+
update: {
|
|
777
|
+
sessionUpdate: "config_option_update",
|
|
778
|
+
configOptions
|
|
779
|
+
}
|
|
780
|
+
})).catch((error) => {
|
|
781
|
+
this.ctx.logger.warn(`acp: config-option update failed: ${errorChain(error)}`);
|
|
782
|
+
});
|
|
783
|
+
/* v8 ignore stop */
|
|
784
|
+
}).catch((error) => {
|
|
785
|
+
this.ctx.logger.warn(`acp: config-option update failed: ${errorChain(error)}`);
|
|
786
|
+
});
|
|
787
|
+
/* v8 ignore stop */
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Admit, enqueue, and settle one prompt at whole-Agent quiescence.
|
|
791
|
+
* @param params - standard ACP prompt request for this session.
|
|
792
|
+
* @param imageEnabled - connection capability advertised at initialization.
|
|
793
|
+
* @param requestSignal - JSON-RPC request cancellation signal.
|
|
794
|
+
* @returns the correlated standard stop reason after ordered updates drain.
|
|
795
|
+
*/
|
|
796
|
+
async prompt(params, imageEnabled, requestSignal) {
|
|
797
|
+
this.assertActive();
|
|
798
|
+
if (this.inflight !== void 0) throw invalidParams$1("a prompt is already in flight for this session");
|
|
799
|
+
const completion = Promise.withResolvers();
|
|
800
|
+
const admission = Promise.withResolvers();
|
|
801
|
+
const admissionController = new AbortController();
|
|
802
|
+
const inflight = {
|
|
803
|
+
resolve: completion.resolve,
|
|
804
|
+
reject: completion.reject,
|
|
805
|
+
messageId: void 0,
|
|
806
|
+
messageQueued: false,
|
|
807
|
+
turn: void 0,
|
|
808
|
+
endReason: void 0,
|
|
809
|
+
admissionDone: admission.promise,
|
|
810
|
+
finishAdmission: admission.resolve,
|
|
811
|
+
admissionController,
|
|
812
|
+
cancelRequested: false,
|
|
813
|
+
settlementStarted: false,
|
|
814
|
+
outputError: void 0,
|
|
815
|
+
agentError: void 0
|
|
816
|
+
};
|
|
817
|
+
this.inflight = inflight;
|
|
818
|
+
const onRequestAbort = () => {
|
|
819
|
+
this.cancelPrompt("ACP prompt request cancelled");
|
|
820
|
+
};
|
|
821
|
+
requestSignal?.addEventListener("abort", onRequestAbort, { once: true });
|
|
822
|
+
/* v8 ignore next -- the SDK dispatches a live signal, then notifies abort through its listener. */
|
|
823
|
+
if (requestSignal?.aborted === true) onRequestAbort();
|
|
824
|
+
try {
|
|
825
|
+
let admissionFailure;
|
|
826
|
+
const promptSelection = this.modelControl.snapshot();
|
|
827
|
+
try {
|
|
828
|
+
if (this.ctx.agents.get(this.agent.id) !== this.agent) throw internalError$1("prompt was not queued: the agent was disposed outside the bridge");
|
|
829
|
+
const content = await admitAcpPrompt(this.ctx, promptSelection, params.prompt, imageEnabled, admissionController.signal);
|
|
830
|
+
admissionController.signal.throwIfAborted();
|
|
831
|
+
if (this.ctx.agents.get(this.agent.id) !== this.agent) throw internalError$1("prompt was not queued: the agent was disposed outside the bridge");
|
|
832
|
+
const message = createUserMessage({
|
|
833
|
+
content,
|
|
834
|
+
source: { kind: "user" }
|
|
835
|
+
});
|
|
836
|
+
inflight.messageId = message.id;
|
|
837
|
+
inflight.messageQueued = true;
|
|
838
|
+
if (promptSelection !== void 0) this.pendingSelections.set(message.id, promptSelection);
|
|
839
|
+
try {
|
|
840
|
+
this.agent.followup(message);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
inflight.messageQueued = false;
|
|
843
|
+
this.pendingSelections.delete(message.id);
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
admissionFailure = error;
|
|
848
|
+
} finally {
|
|
849
|
+
inflight.finishAdmission();
|
|
850
|
+
}
|
|
851
|
+
if (inflight.cancelRequested) {
|
|
852
|
+
this.settleAfterQuiescence(inflight);
|
|
853
|
+
return { stopReason: await completion.promise };
|
|
854
|
+
}
|
|
855
|
+
if (admissionFailure !== void 0) {
|
|
856
|
+
this.inflight = void 0;
|
|
857
|
+
if (admissionFailure instanceof AcpContentError) throw admissionFailure.kind === "invalid" ? invalidParams$1(admissionFailure.message) : internalError$1(admissionFailure.message);
|
|
858
|
+
if (admissionFailure instanceof RequestError) throw admissionFailure;
|
|
859
|
+
throw internalError$1(`prompt was not queued: ${admissionFailure.message}`);
|
|
860
|
+
}
|
|
861
|
+
this.settleAfterQuiescence(inflight);
|
|
862
|
+
return { stopReason: await completion.promise };
|
|
863
|
+
} finally {
|
|
864
|
+
requestSignal?.removeEventListener("abort", onRequestAbort);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
/** Cancel the active prompt, or autonomous work when no ACP prompt exists. */
|
|
868
|
+
cancel() {
|
|
869
|
+
const inflight = this.inflight;
|
|
870
|
+
this.cancelPrompt("ACP prompt cancelled");
|
|
871
|
+
if (inflight === void 0) this.agent.cancel({ kind: "user" });
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Process one durable event and enqueue its standard ACP projections.
|
|
875
|
+
* @param session - exact event-owning Session.
|
|
876
|
+
* @param event - committed durable event.
|
|
877
|
+
*/
|
|
878
|
+
onSessionEvent(session, event) {
|
|
879
|
+
try {
|
|
880
|
+
if (event.type === "assistant/message") {
|
|
881
|
+
const inflight = this.inflight?.turn === event.data.turn ? this.inflight : void 0;
|
|
882
|
+
const delivery = this.outputTail.then(async () => {
|
|
883
|
+
for (const update of await assistantUpdates(this.ctx, session, event)) await this.notify({
|
|
884
|
+
sessionId: this.agent.session.id,
|
|
885
|
+
update
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
this.outputTail = delivery.catch((error) => {
|
|
889
|
+
const failure = error;
|
|
890
|
+
if (inflight !== void 0) inflight.outputError ??= failure;
|
|
891
|
+
this.ctx.logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`);
|
|
892
|
+
});
|
|
893
|
+
} else if (event.type === "tool/call") {
|
|
894
|
+
const previous = this.outputTail;
|
|
895
|
+
this.outputTail = previous.then(() => this.notify({
|
|
896
|
+
sessionId: this.agent.session.id,
|
|
897
|
+
update: toolCallUpdate(event)
|
|
898
|
+
})).catch((error) => {
|
|
899
|
+
this.ctx.logger.warn(`acp: tool-call update delivery failed: ${errorChain(error)}`);
|
|
900
|
+
});
|
|
901
|
+
} else if (event.type === "tool/result") {
|
|
902
|
+
const previous = this.outputTail;
|
|
903
|
+
this.outputTail = previous.then(async () => this.notify({
|
|
904
|
+
sessionId: this.agent.session.id,
|
|
905
|
+
update: await toolResultUpdate(this.ctx, event)
|
|
906
|
+
})).catch((error) => {
|
|
907
|
+
this.ctx.logger.warn(`acp: tool-result update delivery failed: ${errorChain(error)}`);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
} finally {
|
|
911
|
+
const inflight = this.inflight;
|
|
912
|
+
if (inflight !== void 0 && event.type === "turn/end" && inflight.turn === event.data.turn) inflight.endReason = event.data.reason;
|
|
913
|
+
if (event.type === "turn/end") this.modelControl.releaseTurn(event.data.turn);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Correlate an accepted user message with its Agent turn and pinned route.
|
|
918
|
+
* @param message - claimed durable inbox message.
|
|
919
|
+
* @param turn - allocated Agent turn.
|
|
920
|
+
*/
|
|
921
|
+
onInboxClaimed(message, turn) {
|
|
922
|
+
if (this.inflight !== void 0 && this.inflight.messageId === message.id) this.inflight.turn = turn;
|
|
923
|
+
const selection = this.pendingSelections.get(message.id);
|
|
924
|
+
this.pendingSelections.delete(message.id);
|
|
925
|
+
if (selection !== void 0) this.modelControl.pinTurn(turn, selection);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Correlate an Agent interval failure with the active ACP prompt.
|
|
929
|
+
* @param turn - failed turn number.
|
|
930
|
+
* @param error - original same-process failure.
|
|
931
|
+
*/
|
|
932
|
+
onAgentError(turn, error) {
|
|
933
|
+
const inflight = this.inflight;
|
|
934
|
+
if (inflight === void 0 || !inflight.messageQueued) return;
|
|
935
|
+
if (inflight.turn === turn) return;
|
|
936
|
+
inflight.agentError = new Error(errorChain(error));
|
|
937
|
+
this.settleAfterQuiescence(inflight);
|
|
938
|
+
}
|
|
939
|
+
/** Await every update queued before this call. */
|
|
940
|
+
drainUpdates() {
|
|
941
|
+
return this.outputTail;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Cancel, drain, flush, and dispose this session once.
|
|
945
|
+
* @param detail - cancellation detail for any prompt still in admission.
|
|
946
|
+
* @returns the shared quiescent teardown promise.
|
|
947
|
+
*/
|
|
948
|
+
close(detail) {
|
|
949
|
+
if (this.closing !== void 0) return this.closing;
|
|
950
|
+
this.closing = (async () => {
|
|
951
|
+
const failures = [];
|
|
952
|
+
const inflight = this.inflight;
|
|
953
|
+
this.cancelPrompt(detail);
|
|
954
|
+
if (inflight === void 0 || !inflight.messageQueued) this.agent.cancel({ kind: "user" });
|
|
955
|
+
try {
|
|
956
|
+
await inflight?.admissionDone;
|
|
957
|
+
await this.agent.whenIdle();
|
|
958
|
+
await this.outputTail;
|
|
959
|
+
} catch (error) {
|
|
960
|
+
failures.push(new Error("ACP session activity drain failed", { cause: error }));
|
|
961
|
+
}
|
|
962
|
+
const subagents = this.ctx.get("subagents");
|
|
963
|
+
try {
|
|
964
|
+
await subagents?.drainContinuableDescendants([this.agent]);
|
|
965
|
+
} catch (error) {
|
|
966
|
+
this.ctx.logger.warn(`acp: continuable subagent teardown failed: ${errorChain(error)}`);
|
|
967
|
+
failures.push(new Error("continuable subagent teardown failed", { cause: error }));
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
await this.ctx.sessions.flush(this.agent.session);
|
|
971
|
+
} catch (error) {
|
|
972
|
+
failures.push(new Error("ACP session persistence flush failed", { cause: error }));
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
await this.disposeAgent();
|
|
976
|
+
} catch (error) {
|
|
977
|
+
failures.push(error);
|
|
978
|
+
}
|
|
979
|
+
this.pendingSelections.clear();
|
|
980
|
+
if (failures.length === 1) throw failures[0];
|
|
981
|
+
/* v8 ignore start -- independent teardown failures can aggregate only under multiple simultaneous provider faults. */
|
|
982
|
+
if (failures.length > 1) throw new AggregateError(failures, `ACP session teardown failed: ${failures.map(errorChain).join("; ")}`);
|
|
983
|
+
/* v8 ignore stop */
|
|
984
|
+
})();
|
|
985
|
+
return this.closing;
|
|
986
|
+
}
|
|
987
|
+
assertActive() {
|
|
988
|
+
if (this.closing !== void 0) throw invalidParams$1(`session is closing: ${this.agent.session.id}`);
|
|
989
|
+
}
|
|
990
|
+
cancelPrompt(detail) {
|
|
991
|
+
const inflight = this.inflight;
|
|
992
|
+
if (inflight === void 0) return;
|
|
993
|
+
inflight.cancelRequested = true;
|
|
994
|
+
inflight.admissionController.abort(new Error(detail));
|
|
995
|
+
this.settleAfterQuiescence(inflight);
|
|
996
|
+
if (inflight.messageQueued) this.agent.cancel({ kind: "user" });
|
|
997
|
+
}
|
|
998
|
+
settleAfterQuiescence(inflight) {
|
|
999
|
+
if (inflight.settlementStarted) return;
|
|
1000
|
+
inflight.settlementStarted = true;
|
|
1001
|
+
(async () => {
|
|
1002
|
+
await inflight.admissionDone;
|
|
1003
|
+
if (inflight.messageQueued) {
|
|
1004
|
+
await this.agent.whenIdle();
|
|
1005
|
+
await this.outputTail;
|
|
1006
|
+
}
|
|
1007
|
+
/* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */
|
|
1008
|
+
if (this.inflight !== inflight) return;
|
|
1009
|
+
this.inflight = void 0;
|
|
1010
|
+
if (inflight.cancelRequested) {
|
|
1011
|
+
inflight.resolve("cancelled");
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (inflight.outputError !== void 0) {
|
|
1015
|
+
inflight.reject(internalError$1(`assistant output delivery failed: ${inflight.outputError.message}`));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (inflight.agentError !== void 0) {
|
|
1019
|
+
inflight.reject(internalError$1(`turn failed: ${inflight.agentError.message}`));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const end = inflight.endReason;
|
|
1023
|
+
if (end === void 0) inflight.resolve("cancelled");
|
|
1024
|
+
else if (end.kind === "error") inflight.reject(internalError$1(`turn failed: ${end.error.message}`));
|
|
1025
|
+
else inflight.resolve(turnEndToStopReason(end));
|
|
1026
|
+
})().catch((error) => {
|
|
1027
|
+
if (this.inflight !== inflight) return;
|
|
1028
|
+
this.inflight = void 0;
|
|
1029
|
+
inflight.reject(internalError$1(`prompt settlement failed: ${errorChain(error)}`));
|
|
1030
|
+
});
|
|
1031
|
+
/* v8 ignore stop */
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
//#endregion
|
|
1035
|
+
//#region lib/types/index.js
|
|
1036
|
+
/**
|
|
1037
|
+
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
|
|
1038
|
+
*
|
|
1039
|
+
* The bridge exposes persistent harness sessions to trusted programmatic
|
|
1040
|
+
* clients. It carries standard configuration, MCP mounts, prompt content,
|
|
1041
|
+
* committed semantic updates, cancellation, and one-shot permission decisions;
|
|
1042
|
+
* presentation and human-interaction features stay with the harness's UI modules.
|
|
1043
|
+
*
|
|
1044
|
+
* @module @qilin-agent/acp
|
|
1045
|
+
*/
|
|
1046
|
+
const DEFAULT_SESSION_LIST_PAGE_SIZE = 100;
|
|
1047
|
+
const name = "acp";
|
|
1048
|
+
/** Core services required by the standard automation controls. */
|
|
1049
|
+
const inject = [
|
|
1050
|
+
"agents",
|
|
1051
|
+
"llm",
|
|
1052
|
+
"sessionPersistence",
|
|
1053
|
+
"sessions"
|
|
1054
|
+
];
|
|
1055
|
+
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
1056
|
+
function invalidParams(detail) {
|
|
1057
|
+
return RequestError.invalidParams(void 0, detail);
|
|
1058
|
+
}
|
|
1059
|
+
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
|
1060
|
+
function internalError(detail) {
|
|
1061
|
+
return RequestError.internalError(void 0, detail);
|
|
1062
|
+
}
|
|
1063
|
+
const Config = Schema.object({
|
|
1064
|
+
provider: Schema.string(),
|
|
1065
|
+
model: Schema.string(),
|
|
1066
|
+
sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE)
|
|
1067
|
+
});
|
|
1068
|
+
/**
|
|
1069
|
+
* Mount the automation-only ACP server.
|
|
1070
|
+
* @param ctx - Cordis context carrying the agent factory and session events.
|
|
1071
|
+
* @param config - Initial provider/model selection and optional test transport.
|
|
1072
|
+
*/
|
|
1073
|
+
function apply(ctx, config) {
|
|
1074
|
+
const persistence = ctx.sessionPersistence;
|
|
1075
|
+
const logger = ctx.logger;
|
|
1076
|
+
const sessionListPageSize = resolveSessionListPageSize(config.sessionListPageSize);
|
|
1077
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1078
|
+
const activating = /* @__PURE__ */ new Set();
|
|
1079
|
+
let closed = false;
|
|
1080
|
+
let imagePromptEnabled = false;
|
|
1081
|
+
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
|
1082
|
+
const ownedRecord = (agent) => {
|
|
1083
|
+
const record = sessions.get(agent.session.id);
|
|
1084
|
+
return record?.owns(agent) === true ? record : void 0;
|
|
1085
|
+
};
|
|
1086
|
+
const assertOpen = () => {
|
|
1087
|
+
if (closed) throw internalError("the ACP bridge has been disposed");
|
|
1088
|
+
};
|
|
1089
|
+
const requireSession = (sessionId) => {
|
|
1090
|
+
const record = sessions.get(sessionId);
|
|
1091
|
+
if (record === void 0) throw invalidParams(`unknown session: ${sessionId}`);
|
|
1092
|
+
return record;
|
|
1093
|
+
};
|
|
1094
|
+
/** Send one ordered protocol update while containing transport-only failure. */
|
|
1095
|
+
const notify = async (notification) => {
|
|
1096
|
+
try {
|
|
1097
|
+
await conn.notify(methods.client.session.update, notification);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
logger.warn(`acp: session/update failed: ${String(error)}`);
|
|
1100
|
+
}
|
|
1101
|
+
/* v8 ignore stop */
|
|
1102
|
+
};
|
|
1103
|
+
ctx.on("session/event", (session, event) => {
|
|
1104
|
+
const record = sessions.get(session.header.id);
|
|
1105
|
+
if (record?.ownsSession(session) === true) record.onSessionEvent(session, event);
|
|
1106
|
+
});
|
|
1107
|
+
ctx.on("agent/inbox/claimed", ({ agent, message, turn }) => {
|
|
1108
|
+
ownedRecord(agent)?.onInboxClaimed(message, turn);
|
|
1109
|
+
});
|
|
1110
|
+
ctx.on("agent/error", ({ agent, turn, error }) => {
|
|
1111
|
+
ownedRecord(agent)?.onAgentError(turn, error);
|
|
1112
|
+
});
|
|
1113
|
+
ctx.on("llm/adapters-updated", () => {
|
|
1114
|
+
for (const record of sessions.values()) record.topologyChanged();
|
|
1115
|
+
});
|
|
1116
|
+
ctx.on("approval/request", (request, next) => {
|
|
1117
|
+
const record = ownedRecord(request.agent);
|
|
1118
|
+
if (record === void 0 || request.callId === void 0) return next();
|
|
1119
|
+
const callId = request.callId;
|
|
1120
|
+
return record.drainUpdates().then(() => {
|
|
1121
|
+
const params = {
|
|
1122
|
+
sessionId: record.agent.session.id,
|
|
1123
|
+
toolCall: { toolCallId: callId },
|
|
1124
|
+
options: [{
|
|
1125
|
+
optionId: "allow-once",
|
|
1126
|
+
name: "Allow once",
|
|
1127
|
+
kind: "allow_once"
|
|
1128
|
+
}, {
|
|
1129
|
+
optionId: "reject-once",
|
|
1130
|
+
name: "Reject",
|
|
1131
|
+
kind: "reject_once"
|
|
1132
|
+
}]
|
|
1133
|
+
};
|
|
1134
|
+
return conn.request(methods.client.session.requestPermission, params);
|
|
1135
|
+
}).then(({ outcome }) => {
|
|
1136
|
+
if (outcome.outcome === "cancelled") return "cancelled";
|
|
1137
|
+
return outcome.optionId === "allow-once" ? "allowed-once" : "rejected";
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
const implementation = {
|
|
1141
|
+
async initialize(_params) {
|
|
1142
|
+
imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model);
|
|
1143
|
+
return {
|
|
1144
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1145
|
+
agentInfo: {
|
|
1146
|
+
name: "deepseek-harness-acp",
|
|
1147
|
+
version: "0.0.1"
|
|
1148
|
+
},
|
|
1149
|
+
agentCapabilities: {
|
|
1150
|
+
mcpCapabilities: { http: true },
|
|
1151
|
+
promptCapabilities: {
|
|
1152
|
+
image: imagePromptEnabled,
|
|
1153
|
+
audio: false,
|
|
1154
|
+
embeddedContext: false
|
|
1155
|
+
},
|
|
1156
|
+
sessionCapabilities: {
|
|
1157
|
+
close: {},
|
|
1158
|
+
list: {},
|
|
1159
|
+
resume: {}
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
authMethods: []
|
|
1163
|
+
};
|
|
1164
|
+
},
|
|
1165
|
+
authenticate(_params) {
|
|
1166
|
+
return Promise.resolve();
|
|
1167
|
+
},
|
|
1168
|
+
async newSession(params, signal) {
|
|
1169
|
+
assertOpen();
|
|
1170
|
+
validateWorkspaceParams(params);
|
|
1171
|
+
const sessionId = brandString(randomUUID());
|
|
1172
|
+
let record;
|
|
1173
|
+
try {
|
|
1174
|
+
record = await AcpSession.create(ctx, {
|
|
1175
|
+
sessionId,
|
|
1176
|
+
cwd: params.cwd,
|
|
1177
|
+
mcpServers: params.mcpServers,
|
|
1178
|
+
agentOptions: agentOptions(config),
|
|
1179
|
+
fallbackSelection: initialSelection(config),
|
|
1180
|
+
signal,
|
|
1181
|
+
notify
|
|
1182
|
+
});
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
if (error instanceof AcpMcpConfigError) throw invalidParams(error.message);
|
|
1185
|
+
throw error;
|
|
1186
|
+
}
|
|
1187
|
+
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
|
|
1188
|
+
if (closed) {
|
|
1189
|
+
await record.close("connection closed during session/new");
|
|
1190
|
+
throw internalError("connection closed during session/new");
|
|
1191
|
+
}
|
|
1192
|
+
sessions.set(sessionId, record);
|
|
1193
|
+
try {
|
|
1194
|
+
const configOptions = await record.configOptions(signal);
|
|
1195
|
+
assertOpen();
|
|
1196
|
+
await ctx.sessions.flush(record.agent.session);
|
|
1197
|
+
assertOpen();
|
|
1198
|
+
return {
|
|
1199
|
+
sessionId,
|
|
1200
|
+
configOptions
|
|
1201
|
+
};
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
sessions.delete(sessionId);
|
|
1204
|
+
await record.close("session/new activation failed");
|
|
1205
|
+
throw error;
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
async resumeSession(params, signal) {
|
|
1209
|
+
assertOpen();
|
|
1210
|
+
validateWorkspaceParams(params);
|
|
1211
|
+
const sessionId = brandString(params.sessionId);
|
|
1212
|
+
if (sessions.has(sessionId) || activating.has(sessionId) || ctx.sessions.get(sessionId) !== void 0) throw invalidParams(`session is already active: ${sessionId}`);
|
|
1213
|
+
activating.add(sessionId);
|
|
1214
|
+
return (async () => {
|
|
1215
|
+
const persisted = (await persistence.stat(sessionId, { signal }))?.header;
|
|
1216
|
+
if (persisted === void 0 || persisted.origin === "subagent" || persisted.parentSession !== void 0) throw invalidParams(`session is not resumable: ${sessionId}`);
|
|
1217
|
+
if (!await sameDirectory(persisted.cwd, params.cwd)) throw invalidParams(`session cwd does not match: ${params.cwd}`);
|
|
1218
|
+
let record;
|
|
1219
|
+
try {
|
|
1220
|
+
record = await AcpSession.resume(ctx, {
|
|
1221
|
+
sessionId,
|
|
1222
|
+
cwd: params.cwd,
|
|
1223
|
+
mcpServers: params.mcpServers ?? [],
|
|
1224
|
+
agentOptions: agentOptions(config),
|
|
1225
|
+
fallbackSelection: initialSelection(config),
|
|
1226
|
+
signal,
|
|
1227
|
+
notify
|
|
1228
|
+
});
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
if (error instanceof AcpMcpConfigError) throw invalidParams(error.message);
|
|
1231
|
+
throw error;
|
|
1232
|
+
}
|
|
1233
|
+
/* v8 ignore start -- the persisted header was checked before resume; the factory restores that exact header. */
|
|
1234
|
+
if (!await sameDirectory(record.agent.session.header.cwd, params.cwd)) {
|
|
1235
|
+
await record.close("session/resume cwd mismatch");
|
|
1236
|
+
throw invalidParams(`session cwd does not match: ${params.cwd}`);
|
|
1237
|
+
}
|
|
1238
|
+
/* v8 ignore stop */
|
|
1239
|
+
/* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */
|
|
1240
|
+
if (closed) {
|
|
1241
|
+
await record.close("connection closed during session/resume");
|
|
1242
|
+
throw internalError("connection closed during session/resume");
|
|
1243
|
+
}
|
|
1244
|
+
sessions.set(sessionId, record);
|
|
1245
|
+
try {
|
|
1246
|
+
return { configOptions: await record.configOptions(signal) };
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
sessions.delete(sessionId);
|
|
1249
|
+
await record.close("session/resume option discovery failed");
|
|
1250
|
+
throw error;
|
|
1251
|
+
}
|
|
1252
|
+
})().finally(() => {
|
|
1253
|
+
activating.delete(sessionId);
|
|
1254
|
+
});
|
|
1255
|
+
},
|
|
1256
|
+
async listSessions(params, signal) {
|
|
1257
|
+
assertOpen();
|
|
1258
|
+
if (params.cwd !== void 0 && params.cwd !== null && !isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
|
1259
|
+
let cursor;
|
|
1260
|
+
try {
|
|
1261
|
+
cursor = decodeSessionListCursor(params.cursor);
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
throw invalidParams(error.message);
|
|
1264
|
+
}
|
|
1265
|
+
const listed = await persistence.list({ signal });
|
|
1266
|
+
const entries = (await Promise.all(listed.map(async ({ header }) => {
|
|
1267
|
+
if (sessions.has(header.id) || activating.has(header.id) || ctx.sessions.get(header.id) !== void 0 || header.origin === "subagent" || header.parentSession !== void 0 || header.cwd === void 0 || !isAbsolute(header.cwd)) return void 0;
|
|
1268
|
+
if (params.cwd !== void 0 && params.cwd !== null && !await sameDirectory(header.cwd, params.cwd)) return;
|
|
1269
|
+
return {
|
|
1270
|
+
sessionId: header.id,
|
|
1271
|
+
cwd: header.cwd,
|
|
1272
|
+
createdAt: header.createdAt
|
|
1273
|
+
};
|
|
1274
|
+
}))).filter((entry) => entry !== void 0).sort((left, right) => right.createdAt - left.createdAt || compareSessionIds(left.sessionId, right.sessionId));
|
|
1275
|
+
const remaining = cursor === void 0 ? entries : entries.filter((entry) => isAfterSessionListCursor(entry, cursor));
|
|
1276
|
+
const page = remaining.slice(0, sessionListPageSize);
|
|
1277
|
+
const next = remaining.length > page.length ? page.at(-1) : void 0;
|
|
1278
|
+
return {
|
|
1279
|
+
sessions: page.map(({ sessionId, cwd }) => ({
|
|
1280
|
+
sessionId,
|
|
1281
|
+
cwd
|
|
1282
|
+
})),
|
|
1283
|
+
...next === void 0 ? {} : { nextCursor: encodeSessionListCursor(next) }
|
|
1284
|
+
};
|
|
1285
|
+
},
|
|
1286
|
+
async setSessionConfigOption(params, signal) {
|
|
1287
|
+
assertOpen();
|
|
1288
|
+
const record = requireSession(brandString(params.sessionId));
|
|
1289
|
+
try {
|
|
1290
|
+
return { configOptions: await record.setConfig(params.configId, params.value, signal) };
|
|
1291
|
+
} catch (error) {
|
|
1292
|
+
if (error instanceof AcpModelConfigError) throw invalidParams(error.message);
|
|
1293
|
+
throw error;
|
|
1294
|
+
}
|
|
1295
|
+
},
|
|
1296
|
+
async closeSession(params) {
|
|
1297
|
+
assertOpen();
|
|
1298
|
+
const sessionId = brandString(params.sessionId);
|
|
1299
|
+
const record = requireSession(sessionId);
|
|
1300
|
+
try {
|
|
1301
|
+
await record.close("ACP session closed");
|
|
1302
|
+
} catch (error) {
|
|
1303
|
+
throw internalError(`session close failed: ${errorChain(error)}`);
|
|
1304
|
+
} finally {
|
|
1305
|
+
if (sessions.get(sessionId) === record) sessions.delete(sessionId);
|
|
1306
|
+
}
|
|
1307
|
+
return {};
|
|
1308
|
+
},
|
|
1309
|
+
async prompt(params, requestSignal) {
|
|
1310
|
+
assertOpen();
|
|
1311
|
+
return requireSession(brandString(params.sessionId)).prompt(params, imagePromptEnabled, requestSignal);
|
|
1312
|
+
},
|
|
1313
|
+
cancel(params) {
|
|
1314
|
+
sessions.get(brandString(params.sessionId))?.cancel();
|
|
1315
|
+
return Promise.resolve();
|
|
1316
|
+
}
|
|
1317
|
+
};
|
|
1318
|
+
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
|
|
1319
|
+
const stream = config.stream ?? ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
1320
|
+
const connection = agent({ name: "deepseek-harness-acp" }).onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params)).onRequest(methods.agent.authenticate, async ({ params }) => {
|
|
1321
|
+
await implementation.authenticate(params);
|
|
1322
|
+
return {};
|
|
1323
|
+
}).onRequest(methods.agent.session.new, ({ params, signal }) => implementation.newSession(params, signal)).onRequest(methods.agent.session.list, ({ params, signal }) => implementation.listSessions(params, signal)).onRequest(methods.agent.session.resume, ({ params, signal }) => implementation.resumeSession(params, signal)).onRequest(methods.agent.session.close, ({ params }) => implementation.closeSession(params)).onRequest(methods.agent.session.setConfigOption, ({ params, signal }) => implementation.setSessionConfigOption(params, signal)).onRequest(methods.agent.session.prompt, ({ params, signal }) => implementation.prompt(params, signal)).onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params)).connect(stream);
|
|
1324
|
+
const conn = connection.client;
|
|
1325
|
+
let quiescing;
|
|
1326
|
+
const quiesce = () => {
|
|
1327
|
+
if (quiescing !== void 0) return quiescing;
|
|
1328
|
+
closed = true;
|
|
1329
|
+
const records = [...sessions.values()];
|
|
1330
|
+
quiescing = (async () => {
|
|
1331
|
+
const disposals = await Promise.allSettled(records.map((record) => record.close("ACP bridge disposed")));
|
|
1332
|
+
for (const record of records)
|
|
1333
|
+
/* v8 ignore next -- closed blocks concurrent handlers; each captured record remains mapped until this loop. */
|
|
1334
|
+
if (sessions.get(record.agent.session.id) === record) sessions.delete(record.agent.session.id);
|
|
1335
|
+
const failures = [];
|
|
1336
|
+
for (const result of disposals) if (result.status === "rejected") failures.push(result.reason);
|
|
1337
|
+
if (failures.length > 0) {
|
|
1338
|
+
const detail = failures.map((failure) => errorChain(failure)).join("; ");
|
|
1339
|
+
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`);
|
|
1340
|
+
}
|
|
1341
|
+
})();
|
|
1342
|
+
return quiescing;
|
|
1343
|
+
};
|
|
1344
|
+
/* v8 ignore start -- production transport rejection and teardown failure. */
|
|
1345
|
+
connection.closed.catch((error) => {
|
|
1346
|
+
logger.warn(`acp: connection closed with an error: ${String(error)}`);
|
|
1347
|
+
}).then(quiesce).catch((error) => {
|
|
1348
|
+
logger.warn(`acp: connection-close teardown failed: ${String(error)}`);
|
|
1349
|
+
});
|
|
1350
|
+
/* v8 ignore stop */
|
|
1351
|
+
ctx.effect(() => quiesce, "acp.connection");
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Build per-agent options from plugin config without assigning absent optional fields.
|
|
1355
|
+
* @param config - ACP provider/model configuration.
|
|
1356
|
+
* @returns the configured fields only.
|
|
1357
|
+
*/
|
|
1358
|
+
function agentOptions(config) {
|
|
1359
|
+
return {
|
|
1360
|
+
...config.provider !== void 0 ? { provider: config.provider } : {},
|
|
1361
|
+
...config.model !== void 0 ? { model: config.model } : {}
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
/** Initial session selection when both deployment fields are present. */
|
|
1365
|
+
function initialSelection(config) {
|
|
1366
|
+
return config.provider === void 0 || config.model === void 0 ? void 0 : {
|
|
1367
|
+
provider: config.provider,
|
|
1368
|
+
model: config.model
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
/** Resolve and validate the deployment-owned session page limit. */
|
|
1372
|
+
function resolveSessionListPageSize(value) {
|
|
1373
|
+
const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE;
|
|
1374
|
+
/* v8 ignore start -- Cordis applies the positive-integer Config schema; this protects direct apply callers. */
|
|
1375
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) throw new Error("acp: sessionListPageSize must be a positive safe integer");
|
|
1376
|
+
/* v8 ignore stop */
|
|
1377
|
+
return resolved;
|
|
1378
|
+
}
|
|
1379
|
+
/** Decode an opaque keyset cursor without assigning meaning to client metadata. */
|
|
1380
|
+
function decodeSessionListCursor(value) {
|
|
1381
|
+
if (value === void 0 || value === null) return void 0;
|
|
1382
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("session/list cursor is invalid");
|
|
1383
|
+
try {
|
|
1384
|
+
const decoded = JSON.parse(Buffer$1.from(value, "base64url").toString("utf8"));
|
|
1385
|
+
const createdAt = Array.isArray(decoded) ? decoded[0] : void 0;
|
|
1386
|
+
const sessionId = Array.isArray(decoded) ? decoded[1] : void 0;
|
|
1387
|
+
if (!Array.isArray(decoded) || decoded.length !== 2 || typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0 || typeof sessionId !== "string" || sessionId.length === 0) throw new Error("invalid cursor fields");
|
|
1388
|
+
if (Buffer$1.from(JSON.stringify(decoded), "utf8").toString("base64url") !== value) throw new Error("non-canonical cursor");
|
|
1389
|
+
return {
|
|
1390
|
+
createdAt,
|
|
1391
|
+
sessionId
|
|
1392
|
+
};
|
|
1393
|
+
} catch (_invalidCursor) {
|
|
1394
|
+
throw new Error("session/list cursor is invalid");
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
/** Encode the last returned ordering key as an opaque continuation token. */
|
|
1398
|
+
function encodeSessionListCursor(entry) {
|
|
1399
|
+
return Buffer$1.from(JSON.stringify([entry.createdAt, entry.sessionId]), "utf8").toString("base64url");
|
|
1400
|
+
}
|
|
1401
|
+
/** Test whether an entry follows the cursor in newest-first list order. */
|
|
1402
|
+
function isAfterSessionListCursor(entry, cursor) {
|
|
1403
|
+
return entry.createdAt < cursor.createdAt || entry.createdAt === cursor.createdAt && compareSessionIds(entry.sessionId, cursor.sessionId) > 0;
|
|
1404
|
+
}
|
|
1405
|
+
/** Compare opaque session ids by stable UTF-8 bytes, independent of process locale. */
|
|
1406
|
+
function compareSessionIds(left, right) {
|
|
1407
|
+
return Buffer$1.compare(Buffer$1.from(left), Buffer$1.from(right));
|
|
1408
|
+
}
|
|
1409
|
+
/** Reject workspace features outside the automation contract. */
|
|
1410
|
+
function validateWorkspaceParams(params) {
|
|
1411
|
+
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
|
1412
|
+
if (params.additionalDirectories !== void 0 && params.additionalDirectories !== null && params.additionalDirectories.length > 0) throw invalidParams("additionalDirectories is not supported");
|
|
1413
|
+
}
|
|
1414
|
+
/** Compare existing directories by physical identity and missing paths lexically. */
|
|
1415
|
+
async function sameDirectory(left, right) {
|
|
1416
|
+
if (left === void 0) return false;
|
|
1417
|
+
try {
|
|
1418
|
+
const [realLeft, realRight] = await Promise.all([realpath(left), realpath(right)]);
|
|
1419
|
+
return realLeft === realRight;
|
|
1420
|
+
} catch (_unresolvablePath) {
|
|
1421
|
+
return resolve(left) === resolve(right);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
export { Config, apply, inject, name };
|