dsh-acp-replay 0.1.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 +27 -0
- package/README.md +126 -0
- package/cordis.patch.yml +10 -0
- package/lib/codec.js +30 -0
- package/lib/compat.js +70 -0
- package/lib/content.js +212 -0
- package/lib/index.js +549 -0
- package/lib/mcp.js +130 -0
- package/lib/model-control.js +217 -0
- package/lib/replay.js +57 -0
- package/lib/session.js +461 -0
- package/lib/types/codec.d.ts +12 -0
- package/lib/types/compat.d.ts +39 -0
- package/lib/types/content.d.ts +49 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/mcp.d.ts +14 -0
- package/lib/types/model-control.d.ts +61 -0
- package/lib/types/replay.d.ts +20 -0
- package/lib/types/session.d.ts +123 -0
- package/lib/types/updates.d.ts +25 -0
- package/lib/updates.js +95 -0
- package/package.json +79 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
|
|
3
|
+
*
|
|
4
|
+
* The bridge exposes persistent harness sessions to trusted programmatic
|
|
5
|
+
* clients. It carries standard configuration, MCP mounts, prompt content,
|
|
6
|
+
* committed semantic updates, cancellation, and one-shot permission decisions;
|
|
7
|
+
* presentation and human-interaction features stay with the harness's UI modules.
|
|
8
|
+
*
|
|
9
|
+
* @module @deepseek-ai/dsh-acp
|
|
10
|
+
*/
|
|
11
|
+
import { Buffer } from 'node:buffer';
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { realpath } from 'node:fs/promises';
|
|
14
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
15
|
+
import { Readable, Writable } from 'node:stream';
|
|
16
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
17
|
+
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
18
|
+
import { errorChain } from '@deepseek-ai/dsh-llm';
|
|
19
|
+
import { agent as createAcpAgentApp, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from '@agentclientprotocol/sdk';
|
|
20
|
+
import { supportsAcpImagePrompts } from "./content.js";
|
|
21
|
+
import { AcpMcpConfigError } from "./mcp.js";
|
|
22
|
+
import { AcpModelConfigError } from "./model-control.js";
|
|
23
|
+
import { assertReplayHostSupport } from "./compat.js";
|
|
24
|
+
import { replaySessionUpdates } from "./replay.js";
|
|
25
|
+
import { AcpSession } from "./session.js";
|
|
26
|
+
const DEFAULT_SESSION_LIST_PAGE_SIZE = 100;
|
|
27
|
+
export const name = 'acp-replay';
|
|
28
|
+
/** Core services required by the standard automation controls. */
|
|
29
|
+
export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions'];
|
|
30
|
+
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
|
31
|
+
function invalidParams(detail) {
|
|
32
|
+
return RequestError.invalidParams(undefined, detail);
|
|
33
|
+
}
|
|
34
|
+
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
|
35
|
+
function internalError(detail) {
|
|
36
|
+
return RequestError.internalError(undefined, detail);
|
|
37
|
+
}
|
|
38
|
+
export const Config = Schema.object({
|
|
39
|
+
provider: Schema.string(),
|
|
40
|
+
model: Schema.string(),
|
|
41
|
+
sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE),
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Mount the automation-only ACP server.
|
|
45
|
+
* @param ctx - Cordis context carrying the agent factory and session events.
|
|
46
|
+
* @param config - Initial provider/model selection and optional test transport.
|
|
47
|
+
*/
|
|
48
|
+
export function apply(ctx, config) {
|
|
49
|
+
// ACP handlers execute outside this plugin's injection scope, so capture the
|
|
50
|
+
// injected service during apply rather than reading it lazily in a callback.
|
|
51
|
+
const persistence = ctx.sessionPersistence;
|
|
52
|
+
const logger = ctx.logger;
|
|
53
|
+
// One check at mount beats a broken bridge that only fails when a client
|
|
54
|
+
// asks for history, or when the provider has already been advertised.
|
|
55
|
+
assertReplayHostSupport({
|
|
56
|
+
persistence,
|
|
57
|
+
sessionLoadMethod: methods.agent.session.load,
|
|
58
|
+
});
|
|
59
|
+
logger.info('dsh-acp-replay: session/load replay enabled');
|
|
60
|
+
const sessionListPageSize = resolveSessionListPageSize(config.sessionListPageSize);
|
|
61
|
+
const sessions = new Map();
|
|
62
|
+
const activating = new Set();
|
|
63
|
+
let closed = false;
|
|
64
|
+
let imagePromptEnabled = false;
|
|
65
|
+
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
|
|
66
|
+
const ownedRecord = (agent) => {
|
|
67
|
+
const record = sessions.get(agent.session.id);
|
|
68
|
+
return record?.owns(agent) === true ? record : undefined;
|
|
69
|
+
};
|
|
70
|
+
const assertOpen = () => {
|
|
71
|
+
if (closed)
|
|
72
|
+
throw internalError('the ACP bridge has been disposed');
|
|
73
|
+
};
|
|
74
|
+
const requireSession = (sessionId) => {
|
|
75
|
+
const record = sessions.get(sessionId);
|
|
76
|
+
if (record === undefined)
|
|
77
|
+
throw invalidParams(`unknown session: ${sessionId}`);
|
|
78
|
+
return record;
|
|
79
|
+
};
|
|
80
|
+
/** Send one ordered protocol update while containing transport-only failure. */
|
|
81
|
+
const notify = async (notification) => {
|
|
82
|
+
try {
|
|
83
|
+
await conn.notify(methods.client.session.update, notification);
|
|
84
|
+
/* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
logger.warn(`acp: session/update failed: ${String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
/* v8 ignore stop */
|
|
90
|
+
};
|
|
91
|
+
ctx.on('session/event', (session, event) => {
|
|
92
|
+
const record = sessions.get(session.header.id);
|
|
93
|
+
if (record?.ownsSession(session) === true)
|
|
94
|
+
record.onSessionEvent(session, event);
|
|
95
|
+
});
|
|
96
|
+
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
|
|
97
|
+
ownedRecord(agent)?.onInboxClaimed(message, turn);
|
|
98
|
+
});
|
|
99
|
+
ctx.on('agent/error', ({ agent, turn, error }) => {
|
|
100
|
+
ownedRecord(agent)?.onAgentError(turn, error);
|
|
101
|
+
});
|
|
102
|
+
ctx.on('llm/adapters-updated', () => {
|
|
103
|
+
for (const record of sessions.values())
|
|
104
|
+
record.topologyChanged();
|
|
105
|
+
});
|
|
106
|
+
// Permission requests are a machine policy channel for ACP clients such as
|
|
107
|
+
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
|
|
108
|
+
// durable grant from an unknown client response.
|
|
109
|
+
ctx.on('approval/request', (request, next) => {
|
|
110
|
+
const record = ownedRecord(request.agent);
|
|
111
|
+
if (record === undefined || request.callId === undefined)
|
|
112
|
+
return next();
|
|
113
|
+
const callId = request.callId;
|
|
114
|
+
return record.drainUpdates().then(() => {
|
|
115
|
+
const params = {
|
|
116
|
+
sessionId: record.agent.session.id,
|
|
117
|
+
toolCall: { toolCallId: callId },
|
|
118
|
+
options: [
|
|
119
|
+
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
|
120
|
+
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
return conn.request(methods.client.session.requestPermission, params);
|
|
124
|
+
}).then(({ outcome }) => {
|
|
125
|
+
if (outcome.outcome === 'cancelled')
|
|
126
|
+
return 'cancelled';
|
|
127
|
+
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected';
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
const implementation = {
|
|
131
|
+
async initialize(_params) {
|
|
132
|
+
// Single-version agent: the spec's "same version if supported, else
|
|
133
|
+
// the latest supported" both resolve to this server's one version.
|
|
134
|
+
imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model);
|
|
135
|
+
return {
|
|
136
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
137
|
+
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
|
138
|
+
agentCapabilities: {
|
|
139
|
+
mcpCapabilities: { http: true },
|
|
140
|
+
promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false },
|
|
141
|
+
sessionCapabilities: { close: {}, list: {}, resume: {} },
|
|
142
|
+
// Replaying a persisted log is what makes a resumed session visible
|
|
143
|
+
// to the client that created it; `session/resume` alone does not.
|
|
144
|
+
loadSession: true,
|
|
145
|
+
},
|
|
146
|
+
authMethods: [],
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
authenticate(_params) {
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
},
|
|
152
|
+
async newSession(params, signal) {
|
|
153
|
+
assertOpen();
|
|
154
|
+
validateWorkspaceParams(params);
|
|
155
|
+
const sessionId = brandString(randomUUID());
|
|
156
|
+
// No preset composition: the ACP bundle keeps the model-facing rows in
|
|
157
|
+
// the host plane, so this agent reads them from the global layer. A
|
|
158
|
+
// deployment that configures a roster has to join one here first
|
|
159
|
+
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
|
160
|
+
let record;
|
|
161
|
+
try {
|
|
162
|
+
record = await AcpSession.create(ctx, {
|
|
163
|
+
sessionId,
|
|
164
|
+
cwd: params.cwd,
|
|
165
|
+
mcpServers: params.mcpServers,
|
|
166
|
+
agentOptions: agentOptions(config),
|
|
167
|
+
fallbackSelection: initialSelection(config),
|
|
168
|
+
signal,
|
|
169
|
+
notify,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error instanceof AcpMcpConfigError)
|
|
174
|
+
throw invalidParams(error.message);
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
|
|
178
|
+
if (closed) {
|
|
179
|
+
await record.close('connection closed during session/new');
|
|
180
|
+
throw internalError('connection closed during session/new');
|
|
181
|
+
}
|
|
182
|
+
sessions.set(sessionId, record);
|
|
183
|
+
try {
|
|
184
|
+
const configOptions = await record.configOptions(signal);
|
|
185
|
+
assertOpen();
|
|
186
|
+
// The attached log writer's flush materializes an empty session durably.
|
|
187
|
+
await ctx.sessions.flush(record.agent.session);
|
|
188
|
+
assertOpen();
|
|
189
|
+
return { sessionId, configOptions };
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
sessions.delete(sessionId);
|
|
193
|
+
await record.close('session/new activation failed');
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async resumeSession(params, signal) {
|
|
198
|
+
assertOpen();
|
|
199
|
+
validateWorkspaceParams(params);
|
|
200
|
+
const sessionId = brandString(params.sessionId);
|
|
201
|
+
if (sessions.has(sessionId) || activating.has(sessionId) || ctx.sessions.get(sessionId) !== undefined) {
|
|
202
|
+
throw invalidParams(`session is already active: ${sessionId}`);
|
|
203
|
+
}
|
|
204
|
+
activating.add(sessionId);
|
|
205
|
+
return (async () => {
|
|
206
|
+
const persisted = (await persistence.stat(sessionId, { signal }))?.header;
|
|
207
|
+
if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) {
|
|
208
|
+
throw invalidParams(`session is not resumable: ${sessionId}`);
|
|
209
|
+
}
|
|
210
|
+
if (!await sameDirectory(persisted.cwd, params.cwd)) {
|
|
211
|
+
throw invalidParams(`session cwd does not match: ${params.cwd}`);
|
|
212
|
+
}
|
|
213
|
+
let record;
|
|
214
|
+
try {
|
|
215
|
+
record = await AcpSession.resume(ctx, {
|
|
216
|
+
sessionId,
|
|
217
|
+
cwd: params.cwd,
|
|
218
|
+
mcpServers: params.mcpServers ?? [],
|
|
219
|
+
agentOptions: agentOptions(config),
|
|
220
|
+
fallbackSelection: initialSelection(config),
|
|
221
|
+
signal,
|
|
222
|
+
notify,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof AcpMcpConfigError)
|
|
227
|
+
throw invalidParams(error.message);
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
/* v8 ignore start -- the persisted header was checked before resume; the factory restores that exact header. */
|
|
231
|
+
if (!await sameDirectory(record.agent.session.header.cwd, params.cwd)) {
|
|
232
|
+
await record.close('session/resume cwd mismatch');
|
|
233
|
+
throw invalidParams(`session cwd does not match: ${params.cwd}`);
|
|
234
|
+
}
|
|
235
|
+
/* v8 ignore stop */
|
|
236
|
+
/* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */
|
|
237
|
+
if (closed) {
|
|
238
|
+
await record.close('connection closed during session/resume');
|
|
239
|
+
throw internalError('connection closed during session/resume');
|
|
240
|
+
}
|
|
241
|
+
sessions.set(sessionId, record);
|
|
242
|
+
try {
|
|
243
|
+
return { configOptions: await record.configOptions(signal) };
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
sessions.delete(sessionId);
|
|
247
|
+
await record.close('session/resume option discovery failed');
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
})().finally(() => { activating.delete(sessionId); });
|
|
251
|
+
},
|
|
252
|
+
/**
|
|
253
|
+
* Restore a persisted session and replay its transcript.
|
|
254
|
+
*
|
|
255
|
+
* ACP expects the agent to stream the whole conversation back as
|
|
256
|
+
* `session/update` notifications before this call returns, so a client that
|
|
257
|
+
* treats the agent as the transcript authority can render history after its
|
|
258
|
+
* own restart.
|
|
259
|
+
*/
|
|
260
|
+
async loadSession(params, signal) {
|
|
261
|
+
assertOpen();
|
|
262
|
+
validateWorkspaceParams(params);
|
|
263
|
+
const sessionId = brandString(params.sessionId);
|
|
264
|
+
if (sessions.has(sessionId) || activating.has(sessionId) || ctx.sessions.get(sessionId) !== undefined) {
|
|
265
|
+
throw invalidParams(`session is already active: ${sessionId}`);
|
|
266
|
+
}
|
|
267
|
+
activating.add(sessionId);
|
|
268
|
+
return (async () => {
|
|
269
|
+
// 'read' observes without taking the single write owner, so the log can
|
|
270
|
+
// be snapshotted before the restored Agent starts appending to it.
|
|
271
|
+
const handle = await persistence.open(sessionId, 'read', { signal });
|
|
272
|
+
let persisted;
|
|
273
|
+
let transcript;
|
|
274
|
+
try {
|
|
275
|
+
persisted = handle.header;
|
|
276
|
+
transcript = (await handle.read(0, undefined, { signal })).events;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
await handle.close();
|
|
280
|
+
}
|
|
281
|
+
if (persisted.origin === 'subagent' || persisted.parentSession !== undefined) {
|
|
282
|
+
throw invalidParams(`session is not loadable: ${sessionId}`);
|
|
283
|
+
}
|
|
284
|
+
if (!await sameDirectory(persisted.cwd, params.cwd)) {
|
|
285
|
+
throw invalidParams(`session cwd does not match: ${params.cwd}`);
|
|
286
|
+
}
|
|
287
|
+
let record;
|
|
288
|
+
try {
|
|
289
|
+
record = await AcpSession.resume(ctx, {
|
|
290
|
+
sessionId,
|
|
291
|
+
cwd: params.cwd,
|
|
292
|
+
mcpServers: params.mcpServers ?? [],
|
|
293
|
+
agentOptions: agentOptions(config),
|
|
294
|
+
fallbackSelection: initialSelection(config),
|
|
295
|
+
signal,
|
|
296
|
+
notify,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (error instanceof AcpMcpConfigError)
|
|
301
|
+
throw invalidParams(error.message);
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (closed) {
|
|
305
|
+
await record.close('connection closed during session/load');
|
|
306
|
+
throw internalError('connection closed during session/load');
|
|
307
|
+
}
|
|
308
|
+
sessions.set(sessionId, record);
|
|
309
|
+
try {
|
|
310
|
+
const configOptions = await record.configOptions(signal);
|
|
311
|
+
for (const update of await replaySessionUpdates(ctx, record.agent.session, transcript)) {
|
|
312
|
+
await notify({ sessionId, update });
|
|
313
|
+
}
|
|
314
|
+
return { configOptions };
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
sessions.delete(sessionId);
|
|
318
|
+
await record.close('session/load replay failed');
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
})().finally(() => { activating.delete(sessionId); });
|
|
322
|
+
},
|
|
323
|
+
async listSessions(params, signal) {
|
|
324
|
+
assertOpen();
|
|
325
|
+
if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) {
|
|
326
|
+
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
|
327
|
+
}
|
|
328
|
+
let cursor;
|
|
329
|
+
try {
|
|
330
|
+
cursor = decodeSessionListCursor(params.cursor);
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
throw invalidParams(error.message);
|
|
334
|
+
}
|
|
335
|
+
const listed = await persistence.list({ signal });
|
|
336
|
+
const filtered = await Promise.all(listed.map(async ({ header }) => {
|
|
337
|
+
if (sessions.has(header.id)
|
|
338
|
+
|| activating.has(header.id)
|
|
339
|
+
|| ctx.sessions.get(header.id) !== undefined
|
|
340
|
+
|| header.origin === 'subagent'
|
|
341
|
+
|| header.parentSession !== undefined
|
|
342
|
+
|| header.cwd === undefined
|
|
343
|
+
|| !isAbsolute(header.cwd))
|
|
344
|
+
return undefined;
|
|
345
|
+
if (params.cwd !== undefined && params.cwd !== null && !await sameDirectory(header.cwd, params.cwd)) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
return { sessionId: header.id, cwd: header.cwd, createdAt: header.createdAt };
|
|
349
|
+
}));
|
|
350
|
+
const entries = filtered
|
|
351
|
+
.filter((entry) => entry !== undefined)
|
|
352
|
+
.sort((left, right) => right.createdAt - left.createdAt || compareSessionIds(left.sessionId, right.sessionId));
|
|
353
|
+
const remaining = cursor === undefined
|
|
354
|
+
? entries
|
|
355
|
+
: entries.filter(entry => isAfterSessionListCursor(entry, cursor));
|
|
356
|
+
const page = remaining.slice(0, sessionListPageSize);
|
|
357
|
+
const next = remaining.length > page.length ? page.at(-1) : undefined;
|
|
358
|
+
return {
|
|
359
|
+
sessions: page.map(({ sessionId, cwd }) => ({ sessionId, cwd })),
|
|
360
|
+
...next === undefined ? {} : { nextCursor: encodeSessionListCursor(next) },
|
|
361
|
+
};
|
|
362
|
+
},
|
|
363
|
+
async setSessionConfigOption(params, signal) {
|
|
364
|
+
assertOpen();
|
|
365
|
+
const record = requireSession(brandString(params.sessionId));
|
|
366
|
+
try {
|
|
367
|
+
return { configOptions: await record.setConfig(params.configId, params.value, signal) };
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
if (error instanceof AcpModelConfigError)
|
|
371
|
+
throw invalidParams(error.message);
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
async closeSession(params) {
|
|
376
|
+
assertOpen();
|
|
377
|
+
const sessionId = brandString(params.sessionId);
|
|
378
|
+
const record = requireSession(sessionId);
|
|
379
|
+
try {
|
|
380
|
+
await record.close('ACP session closed');
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
throw internalError(`session close failed: ${errorChain(error)}`);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
if (sessions.get(sessionId) === record)
|
|
387
|
+
sessions.delete(sessionId);
|
|
388
|
+
}
|
|
389
|
+
return {};
|
|
390
|
+
},
|
|
391
|
+
async prompt(params, requestSignal) {
|
|
392
|
+
assertOpen();
|
|
393
|
+
const record = requireSession(brandString(params.sessionId));
|
|
394
|
+
return record.prompt(params, imagePromptEnabled, requestSignal);
|
|
395
|
+
},
|
|
396
|
+
cancel(params) {
|
|
397
|
+
sessions.get(brandString(params.sessionId))?.cancel();
|
|
398
|
+
return Promise.resolve();
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
|
|
402
|
+
const stream = config.stream ?? ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
403
|
+
const app = createAcpAgentApp({ name: 'deepseek-harness-acp' })
|
|
404
|
+
.onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params))
|
|
405
|
+
.onRequest(methods.agent.authenticate, async ({ params }) => {
|
|
406
|
+
await implementation.authenticate(params);
|
|
407
|
+
return {};
|
|
408
|
+
})
|
|
409
|
+
.onRequest(methods.agent.session.new, ({ params, signal }) => implementation.newSession(params, signal))
|
|
410
|
+
.onRequest(methods.agent.session.list, ({ params, signal }) => implementation.listSessions(params, signal))
|
|
411
|
+
.onRequest(methods.agent.session.resume, ({ params, signal }) => implementation.resumeSession(params, signal))
|
|
412
|
+
.onRequest(methods.agent.session.load, ({ params, signal }) => implementation.loadSession(params, signal))
|
|
413
|
+
.onRequest(methods.agent.session.close, ({ params }) => implementation.closeSession(params))
|
|
414
|
+
.onRequest(methods.agent.session.setConfigOption, ({ params, signal }) => implementation.setSessionConfigOption(params, signal))
|
|
415
|
+
.onRequest(methods.agent.session.prompt, ({ params, signal }) => implementation.prompt(params, signal))
|
|
416
|
+
.onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params));
|
|
417
|
+
const connection = app.connect(stream);
|
|
418
|
+
const conn = connection.client;
|
|
419
|
+
let quiescing;
|
|
420
|
+
const quiesce = () => {
|
|
421
|
+
if (quiescing !== undefined)
|
|
422
|
+
return quiescing;
|
|
423
|
+
closed = true;
|
|
424
|
+
const records = [...sessions.values()];
|
|
425
|
+
// AcpSession.close cancels synchronously before its first await, so every owned
|
|
426
|
+
// prompt stops before any descendant or persistence drain can block.
|
|
427
|
+
quiescing = (async () => {
|
|
428
|
+
const disposals = await Promise.allSettled(records.map(record => record.close('ACP bridge disposed')));
|
|
429
|
+
for (const record of records) {
|
|
430
|
+
/* v8 ignore next -- closed blocks concurrent handlers; each captured record remains mapped until this loop. */
|
|
431
|
+
if (sessions.get(record.agent.session.id) === record)
|
|
432
|
+
sessions.delete(record.agent.session.id);
|
|
433
|
+
}
|
|
434
|
+
const failures = [];
|
|
435
|
+
for (const result of disposals) {
|
|
436
|
+
if (result.status === 'rejected')
|
|
437
|
+
failures.push(result.reason);
|
|
438
|
+
}
|
|
439
|
+
if (failures.length > 0) {
|
|
440
|
+
// The production consumer logs this AggregateError through `String`,
|
|
441
|
+
// which renders only its message. Embed every per-session diagnostic,
|
|
442
|
+
// including nested causes and aggregate members, in that message.
|
|
443
|
+
const detail = failures.map(failure => errorChain(failure)).join('; ');
|
|
444
|
+
throw new AggregateError(failures, `ACP agent teardown failed for ${failures.length} session(s): ${detail}`);
|
|
445
|
+
}
|
|
446
|
+
})();
|
|
447
|
+
return quiescing;
|
|
448
|
+
};
|
|
449
|
+
/* v8 ignore start -- production transport rejection and teardown failure. */
|
|
450
|
+
void connection.closed
|
|
451
|
+
.catch((error) => {
|
|
452
|
+
logger.warn(`acp: connection closed with an error: ${String(error)}`);
|
|
453
|
+
})
|
|
454
|
+
.then(quiesce)
|
|
455
|
+
.catch((error) => {
|
|
456
|
+
logger.warn(`acp: connection-close teardown failed: ${String(error)}`);
|
|
457
|
+
});
|
|
458
|
+
/* v8 ignore stop */
|
|
459
|
+
ctx.effect(() => quiesce, 'acp.connection');
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Build per-agent options from plugin config without assigning absent optional fields.
|
|
463
|
+
* @param config - ACP provider/model configuration.
|
|
464
|
+
* @returns the configured fields only.
|
|
465
|
+
*/
|
|
466
|
+
function agentOptions(config) {
|
|
467
|
+
return {
|
|
468
|
+
...config.provider !== undefined ? { provider: config.provider } : {},
|
|
469
|
+
...config.model !== undefined ? { model: config.model } : {},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** Initial session selection when both deployment fields are present. */
|
|
473
|
+
function initialSelection(config) {
|
|
474
|
+
return config.provider === undefined || config.model === undefined
|
|
475
|
+
? undefined
|
|
476
|
+
: { provider: config.provider, model: config.model };
|
|
477
|
+
}
|
|
478
|
+
/** Resolve and validate the deployment-owned session page limit. */
|
|
479
|
+
function resolveSessionListPageSize(value) {
|
|
480
|
+
const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE;
|
|
481
|
+
/* v8 ignore start -- Cordis applies the positive-integer Config schema; this protects direct apply callers. */
|
|
482
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1) {
|
|
483
|
+
throw new Error('acp: sessionListPageSize must be a positive safe integer');
|
|
484
|
+
}
|
|
485
|
+
/* v8 ignore stop */
|
|
486
|
+
return resolved;
|
|
487
|
+
}
|
|
488
|
+
/** Decode an opaque keyset cursor without assigning meaning to client metadata. */
|
|
489
|
+
function decodeSessionListCursor(value) {
|
|
490
|
+
if (value === undefined || value === null)
|
|
491
|
+
return undefined;
|
|
492
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
493
|
+
throw new Error('session/list cursor is invalid');
|
|
494
|
+
try {
|
|
495
|
+
const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
496
|
+
const createdAt = Array.isArray(decoded) ? decoded[0] : undefined;
|
|
497
|
+
const sessionId = Array.isArray(decoded) ? decoded[1] : undefined;
|
|
498
|
+
if (!Array.isArray(decoded)
|
|
499
|
+
|| decoded.length !== 2
|
|
500
|
+
|| typeof createdAt !== 'number'
|
|
501
|
+
|| !Number.isSafeInteger(createdAt)
|
|
502
|
+
|| createdAt < 0
|
|
503
|
+
|| typeof sessionId !== 'string'
|
|
504
|
+
|| sessionId.length === 0)
|
|
505
|
+
throw new Error('invalid cursor fields');
|
|
506
|
+
const canonical = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url');
|
|
507
|
+
if (canonical !== value)
|
|
508
|
+
throw new Error('non-canonical cursor');
|
|
509
|
+
return { createdAt, sessionId };
|
|
510
|
+
}
|
|
511
|
+
catch (_invalidCursor) {
|
|
512
|
+
throw new Error('session/list cursor is invalid');
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** Encode the last returned ordering key as an opaque continuation token. */
|
|
516
|
+
function encodeSessionListCursor(entry) {
|
|
517
|
+
return Buffer.from(JSON.stringify([entry.createdAt, entry.sessionId]), 'utf8').toString('base64url');
|
|
518
|
+
}
|
|
519
|
+
/** Test whether an entry follows the cursor in newest-first list order. */
|
|
520
|
+
function isAfterSessionListCursor(entry, cursor) {
|
|
521
|
+
return entry.createdAt < cursor.createdAt
|
|
522
|
+
|| (entry.createdAt === cursor.createdAt && compareSessionIds(entry.sessionId, cursor.sessionId) > 0);
|
|
523
|
+
}
|
|
524
|
+
/** Compare opaque session ids by stable UTF-8 bytes, independent of process locale. */
|
|
525
|
+
function compareSessionIds(left, right) {
|
|
526
|
+
return Buffer.compare(Buffer.from(left), Buffer.from(right));
|
|
527
|
+
}
|
|
528
|
+
/** Reject workspace features outside the automation contract. */
|
|
529
|
+
function validateWorkspaceParams(params) {
|
|
530
|
+
if (!isAbsolute(params.cwd))
|
|
531
|
+
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`);
|
|
532
|
+
if (params.additionalDirectories !== undefined
|
|
533
|
+
&& params.additionalDirectories !== null
|
|
534
|
+
&& params.additionalDirectories.length > 0) {
|
|
535
|
+
throw invalidParams('additionalDirectories is not supported');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/** Compare existing directories by physical identity and missing paths lexically. */
|
|
539
|
+
async function sameDirectory(left, right) {
|
|
540
|
+
if (left === undefined)
|
|
541
|
+
return false;
|
|
542
|
+
try {
|
|
543
|
+
const [realLeft, realRight] = await Promise.all([realpath(left), realpath(right)]);
|
|
544
|
+
return realLeft === realRight;
|
|
545
|
+
}
|
|
546
|
+
catch (_unresolvablePath) {
|
|
547
|
+
return resolve(left) === resolve(right);
|
|
548
|
+
}
|
|
549
|
+
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Standard ACP MCP-server declarations translated into Agent-scoped DSH MCP clients. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { validateHeaderName, validateHeaderValue } from 'node:http';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
5
|
+
import * as McpClient from '@deepseek-ai/dsh-mcp-client';
|
|
6
|
+
const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
7
|
+
/** Caller-correctable MCP declaration failure. */
|
|
8
|
+
export class AcpMcpConfigError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'AcpMcpConfigError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validate and mount one session's complete standard MCP server list before Agent publication.
|
|
16
|
+
* @param agentCtx - unpublished Agent scope that owns the MCP clients and tools.
|
|
17
|
+
* @param servers - stable ACP stdio or HTTP server declarations.
|
|
18
|
+
* @param sessionCwd - canonical primary workspace used by stdio servers.
|
|
19
|
+
*/
|
|
20
|
+
export async function mountAcpMcpServers(agentCtx, servers, sessionCwd) {
|
|
21
|
+
const configs = resolveMcpConfigs(servers, sessionCwd);
|
|
22
|
+
for (const config of configs)
|
|
23
|
+
await agentCtx.plugin(McpClient, config);
|
|
24
|
+
}
|
|
25
|
+
/** Convert the stable stdio/HTTP ACP transports and reject every other transport. */
|
|
26
|
+
function resolveMcpConfigs(servers, sessionCwd) {
|
|
27
|
+
const names = new Set();
|
|
28
|
+
return servers.map((server, index) => {
|
|
29
|
+
const serverName = normalizeServerName(server.name);
|
|
30
|
+
if (names.has(serverName)) {
|
|
31
|
+
throw new AcpMcpConfigError(`mcpServers contains duplicate normalized name: ${serverName}`);
|
|
32
|
+
}
|
|
33
|
+
names.add(serverName);
|
|
34
|
+
if (!('type' in server)) {
|
|
35
|
+
if (!isAbsolute(server.command)) {
|
|
36
|
+
throw new AcpMcpConfigError(`mcpServers[${index}].command must be an absolute path`);
|
|
37
|
+
}
|
|
38
|
+
const env = entriesToRecord(server.env, `mcpServers[${index}].env`, 'environment');
|
|
39
|
+
const config = validateClientConfig(index, () => McpClient.Config({
|
|
40
|
+
transport: 'stdio',
|
|
41
|
+
serverName,
|
|
42
|
+
command: server.command,
|
|
43
|
+
args: server.args,
|
|
44
|
+
env,
|
|
45
|
+
cwd: sessionCwd,
|
|
46
|
+
failOnStartupError: true,
|
|
47
|
+
}));
|
|
48
|
+
return { ...config, env };
|
|
49
|
+
}
|
|
50
|
+
if (server.type === 'http') {
|
|
51
|
+
assertHttpUrl(server.url, `mcpServers[${index}].url`);
|
|
52
|
+
const headers = entriesToRecord(server.headers, `mcpServers[${index}].headers`, 'header');
|
|
53
|
+
const config = validateClientConfig(index, () => McpClient.Config({
|
|
54
|
+
transport: 'streamable-http',
|
|
55
|
+
serverName,
|
|
56
|
+
url: server.url,
|
|
57
|
+
headers,
|
|
58
|
+
failOnStartupError: true,
|
|
59
|
+
}));
|
|
60
|
+
return { ...config, headers };
|
|
61
|
+
}
|
|
62
|
+
throw new AcpMcpConfigError(`mcpServers[${index}] transport ${server.type} is not supported`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Convert ordered ACP name/value entries without silently accepting duplicate keys. */
|
|
66
|
+
function entriesToRecord(entries, field, kind) {
|
|
67
|
+
// Valid environment and header names include "__proto__"; a null prototype
|
|
68
|
+
// keeps that entry as data instead of invoking Object.prototype's setter.
|
|
69
|
+
const result = Object.create(null);
|
|
70
|
+
const names = new Set();
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (kind === 'header') {
|
|
73
|
+
try {
|
|
74
|
+
validateHeaderName(entry.name);
|
|
75
|
+
validateHeaderValue(entry.name, entry.value);
|
|
76
|
+
}
|
|
77
|
+
catch (_invalidHeader) {
|
|
78
|
+
throw new AcpMcpConfigError(`${field} contains an invalid header entry`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (entry.name.length === 0
|
|
82
|
+
|| entry.name.includes('=')
|
|
83
|
+
|| entry.name.includes('\0')
|
|
84
|
+
|| entry.value.includes('\0')) {
|
|
85
|
+
throw new AcpMcpConfigError(`${field} contains an invalid environment entry`);
|
|
86
|
+
}
|
|
87
|
+
const identity = kind === 'header' ? entry.name.toLowerCase() : entry.name;
|
|
88
|
+
if (names.has(identity))
|
|
89
|
+
throw new AcpMcpConfigError(`${field} contains duplicate name: ${entry.name}`);
|
|
90
|
+
names.add(identity);
|
|
91
|
+
result[entry.name] = entry.value;
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
/** Produce a stable DSH tool namespace from ACP's human-readable server name. */
|
|
96
|
+
function normalizeServerName(name) {
|
|
97
|
+
if (name.trim().length === 0 || /[\u0000-\u001f\u007f]/.test(name)) {
|
|
98
|
+
throw new AcpMcpConfigError('mcpServers contains an invalid server name');
|
|
99
|
+
}
|
|
100
|
+
if (VALID_SERVER_NAME.test(name))
|
|
101
|
+
return name;
|
|
102
|
+
const slug = name.normalize('NFKD')
|
|
103
|
+
.replace(/[^A-Za-z0-9_-]+/g, '_')
|
|
104
|
+
.replace(/^_+|_+$/g, '')
|
|
105
|
+
.slice(0, 20) || 'server';
|
|
106
|
+
const digest = createHash('sha256').update(name).digest('hex').slice(0, 8);
|
|
107
|
+
return `${slug}_${digest}`.slice(0, 32);
|
|
108
|
+
}
|
|
109
|
+
/** Require the stable Streamable HTTP transport URL schemes. */
|
|
110
|
+
function assertHttpUrl(value, field) {
|
|
111
|
+
try {
|
|
112
|
+
const url = new URL(value);
|
|
113
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
114
|
+
throw new Error('unsupported protocol');
|
|
115
|
+
}
|
|
116
|
+
catch (_invalidUrl) {
|
|
117
|
+
throw new AcpMcpConfigError(`${field} must be an absolute HTTP(S) URL`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Map the existing MCP provider's schema error into ACP invalid params. */
|
|
121
|
+
function validateClientConfig(index, parse) {
|
|
122
|
+
try {
|
|
123
|
+
return parse();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
/* v8 ignore next -- Schemastery validation rejects with Error instances. */
|
|
127
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
128
|
+
throw new AcpMcpConfigError(`mcpServers[${index}] is invalid: ${detail}`);
|
|
129
|
+
}
|
|
130
|
+
}
|