@mono-agent/agent-runtime 0.17.1 → 0.18.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/MIGRATION.md +30 -3
- package/README.md +93 -12
- package/package.json +7 -2
- package/src/ai/failure.js +2 -1
- package/src/ai/index.js +2 -0
- package/src/ai/providers/acp-client.js +1124 -0
- package/src/ai/providers/acp-privacy.js +124 -0
- package/src/ai/providers/acp-public.js +21 -0
- package/src/ai/providers/acp-session-tokens.js +129 -0
- package/src/ai/providers/acp-transport.js +259 -0
- package/src/ai/providers/acp.js +523 -0
- package/src/ai/runtime/capabilities.js +16 -0
- package/src/ai/runtime/model-refs.js +20 -1
- package/src/ai/runtime/registry.js +6 -0
- package/src/ai/runtime/router.js +4 -1
- package/src/ai/types.js +9 -5
- package/src/runtime.js +4 -2
- package/types/ai/failure.d.ts +2 -2
- package/types/ai/index.d.ts +2 -0
- package/types/ai/providers/acp-client.d.ts +223 -0
- package/types/ai/providers/acp-privacy.d.ts +25 -0
- package/types/ai/providers/acp-public.d.ts +7 -0
- package/types/ai/providers/acp-session-tokens.d.ts +34 -0
- package/types/ai/providers/acp-transport.d.ts +32 -0
- package/types/ai/providers/acp.d.ts +93 -0
- package/types/ai/runtime/capabilities.d.ts +21 -0
- package/types/ai/types.d.ts +29 -9
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { runtimeCapabilities } from "../runtime/capabilities.js";
|
|
5
|
+
import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
|
|
6
|
+
import {
|
|
7
|
+
AcpClientError,
|
|
8
|
+
connectAcpProfile,
|
|
9
|
+
} from "./acp-client.js";
|
|
10
|
+
import {
|
|
11
|
+
decodeAcpProviderSessionId,
|
|
12
|
+
encodeAcpProviderSessionId,
|
|
13
|
+
validateAcpProviderSessionId,
|
|
14
|
+
} from "./acp-session-tokens.js";
|
|
15
|
+
import {
|
|
16
|
+
ownAcpSessionUpdateKind,
|
|
17
|
+
sanitizeAcpHostValueWithStatus,
|
|
18
|
+
} from "./acp-privacy.js";
|
|
19
|
+
|
|
20
|
+
/** @param {any} callback @param {any} event */
|
|
21
|
+
function emit(callback, event) {
|
|
22
|
+
try { callback?.(event); } catch { /* observers cannot break a provider turn */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {any} value */
|
|
26
|
+
function jsonSafe(value) {
|
|
27
|
+
try { return JSON.parse(JSON.stringify(value)); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {any} content @returns {string} */
|
|
31
|
+
function textFromContent(content) {
|
|
32
|
+
if (typeof content === "string") return content;
|
|
33
|
+
if (!Array.isArray(content)) return content == null ? "" : String(content);
|
|
34
|
+
return content
|
|
35
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
36
|
+
.map((block) => block.text)
|
|
37
|
+
.join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {any} block @param {any} promptCapabilities */
|
|
41
|
+
function normalizePromptBlock(block, promptCapabilities) {
|
|
42
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) {
|
|
43
|
+
throw new AcpClientError("invalid_request", "ACP prompt content blocks must be objects.");
|
|
44
|
+
}
|
|
45
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
46
|
+
return { type: "text", text: block.text };
|
|
47
|
+
}
|
|
48
|
+
// Resource links are ACP baseline content and must survive normalization.
|
|
49
|
+
if (block.type === "resource_link"
|
|
50
|
+
&& typeof block.uri === "string"
|
|
51
|
+
&& typeof block.name === "string") {
|
|
52
|
+
return jsonSafe(block);
|
|
53
|
+
}
|
|
54
|
+
if (block.type === "image" && promptCapabilities?.image === true) return jsonSafe(block);
|
|
55
|
+
if (block.type === "audio" && promptCapabilities?.audio === true) return jsonSafe(block);
|
|
56
|
+
if (block.type === "resource" && promptCapabilities?.embeddedContext === true) return jsonSafe(block);
|
|
57
|
+
throw new AcpClientError("capability_missing", `ACP agent cannot accept prompt content type '${block.type || "unknown"}'.`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert runtime history to one ACP user prompt. A fresh ACP session has no
|
|
62
|
+
* protocol method for importing arbitrary prior assistant turns, so the
|
|
63
|
+
* client-owned path sends them as a labelled transcript while preserving
|
|
64
|
+
* baseline resource_link blocks as real blocks. A resumed session sends only
|
|
65
|
+
* the latest user content because the agent already owns prior history.
|
|
66
|
+
* @param {string} systemPrompt
|
|
67
|
+
* @param {any[]} messages
|
|
68
|
+
* @param {{includeHistory: boolean, includeSystem: boolean, promptCapabilities: any}} options
|
|
69
|
+
*/
|
|
70
|
+
function runtimePrompt(systemPrompt, messages, options) {
|
|
71
|
+
const source = Array.isArray(messages) ? messages : [];
|
|
72
|
+
const selected = options.includeHistory
|
|
73
|
+
? source
|
|
74
|
+
: (() => {
|
|
75
|
+
for (let index = source.length - 1; index >= 0; index -= 1) {
|
|
76
|
+
if (source[index]?.role === "user") return [source[index]];
|
|
77
|
+
}
|
|
78
|
+
return source.length > 0 ? [source[source.length - 1]] : [];
|
|
79
|
+
})();
|
|
80
|
+
/** @type {any[]} */
|
|
81
|
+
const blocks = [];
|
|
82
|
+
if (options.includeSystem && systemPrompt.trim()) {
|
|
83
|
+
blocks.push({ type: "text", text: `[System]\n${systemPrompt}` });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
selected.forEach((message, messageIndex) => {
|
|
87
|
+
const role = typeof message?.role === "string" ? message.role : "user";
|
|
88
|
+
const content = message?.content;
|
|
89
|
+
if (Array.isArray(content)) {
|
|
90
|
+
let labelled = false;
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
const normalized = normalizePromptBlock(block, options.promptCapabilities);
|
|
93
|
+
if (normalized.type === "text") {
|
|
94
|
+
blocks.push({
|
|
95
|
+
...normalized,
|
|
96
|
+
text: `${labelled ? "" : `[${role}]\n`}${normalized.text}`,
|
|
97
|
+
});
|
|
98
|
+
labelled = true;
|
|
99
|
+
} else {
|
|
100
|
+
blocks.push(normalized);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (content.length === 0) blocks.push({ type: "text", text: `[${role}]\n` });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const text = textFromContent(content);
|
|
107
|
+
if (text || messageIndex === selected.length - 1) {
|
|
108
|
+
blocks.push({ type: "text", text: `[${role}]\n${text}` });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
if (blocks.length === 0) blocks.push({ type: "text", text: "" });
|
|
112
|
+
return blocks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {unknown} value @param {string} key */
|
|
116
|
+
function ownValue(value, key) {
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, key)) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
return /** @type {Record<string, unknown>} */ (value)[key];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {any} update @param {any} state */
|
|
124
|
+
function normalizeUpdate(update, state) {
|
|
125
|
+
const protocolBody = update.update;
|
|
126
|
+
const updateKind = ownAcpSessionUpdateKind(protocolBody);
|
|
127
|
+
if (updateKind === null) {
|
|
128
|
+
state.protocolError ||= new AcpClientError("protocol", "ACP session update has no valid own discriminator.");
|
|
129
|
+
return [{ type: "acp_session_update_rejected", reason: "invalid_discriminator" }];
|
|
130
|
+
}
|
|
131
|
+
const sanitized = sanitizeAcpHostValueWithStatus(protocolBody, [update.sessionId]);
|
|
132
|
+
if (sanitized.truncated) {
|
|
133
|
+
state.protocolError ||= new AcpClientError(
|
|
134
|
+
"protocol",
|
|
135
|
+
"ACP session update exceeded safe host normalization limits.",
|
|
136
|
+
);
|
|
137
|
+
return [{ type: "acp_session_update_rejected", reason: "normalization_limit" }];
|
|
138
|
+
}
|
|
139
|
+
const publicBody = sanitized.value;
|
|
140
|
+
/** @param {unknown} value */
|
|
141
|
+
const publicValue = (value) => sanitizeAcpHostValueWithStatus(value, [update.sessionId]).value;
|
|
142
|
+
const raw = {
|
|
143
|
+
type: "acp_session_update",
|
|
144
|
+
update: jsonSafe({ ...publicBody, sessionUpdate: updateKind }),
|
|
145
|
+
};
|
|
146
|
+
/** @type {any[]} */
|
|
147
|
+
const events = [raw];
|
|
148
|
+
switch (updateKind) {
|
|
149
|
+
case "agent_message_chunk": {
|
|
150
|
+
const content = publicValue(ownValue(protocolBody, "content"));
|
|
151
|
+
if (content?.type === "text" && typeof content.text === "string") state.text.push(content.text);
|
|
152
|
+
events.push({ type: "assistant", message: { content: [jsonSafe(content)] } });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "agent_thought_chunk": {
|
|
156
|
+
const content = publicValue(ownValue(protocolBody, "content"));
|
|
157
|
+
if (content?.type === "text" && typeof content.text === "string") state.thinking.push(content.text);
|
|
158
|
+
events.push({
|
|
159
|
+
type: "assistant",
|
|
160
|
+
message: { content: [{ type: "thinking", text: content?.type === "text" ? content.text : "" }] },
|
|
161
|
+
});
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "tool_call":
|
|
165
|
+
events.push({
|
|
166
|
+
type: "assistant",
|
|
167
|
+
message: {
|
|
168
|
+
content: [{
|
|
169
|
+
type: "tool_use",
|
|
170
|
+
id: publicValue(ownValue(protocolBody, "toolCallId")),
|
|
171
|
+
name: publicValue(ownValue(protocolBody, "name"))
|
|
172
|
+
|| publicValue(ownValue(protocolBody, "title"))
|
|
173
|
+
|| "acp_tool",
|
|
174
|
+
input: jsonSafe(publicValue(ownValue(protocolBody, "rawInput") ?? {})),
|
|
175
|
+
}],
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
break;
|
|
179
|
+
case "tool_call_update":
|
|
180
|
+
if (["completed", "failed"].includes(/** @type {any} */ (ownValue(protocolBody, "status")))) {
|
|
181
|
+
const rawOutput = ownValue(protocolBody, "rawOutput") ?? ownValue(protocolBody, "content") ?? "";
|
|
182
|
+
const output = publicValue(rawOutput);
|
|
183
|
+
events.push({
|
|
184
|
+
type: "user",
|
|
185
|
+
message: {
|
|
186
|
+
content: [{
|
|
187
|
+
type: "tool_result",
|
|
188
|
+
tool_use_id: publicValue(ownValue(protocolBody, "toolCallId")),
|
|
189
|
+
content: typeof output === "string" ? output : JSON.stringify(jsonSafe(output)),
|
|
190
|
+
is_error: ownValue(protocolBody, "status") === "failed",
|
|
191
|
+
}],
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
case "usage_update":
|
|
197
|
+
state.usage = {
|
|
198
|
+
totalTokens: Number(ownValue(protocolBody, "used")) || 0,
|
|
199
|
+
contextWindow: Number(ownValue(protocolBody, "size")) || 0,
|
|
200
|
+
cost: publicValue(ownValue(protocolBody, "cost")) || null,
|
|
201
|
+
};
|
|
202
|
+
events.push({
|
|
203
|
+
type: "context_usage",
|
|
204
|
+
model: state.model,
|
|
205
|
+
source: "acp",
|
|
206
|
+
context: {
|
|
207
|
+
used: Number(ownValue(protocolBody, "used")) || 0,
|
|
208
|
+
window: Number(ownValue(protocolBody, "size")) || 0,
|
|
209
|
+
},
|
|
210
|
+
cost: publicValue(ownValue(protocolBody, "cost")) || null,
|
|
211
|
+
});
|
|
212
|
+
break;
|
|
213
|
+
case "plan":
|
|
214
|
+
case "plan_update":
|
|
215
|
+
case "plan_removed":
|
|
216
|
+
events.push({
|
|
217
|
+
type: "plan",
|
|
218
|
+
source: "acp",
|
|
219
|
+
update: jsonSafe({ ...publicBody, sessionUpdate: updateKind }),
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
return events;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** @param {any} descriptor @param {any} req */
|
|
229
|
+
function workspaceFor(descriptor, req) {
|
|
230
|
+
if (descriptor.workspaceOwner === "agent") return descriptor.workspacePath;
|
|
231
|
+
const cwd = req.cwd || descriptor.workspacePath || descriptor.cwd || process.cwd();
|
|
232
|
+
if (typeof cwd !== "string" || !isAbsolute(cwd)) {
|
|
233
|
+
throw new AcpClientError("invalid_request", "ACP runtime cwd must be absolute.");
|
|
234
|
+
}
|
|
235
|
+
return cwd;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @param {any} descriptor */
|
|
239
|
+
function sessionConfig(descriptor) {
|
|
240
|
+
const config = descriptor.sessionConfig || {};
|
|
241
|
+
return {
|
|
242
|
+
additionalDirectories: [...(config.additionalDirectories || [])],
|
|
243
|
+
mcpServers: descriptor.mcpOwner === "agent" ? [] : [...(config.mcpServers || [])],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @param {any} connection @param {string} sessionId @param {any} setupResponse @param {any} descriptor */
|
|
248
|
+
async function applyClientConfiguration(connection, sessionId, setupResponse, descriptor) {
|
|
249
|
+
if (descriptor.configurationOwner === "agent") return { modeApplied: false, configOptionsApplied: [] };
|
|
250
|
+
const config = descriptor.sessionConfig || {};
|
|
251
|
+
let modes = setupResponse?.modes || null;
|
|
252
|
+
let configOptions = Array.isArray(setupResponse?.configOptions) ? setupResponse.configOptions : [];
|
|
253
|
+
let modeApplied = false;
|
|
254
|
+
const applied = [];
|
|
255
|
+
if (config.modeId !== undefined) {
|
|
256
|
+
const available = modes?.availableModes?.some((mode) => mode?.id === config.modeId) === true;
|
|
257
|
+
if (!available) throw new AcpClientError("capability_missing", "Configured ACP session mode was not advertised.");
|
|
258
|
+
await connection.setSessionMode(sessionId, config.modeId);
|
|
259
|
+
modeApplied = true;
|
|
260
|
+
}
|
|
261
|
+
for (const [configId, value] of Object.entries(config.configOptions || {})) {
|
|
262
|
+
const option = configOptions.find((candidate) => candidate?.id === configId);
|
|
263
|
+
if (!option) throw new AcpClientError("capability_missing", "Configured ACP session option was not advertised.");
|
|
264
|
+
if (option.type === "boolean") {
|
|
265
|
+
if (typeof value !== "boolean") {
|
|
266
|
+
throw new AcpClientError("invalid_profile", "ACP boolean config option requires a boolean value.");
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
if (typeof value !== "string" || !selectValues(option.options).includes(value)) {
|
|
270
|
+
throw new AcpClientError("invalid_profile", "ACP select config option value was not advertised.");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const response = await connection.setSessionConfigOption(sessionId, configId, value);
|
|
274
|
+
configOptions = Array.isArray(response?.configOptions) ? response.configOptions : configOptions;
|
|
275
|
+
applied.push(configId);
|
|
276
|
+
}
|
|
277
|
+
return { modeApplied, configOptionsApplied: applied };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @param {any[]} options */
|
|
281
|
+
function selectValues(options) {
|
|
282
|
+
if (!Array.isArray(options)) return [];
|
|
283
|
+
const values = [];
|
|
284
|
+
for (const item of options) {
|
|
285
|
+
if (typeof item?.value === "string") values.push(item.value);
|
|
286
|
+
if (Array.isArray(item?.options)) values.push(...selectValues(item.options));
|
|
287
|
+
}
|
|
288
|
+
return values;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** @param {any} connection @param {any} descriptor @param {any} req @param {string} profileId @param {(notification:any)=>void} onUpdate */
|
|
292
|
+
async function openSession(connection, descriptor, req, profileId, onUpdate) {
|
|
293
|
+
const cwd = workspaceFor(descriptor, req);
|
|
294
|
+
const config = sessionConfig(descriptor);
|
|
295
|
+
const baseRequest = { cwd, ...config };
|
|
296
|
+
if (!req.providerSessionId) {
|
|
297
|
+
const response = await connection.newSession(baseRequest);
|
|
298
|
+
return { sessionId: response.sessionId, response, resumed: false, resumeMethod: null };
|
|
299
|
+
}
|
|
300
|
+
const decoded = decodeAcpProviderSessionId(req.providerSessionId);
|
|
301
|
+
if (decoded.profileId !== profileId) {
|
|
302
|
+
throw new AcpClientError("invalid_session_id", "ACP provider session belongs to a different profile.");
|
|
303
|
+
}
|
|
304
|
+
const remove = connection.onSessionUpdate(decoded.sessionId, onUpdate);
|
|
305
|
+
try {
|
|
306
|
+
const strategy = descriptor.sessionConfig?.resumeStrategy || "auto";
|
|
307
|
+
if (strategy === "resume" || (strategy === "auto" && connection.hasCapability("resume"))) {
|
|
308
|
+
if (!connection.hasCapability("resume")) {
|
|
309
|
+
throw new AcpClientError("capability_missing", "ACP agent did not advertise session/resume.");
|
|
310
|
+
}
|
|
311
|
+
const response = await connection.resumeSession({ sessionId: decoded.sessionId, ...baseRequest });
|
|
312
|
+
return { sessionId: decoded.sessionId, response, resumed: true, resumeMethod: "resume" };
|
|
313
|
+
}
|
|
314
|
+
if (strategy === "load" || (strategy === "auto" && connection.hasCapability("load"))) {
|
|
315
|
+
if (!connection.hasCapability("load")) {
|
|
316
|
+
throw new AcpClientError("capability_missing", "ACP agent did not advertise session/load.");
|
|
317
|
+
}
|
|
318
|
+
const response = await connection.loadSession({ sessionId: decoded.sessionId, ...baseRequest });
|
|
319
|
+
return { sessionId: decoded.sessionId, response, resumed: true, resumeMethod: "load" };
|
|
320
|
+
}
|
|
321
|
+
if (strategy === "auto") {
|
|
322
|
+
const response = await connection.newSession(baseRequest);
|
|
323
|
+
return {
|
|
324
|
+
sessionId: response.sessionId,
|
|
325
|
+
response,
|
|
326
|
+
resumed: false,
|
|
327
|
+
resumeMethod: "new_fallback",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
throw new AcpClientError("invalid_profile", "Invalid ACP resume strategy.");
|
|
331
|
+
} finally {
|
|
332
|
+
remove();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** @param {unknown} error @param {boolean} aborted */
|
|
337
|
+
function failureFor(error, aborted) {
|
|
338
|
+
const coded = /** @type {any} */ (error);
|
|
339
|
+
if (aborted || coded?.code === "cancelled") return { cancelled: true, failureKind: null, message: null };
|
|
340
|
+
if (coded?.code === "timeout") return { cancelled: false, failureKind: "timeout", message: coded.message };
|
|
341
|
+
if (coded?.code === "spawn") return { cancelled: false, failureKind: "spawn", message: coded.message };
|
|
342
|
+
return {
|
|
343
|
+
cancelled: false,
|
|
344
|
+
failureKind: "provider_protocol",
|
|
345
|
+
message: error instanceof Error ? error.message : "ACP provider protocol failed.",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Runtime bridge for one ACP v1 profile turn.
|
|
351
|
+
* @param {string} systemPrompt
|
|
352
|
+
* @param {any} req
|
|
353
|
+
*/
|
|
354
|
+
export async function generateAcpResponse(systemPrompt, req) {
|
|
355
|
+
const start = Date.now();
|
|
356
|
+
const profileId = req?.model?.model;
|
|
357
|
+
const reference = req?.model?.reference || `acp:${profileId || "unknown"}`;
|
|
358
|
+
const events = [];
|
|
359
|
+
const state = { text: [], thinking: [], model: reference, usage: null, protocolError: null };
|
|
360
|
+
const onEvent = req?.onEvent;
|
|
361
|
+
const capture = (event) => {
|
|
362
|
+
events.push(event);
|
|
363
|
+
emit(onEvent, event);
|
|
364
|
+
};
|
|
365
|
+
capture({ type: "provider_request_started", sdk: "acp", model: reference, runtime: "acp-stdio", timestamp: start });
|
|
366
|
+
let connection;
|
|
367
|
+
let sessionId = null;
|
|
368
|
+
let providerSessionId = null;
|
|
369
|
+
let setup = null;
|
|
370
|
+
let configuration = { modeApplied: false, configOptionsApplied: [] };
|
|
371
|
+
try {
|
|
372
|
+
if (req?.providerSessionId != null) {
|
|
373
|
+
providerSessionId = validateAcpProviderSessionId(req.providerSessionId, profileId);
|
|
374
|
+
}
|
|
375
|
+
connection = await connectAcpProfile(profileId, {
|
|
376
|
+
resolveAcpProfile: req.resolveAcpProfile,
|
|
377
|
+
onAcpInteractionRequest: req.onAcpInteractionRequest,
|
|
378
|
+
sandbox: req.toolContext?.sandbox || req.sandbox,
|
|
379
|
+
sandboxPolicy: resolveSandboxPolicy(req.toolContext, req.sandboxPolicy),
|
|
380
|
+
sandboxEngine: req.sandboxEngine || req.toolContext?.sandboxEngine,
|
|
381
|
+
cwd: req.cwd,
|
|
382
|
+
signal: req.abortSignal,
|
|
383
|
+
context: { operation: "run", model: reference },
|
|
384
|
+
operation: "run",
|
|
385
|
+
});
|
|
386
|
+
capture({
|
|
387
|
+
type: "capabilities_resolved",
|
|
388
|
+
sdk: "acp",
|
|
389
|
+
model: reference,
|
|
390
|
+
capabilitiesUsed: {
|
|
391
|
+
protocol_version: connection.initializeResult.protocolVersion,
|
|
392
|
+
agent_capabilities: jsonSafe(connection.initializeResult.agentCapabilities || {}),
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
const onUpdate = (notification) => {
|
|
396
|
+
for (const event of normalizeUpdate(notification, state)) capture(event);
|
|
397
|
+
};
|
|
398
|
+
setup = await openSession(connection, connection.descriptor, req, profileId, onUpdate);
|
|
399
|
+
sessionId = setup.sessionId;
|
|
400
|
+
providerSessionId = encodeAcpProviderSessionId(profileId, sessionId);
|
|
401
|
+
configuration = await applyClientConfiguration(
|
|
402
|
+
connection,
|
|
403
|
+
sessionId,
|
|
404
|
+
setup.response,
|
|
405
|
+
connection.descriptor,
|
|
406
|
+
);
|
|
407
|
+
const remove = connection.onSessionUpdate(sessionId, onUpdate);
|
|
408
|
+
let promptResponse;
|
|
409
|
+
try {
|
|
410
|
+
promptResponse = await connection.prompt(
|
|
411
|
+
sessionId,
|
|
412
|
+
runtimePrompt(systemPrompt, req.messages || [], {
|
|
413
|
+
includeHistory: !setup.resumed,
|
|
414
|
+
includeSystem: !setup.resumed && connection.descriptor.configurationOwner === "client",
|
|
415
|
+
promptCapabilities: connection.initializeResult.agentCapabilities?.promptCapabilities || {},
|
|
416
|
+
}),
|
|
417
|
+
{ signal: req.abortSignal },
|
|
418
|
+
);
|
|
419
|
+
} finally {
|
|
420
|
+
remove();
|
|
421
|
+
}
|
|
422
|
+
if (state.protocolError) throw state.protocolError;
|
|
423
|
+
// PromptResponse.usage is explicitly unstable in ACP 1.3.0. The stable
|
|
424
|
+
// cumulative source is the latest top-level usage_update notification.
|
|
425
|
+
const usage = state.usage;
|
|
426
|
+
capture({
|
|
427
|
+
type: "provider_request_completed",
|
|
428
|
+
sdk: "acp",
|
|
429
|
+
model: reference,
|
|
430
|
+
runtime: "acp-stdio",
|
|
431
|
+
timestamp: Date.now(),
|
|
432
|
+
durationMs: Date.now() - start,
|
|
433
|
+
cancelled: promptResponse.stopReason === "cancelled",
|
|
434
|
+
});
|
|
435
|
+
const limited = promptResponse.stopReason === "max_tokens" || promptResponse.stopReason === "max_turn_requests";
|
|
436
|
+
return {
|
|
437
|
+
text: state.text.join("") || null,
|
|
438
|
+
thinking: state.thinking.join(""),
|
|
439
|
+
events,
|
|
440
|
+
usage: usage ? {
|
|
441
|
+
total_tokens: usage.totalTokens || null,
|
|
442
|
+
context_window: usage.contextWindow || null,
|
|
443
|
+
cost: usage.cost,
|
|
444
|
+
} : {},
|
|
445
|
+
durationMs: Date.now() - start,
|
|
446
|
+
numTurns: 1,
|
|
447
|
+
model: reference,
|
|
448
|
+
effort: req.effort || null,
|
|
449
|
+
sdk: "acp",
|
|
450
|
+
cancelled: promptResponse.stopReason === "cancelled",
|
|
451
|
+
error: limited ? `ACP agent stopped with ${promptResponse.stopReason}.` : null,
|
|
452
|
+
failureKind: limited ? "usage_limit" : null,
|
|
453
|
+
providerSessionId,
|
|
454
|
+
runtimeWarnings: [],
|
|
455
|
+
diagnostics: {
|
|
456
|
+
acp_protocol_version: connection.initializeResult.protocolVersion,
|
|
457
|
+
acp_profile_id: profileId,
|
|
458
|
+
acp_session_id_encoded: true,
|
|
459
|
+
acp_stop_reason: promptResponse.stopReason,
|
|
460
|
+
acp_resume_method: setup.resumeMethod,
|
|
461
|
+
acp_mode_applied: configuration.modeApplied,
|
|
462
|
+
acp_config_options_applied: configuration.configOptionsApplied,
|
|
463
|
+
},
|
|
464
|
+
capabilitiesUsed: {
|
|
465
|
+
session_resume: setup.resumed,
|
|
466
|
+
session_load: setup.resumeMethod === "load",
|
|
467
|
+
session_config: configuration.modeApplied || configuration.configOptionsApplied.length > 0,
|
|
468
|
+
mcp: connection.descriptor.mcpOwner === "client"
|
|
469
|
+
&& (connection.descriptor.sessionConfig?.mcpServers || []).length > 0,
|
|
470
|
+
},
|
|
471
|
+
structuredResult: undefined,
|
|
472
|
+
structuredResultSource: null,
|
|
473
|
+
};
|
|
474
|
+
} catch (error) {
|
|
475
|
+
const failure = failureFor(error, req?.abortSignal?.aborted === true);
|
|
476
|
+
capture({
|
|
477
|
+
type: "provider_request_completed",
|
|
478
|
+
sdk: "acp",
|
|
479
|
+
model: reference,
|
|
480
|
+
runtime: "acp-stdio",
|
|
481
|
+
timestamp: Date.now(),
|
|
482
|
+
durationMs: Date.now() - start,
|
|
483
|
+
cancelled: failure.cancelled,
|
|
484
|
+
});
|
|
485
|
+
return {
|
|
486
|
+
text: state.text.join("") || null,
|
|
487
|
+
thinking: state.thinking.join(""),
|
|
488
|
+
events,
|
|
489
|
+
usage: {},
|
|
490
|
+
durationMs: Date.now() - start,
|
|
491
|
+
numTurns: 0,
|
|
492
|
+
model: reference,
|
|
493
|
+
effort: req?.effort || null,
|
|
494
|
+
sdk: "acp",
|
|
495
|
+
cancelled: failure.cancelled,
|
|
496
|
+
error: failure.message,
|
|
497
|
+
errorDetails: failure.message ? {
|
|
498
|
+
acp_error_code: typeof error?.code === "string" ? error.code : "protocol",
|
|
499
|
+
} : null,
|
|
500
|
+
failureKind: failure.failureKind,
|
|
501
|
+
providerSessionId,
|
|
502
|
+
runtimeWarnings: [],
|
|
503
|
+
diagnostics: {
|
|
504
|
+
acp_profile_id: typeof profileId === "string" ? profileId : null,
|
|
505
|
+
acp_session_id_encoded: providerSessionId != null,
|
|
506
|
+
acp_stop_reason: failure.cancelled ? "cancelled" : "error",
|
|
507
|
+
},
|
|
508
|
+
capabilitiesUsed: {},
|
|
509
|
+
structuredResult: undefined,
|
|
510
|
+
structuredResultSource: null,
|
|
511
|
+
};
|
|
512
|
+
} finally {
|
|
513
|
+
await connection?.close().catch(() => {});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export const acpRuntimeBridge = {
|
|
518
|
+
id: "acp-stdio",
|
|
519
|
+
kind: "acp",
|
|
520
|
+
capabilities: runtimeCapabilities("acp"),
|
|
521
|
+
supports: (ref, options) => ref?.sdk === "acp" && options?.executionMode === "acp",
|
|
522
|
+
execute: generateAcpResponse,
|
|
523
|
+
};
|
|
@@ -57,6 +57,22 @@ export const RUNTIME_CAPABILITIES = {
|
|
|
57
57
|
supports_native_subagents: false,
|
|
58
58
|
tool_policy: TOOL_POLICY_ALLOW_ALL_ONLY,
|
|
59
59
|
},
|
|
60
|
+
acp: {
|
|
61
|
+
runtime: "acp-stdio",
|
|
62
|
+
...COMMON_CAPABILITIES,
|
|
63
|
+
structured_output: false,
|
|
64
|
+
supports_session_resume: true,
|
|
65
|
+
// Runtime request-scoped MCP servers are not projected into ACP sessions.
|
|
66
|
+
// Profiles may own static ACP MCP configuration, but that is not the
|
|
67
|
+
// supports_mcp contract advertised to the route capability gate.
|
|
68
|
+
supports_mcp: false,
|
|
69
|
+
supports_skills: false,
|
|
70
|
+
supports_builtin_tools: false,
|
|
71
|
+
supports_live_input: false,
|
|
72
|
+
supports_native_subagents: false,
|
|
73
|
+
supports_request_tool_environment: false,
|
|
74
|
+
tool_policy: TOOL_POLICY_ALLOW_ALL_ONLY,
|
|
75
|
+
},
|
|
60
76
|
};
|
|
61
77
|
|
|
62
78
|
export function runtimeCapabilities(sdkOrModel) {
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/** @typedef {import('../types.js').RuntimeModelRef} RuntimeModelRef */
|
|
4
4
|
|
|
5
5
|
const RESERVED_RUNTIME_IDS = new Set(["openai", "vercel", "claude-code", "codex-cli"]);
|
|
6
|
-
const ACTIVE_RUNTIME_IDS = new Set(["claude", "pi", "codex", "opencode"]);
|
|
6
|
+
const ACTIVE_RUNTIME_IDS = new Set(["claude", "pi", "codex", "opencode", "acp"]);
|
|
7
|
+
const ACP_PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
7
8
|
|
|
8
9
|
function requirePart(value, message) {
|
|
9
10
|
if (!value || typeof value !== "string" || value.trim() !== value) {
|
|
@@ -74,6 +75,14 @@ export function sdkFromModelReference(value) {
|
|
|
74
75
|
export function parseRuntimeModelReference(value) {
|
|
75
76
|
if (!value || typeof value !== "string") throw new Error("model reference required");
|
|
76
77
|
|
|
78
|
+
if (value.startsWith("acp:")) {
|
|
79
|
+
const profileId = requirePart(value.slice("acp:".length), "ACP profile id required");
|
|
80
|
+
if (!ACP_PROFILE_ID_RE.test(profileId)) {
|
|
81
|
+
throw new Error("invalid acp model reference; expected acp:<profile-id>");
|
|
82
|
+
}
|
|
83
|
+
return { sdk: "acp", model: profileId, reference: value };
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
if (value.startsWith("pi:")) {
|
|
78
87
|
const rest = value.slice("pi:".length);
|
|
79
88
|
const i = rest.indexOf(":");
|
|
@@ -120,6 +129,7 @@ export const ACTIVE_RUNTIME_KINDS = [...ACTIVE_RUNTIME_IDS];
|
|
|
120
129
|
export const RESERVED_RUNTIME_KINDS = [...RESERVED_RUNTIME_IDS];
|
|
121
130
|
|
|
122
131
|
// intelligence-ramp: which model refs can run under which execution_mode.
|
|
132
|
+
// sdk='acp' → ACP only (dedicated stdio client mode)
|
|
123
133
|
// sdk='claude' → CLI (claude binary) or SDK (Anthropic)
|
|
124
134
|
// sdk='codex' → CLI only (codex app-server)
|
|
125
135
|
// sdk='opencode' → CLI only (opencode server via @opencode-ai/sdk)
|
|
@@ -143,6 +153,11 @@ export function executionModeIncompatibilityReason(modelRefOrParsed, executionMo
|
|
|
143
153
|
}
|
|
144
154
|
if (!parsed) return null;
|
|
145
155
|
if (!executionMode) return null;
|
|
156
|
+
if (executionMode === "acp") {
|
|
157
|
+
return parsed.sdk === "acp"
|
|
158
|
+
? null
|
|
159
|
+
: `sdk \`${parsed.sdk}\` is not supported under ACP execution mode.`;
|
|
160
|
+
}
|
|
146
161
|
if (executionMode === "sdk") {
|
|
147
162
|
if (parsed.sdk === "codex") {
|
|
148
163
|
return "Codex CLI requires CLI execution mode.";
|
|
@@ -150,12 +165,16 @@ export function executionModeIncompatibilityReason(modelRefOrParsed, executionMo
|
|
|
150
165
|
if (parsed.sdk === "opencode") {
|
|
151
166
|
return "OpenCode CLI requires CLI execution mode.";
|
|
152
167
|
}
|
|
168
|
+
if (parsed.sdk === "acp") {
|
|
169
|
+
return "ACP profiles require ACP execution mode.";
|
|
170
|
+
}
|
|
153
171
|
return null;
|
|
154
172
|
}
|
|
155
173
|
if (executionMode !== "cli") return null;
|
|
156
174
|
if (parsed.sdk === "claude") return null;
|
|
157
175
|
if (parsed.sdk === "codex") return null;
|
|
158
176
|
if (parsed.sdk === "opencode") return null;
|
|
177
|
+
if (parsed.sdk === "acp") return "ACP profiles require ACP execution mode.";
|
|
159
178
|
if (parsed.sdk === "pi") {
|
|
160
179
|
const provider = parsed.provider || "unknown";
|
|
161
180
|
const suffix = provider === "openai-codex" ? "; use codex:<model> for Codex CLI" : "";
|
|
@@ -25,6 +25,12 @@ import { COMMON_CAPABILITIES, runtimeCapabilities } from "./capabilities.js";
|
|
|
25
25
|
// pre-Phase-2 behaviour for any agent that hasn't opted in.
|
|
26
26
|
/** @type {Object<string, BridgeSpec>} */
|
|
27
27
|
const builtinBridgeSpecs = {
|
|
28
|
+
"acp-stdio": {
|
|
29
|
+
id: "acp-stdio",
|
|
30
|
+
supports: (ref, options) => ref?.sdk === "acp" && options?.executionMode === "acp",
|
|
31
|
+
capabilities: () => runtimeCapabilities("acp"),
|
|
32
|
+
load: async () => (await import("../providers/acp.js")).acpRuntimeBridge,
|
|
33
|
+
},
|
|
28
34
|
"claude-code": {
|
|
29
35
|
id: "claude-code",
|
|
30
36
|
supports: (ref, options) => ref?.sdk === "claude" && options?.executionMode === "cli",
|
package/src/ai/runtime/router.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// so the router is a drop-in replacement for createRuntime(host).
|
|
21
21
|
//
|
|
22
22
|
// chain entries:
|
|
23
|
-
// { model: ModelRef, executionMode?: "sdk" | "cli", effort?: string|null,
|
|
23
|
+
// { model: ModelRef, executionMode?: "sdk" | "cli" | "acp", effort?: string|null,
|
|
24
24
|
// requires?: Capabilities }
|
|
25
25
|
// shorthand: a bare ModelRef is also accepted (no requirements).
|
|
26
26
|
// effort string = fixed for that route, undefined = inherit the legacy run
|
|
@@ -683,6 +683,8 @@ function routeSafetyContract(mode, entry, piSandboxPolicy) {
|
|
|
683
683
|
return { mode, sandbox: "codex-native", tools: "exact-allow-all" };
|
|
684
684
|
case "opencode":
|
|
685
685
|
return { mode, sandbox: "provider-native", tools: "exact-allow-all" };
|
|
686
|
+
case "acp":
|
|
687
|
+
return { mode, sandbox: "provider-native", tools: "exact-allow-all" };
|
|
686
688
|
default:
|
|
687
689
|
return { mode, sandbox: "unsupported", tools: "unsupported" };
|
|
688
690
|
}
|
|
@@ -852,6 +854,7 @@ function projectPerRouteNativeOptions(entry, options) {
|
|
|
852
854
|
return projected;
|
|
853
855
|
case "codex":
|
|
854
856
|
case "opencode":
|
|
857
|
+
case "acp":
|
|
855
858
|
delete projected.sandboxPolicy;
|
|
856
859
|
delete projected.sandboxEngine;
|
|
857
860
|
projected.allowedTools = ["*"];
|