@mono-agent/agent-runtime 0.17.0 → 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.
@@ -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",
@@ -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 = ["*"];