aws-runtime-bridge 1.7.50 → 1.8.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.
Files changed (63) hide show
  1. package/dist/adapter/AcodeSdkAdapter.d.ts +30 -3
  2. package/dist/adapter/AcodeSdkAdapter.d.ts.map +1 -1
  3. package/dist/adapter/AcodeSdkAdapter.js +427 -105
  4. package/dist/adapter/AcodeSdkAdapter.test.js +1093 -86
  5. package/dist/adapter/ClaudeSdkAdapter.test.js +31 -8
  6. package/dist/adapter/CodexSdkAdapter.d.ts.map +1 -1
  7. package/dist/adapter/CodexSdkAdapter.js +2 -0
  8. package/dist/adapter/OpencodeSdkAdapter.d.ts.map +1 -1
  9. package/dist/adapter/OpencodeSdkAdapter.js +2 -0
  10. package/dist/adapter/SdkProviderSpi.d.ts.map +1 -1
  11. package/dist/adapter/adapter.test.js +2 -2
  12. package/dist/adapter/idle-poll-loop.d.ts +4 -4
  13. package/dist/adapter/idle-poll-loop.d.ts.map +1 -1
  14. package/dist/adapter/idle-poll-loop.js +3 -3
  15. package/dist/adapter/idle-poll-loop.test.js +15 -1
  16. package/dist/adapter/types.d.ts +25 -6
  17. package/dist/adapter/types.d.ts.map +1 -1
  18. package/dist/adapter/types.js +221 -110
  19. package/dist/adapter/types.test.js +10 -3
  20. package/dist/routes/instance.d.ts.map +1 -1
  21. package/dist/routes/instance.js +6 -7
  22. package/dist/routes/runtime-binding.js +2 -2
  23. package/dist/routes/terminal.d.ts +20 -5
  24. package/dist/routes/terminal.d.ts.map +1 -1
  25. package/dist/routes/terminal.js +420 -170
  26. package/dist/routes/terminal.test.js +380 -208
  27. package/dist/services/mcp-launch-binding-queue.d.ts +1 -0
  28. package/dist/services/mcp-launch-binding-queue.d.ts.map +1 -1
  29. package/dist/services/mcp-launch-binding-queue.js +8 -2
  30. package/dist/services/mcp-launch-binding-queue.test.js +47 -4
  31. package/dist/services/session-output.d.ts +10 -6
  32. package/dist/services/session-output.d.ts.map +1 -1
  33. package/dist/services/session-output.js +29 -14
  34. package/dist/services/session-output.test.js +93 -39
  35. package/package/acode/dist/built-in-file-tools.d.ts +1 -1
  36. package/package/acode/dist/built-in-file-tools.d.ts.map +1 -1
  37. package/package/acode/dist/built-in-file-tools.js +388 -50
  38. package/package/acode/dist/runtime.d.ts +11 -3
  39. package/package/acode/dist/runtime.d.ts.map +1 -1
  40. package/package/acode/dist/runtime.js +902 -190
  41. package/package/acode/dist/types.d.ts +18 -5
  42. package/package/acode/dist/types.d.ts.map +1 -1
  43. package/package/aws-client-agent-mcp/dist/mcp-server.d.ts.map +1 -1
  44. package/package/aws-client-agent-mcp/dist/mcp-server.js +3 -2
  45. package/package/aws-client-agent-mcp/dist/mcp-server.js.map +1 -1
  46. package/package/aws-client-agent-mcp/dist/mcp-server.test.js +11 -9
  47. package/package/aws-client-agent-mcp/dist/mcp-server.test.js.map +1 -1
  48. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.d.ts.map +1 -1
  49. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.js +1 -0
  50. package/package/aws-client-agent-mcp/dist/runtime-launch-binding.js.map +1 -1
  51. package/package/aws-client-agent-mcp/dist/status-reporter.d.ts +2 -0
  52. package/package/aws-client-agent-mcp/dist/status-reporter.d.ts.map +1 -1
  53. package/package/aws-client-agent-mcp/dist/status-reporter.js +31 -3
  54. package/package/aws-client-agent-mcp/dist/status-reporter.js.map +1 -1
  55. package/package/aws-client-agent-mcp/dist/types.d.ts +1 -1
  56. package/package/aws-client-agent-mcp/dist/types.d.ts.map +1 -1
  57. package/package/aws-client-agent-mcp/dist/types.js.map +1 -1
  58. package/package/aws-client-agent-mcp/dist/websocket-client.d.ts.map +1 -1
  59. package/package/aws-client-agent-mcp/dist/websocket-client.js +2 -0
  60. package/package/aws-client-agent-mcp/dist/websocket-client.js.map +1 -1
  61. package/package/aws-client-agent-mcp/dist/websocket-client.test.js +43 -0
  62. package/package/aws-client-agent-mcp/dist/websocket-client.test.js.map +1 -1
  63. package/package.json +1 -1
@@ -1,26 +1,103 @@
1
- import { EventEmitter } from 'node:events';
2
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
4
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
5
- import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
- import { createBuiltInFileTools } from './built-in-file-tools.js';
7
- import { loadAcodeCommands } from './commands/commandLoader.js';
8
- import { ACodeCommandRegistry } from './commands/commandRegistry.js';
9
- import { loadAcodeConfig, loadAcodeSkills } from './config.js';
10
- import { ACodeHookBus, findDeniedHookResult } from './hooks/hookBus.js';
1
+ import { EventEmitter } from "node:events";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import { createBuiltInFileTools, } from "./built-in-file-tools.js";
7
+ import { loadAcodeCommands } from "./commands/commandLoader.js";
8
+ import { ACodeCommandRegistry } from "./commands/commandRegistry.js";
9
+ import { loadAcodeConfig, loadAcodeSkills } from "./config.js";
10
+ import { ACodeHookBus, findDeniedHookResult } from "./hooks/hookBus.js";
11
11
  function nowIso() {
12
12
  return new Date().toISOString();
13
13
  }
14
14
  function isRecord(value) {
15
- return Boolean(value && typeof value === 'object' && !Array.isArray(value));
15
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
16
16
  }
17
17
  function isJsonObject(value) {
18
18
  return isRecord(value);
19
19
  }
20
20
  function normalizeText(value) {
21
- const text = String(value ?? '').trim();
21
+ const text = String(value ?? "").trim();
22
22
  return text || undefined;
23
23
  }
24
+ const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
25
+ const DEFAULT_PROVIDER_REQUEST_RETRY_COUNT = 2;
26
+ const DEFAULT_PROVIDER_REQUEST_RETRY_DELAY_MS = 1000;
27
+ const DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS = 60_000;
28
+ const DEFAULT_MCP_POLLING_TOOL_CALL_TIMEOUT_MS = 30 * 60_000;
29
+ const DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS = 10_000;
30
+ const MAX_INLINE_IMAGE_BYTES = 10 * 1024 * 1024;
31
+ async function withTimeout(operation, timeoutMs, label, onTimeout) {
32
+ if (timeoutMs <= 0) {
33
+ return await operation;
34
+ }
35
+ let timeout;
36
+ try {
37
+ return await new Promise((resolve, reject) => {
38
+ timeout = setTimeout(() => {
39
+ onTimeout?.();
40
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
41
+ }, timeoutMs);
42
+ operation.then(resolve, reject);
43
+ });
44
+ }
45
+ finally {
46
+ if (timeout) {
47
+ clearTimeout(timeout);
48
+ }
49
+ }
50
+ }
51
+ async function runBestEffort(label, operation, onError, timeoutMs = DEFAULT_RUNTIME_SHUTDOWN_TIMEOUT_MS) {
52
+ try {
53
+ await withTimeout(operation, timeoutMs, label);
54
+ }
55
+ catch (error) {
56
+ onError(error instanceof Error ? error.message : String(error));
57
+ }
58
+ }
59
+ function normalizeNonNegativeInteger(value, fallback) {
60
+ if (value === 0 || value === "0") {
61
+ return 0;
62
+ }
63
+ const numeric = typeof value === "number" ? value : Number(value);
64
+ return Number.isFinite(numeric) && numeric > 0
65
+ ? Math.floor(numeric)
66
+ : fallback;
67
+ }
68
+ function normalizePositiveInteger(value, fallback) {
69
+ const numeric = normalizeNonNegativeInteger(value, fallback);
70
+ return numeric > 0 ? numeric : fallback;
71
+ }
72
+ async function delay(ms) {
73
+ await new Promise((resolve) => setTimeout(resolve, ms));
74
+ }
75
+ function isMcpPollingToolName(toolName) {
76
+ const normalized = toolName.trim().toLowerCase();
77
+ return (normalized === "poll_message" ||
78
+ normalized === "get_message" ||
79
+ normalized.includes("poll_message"));
80
+ }
81
+ function isBoolean(value) {
82
+ return typeof value === "boolean";
83
+ }
84
+ function modelSupportsImageInput(model, defaultProvider, providerOptions) {
85
+ if (isBoolean(providerOptions?.supportsImageInput)) {
86
+ return providerOptions.supportsImageInput;
87
+ }
88
+ const models = isRecord(defaultProvider) ? defaultProvider.models : undefined;
89
+ const modelConfig = isRecord(models) ? models[model] : undefined;
90
+ if (!isRecord(modelConfig)) {
91
+ return false;
92
+ }
93
+ const modalities = modelConfig.modalities;
94
+ if (!isRecord(modalities) || !Array.isArray(modalities.input)) {
95
+ return false;
96
+ }
97
+ return modalities.input.some((item) => String(item || "")
98
+ .trim()
99
+ .toLowerCase() === "image");
100
+ }
24
101
  /**
25
102
  * 解析 ACode 当前会话模型:优先显式启动参数,其次兼容 cc-switch 写入的
26
103
  * OpenCode-style `provider.default.model`,最后回落到旧版 top-level model。
@@ -28,41 +105,263 @@ function normalizeText(value) {
28
105
  function resolveProviderConfig(config, acodeConfig) {
29
106
  const defaultProvider = acodeConfig.provider?.default;
30
107
  const providerOptions = defaultProvider?.options;
108
+ const model = config.model ||
109
+ defaultProvider?.model ||
110
+ acodeConfig.model ||
111
+ "acode-default";
31
112
  return {
32
- model: config.model || defaultProvider?.model || acodeConfig.model || 'acode-default',
113
+ model,
33
114
  baseURL: normalizeText(providerOptions?.baseURL || providerOptions?.baseUrl),
34
115
  apiKey: normalizeText(providerOptions?.apiKey),
35
116
  systemPrompt: config.systemPrompt || acodeConfig.systemPrompt,
117
+ stream: providerOptions?.stream !== false,
118
+ requestTimeoutMs: normalizeNonNegativeInteger(providerOptions?.requestTimeoutMs ?? providerOptions?.timeoutMs, DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS),
119
+ requestRetryCount: normalizeNonNegativeInteger(providerOptions?.requestRetryCount, DEFAULT_PROVIDER_REQUEST_RETRY_COUNT),
120
+ mcpToolTimeoutMs: normalizePositiveInteger(providerOptions?.mcpToolTimeoutMs, DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS),
121
+ mcpPollingToolTimeoutMs: normalizePositiveInteger(providerOptions?.mcpPollingToolTimeoutMs, DEFAULT_MCP_POLLING_TOOL_CALL_TIMEOUT_MS),
122
+ supportsImageInput: modelSupportsImageInput(model, defaultProvider, providerOptions),
36
123
  };
37
124
  }
125
+ function appendTextPart(parts, text) {
126
+ const normalized = text.replace(/\s+/g, " ").trim();
127
+ if (normalized) {
128
+ parts.push({ type: "text", text: normalized });
129
+ }
130
+ }
131
+ function toDataImageUrl(mediaType, rawBase64) {
132
+ const trimmed = rawBase64.trim();
133
+ if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) {
134
+ return trimmed;
135
+ }
136
+ return `data:${mediaType || "image/png"};base64,${trimmed}`;
137
+ }
138
+ function isDataImageUrl(url) {
139
+ return /^data:image\/[a-z0-9.+-]+;base64,/i.test(url.trim());
140
+ }
141
+ function assertFetchableImageUrl(url) {
142
+ let parsedUrl;
143
+ try {
144
+ parsedUrl = new URL(url);
145
+ }
146
+ catch {
147
+ throw new Error(`ACode image URL is invalid: ${url}`);
148
+ }
149
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
150
+ throw new Error(`ACode image URL protocol is not supported: ${parsedUrl.protocol}`);
151
+ }
152
+ }
153
+ async function responseToBase64(response) {
154
+ const arrayBuffer = await response.arrayBuffer();
155
+ if (arrayBuffer.byteLength > MAX_INLINE_IMAGE_BYTES) {
156
+ throw new Error(`ACode image is too large to inline: ${arrayBuffer.byteLength} bytes exceeds ${MAX_INLINE_IMAGE_BYTES} bytes`);
157
+ }
158
+ return Buffer.from(arrayBuffer).toString("base64");
159
+ }
160
+ async function materializeImageUrl(url) {
161
+ const trimmedUrl = url.trim();
162
+ if (isDataImageUrl(trimmedUrl)) {
163
+ return trimmedUrl;
164
+ }
165
+ assertFetchableImageUrl(trimmedUrl);
166
+ const response = await fetch(trimmedUrl);
167
+ if (!response.ok) {
168
+ throw new Error(`ACode image fetch failed: ${response.status} ${await response.text()}`);
169
+ }
170
+ const contentType = response.headers.get("content-type") || "";
171
+ const mediaType = contentType.split(";")[0]?.trim().toLowerCase() || "";
172
+ if (!mediaType.startsWith("image/")) {
173
+ throw new Error(`ACode image URL did not return an image content type: ${contentType || "unknown"}`);
174
+ }
175
+ return `data:${mediaType};base64,${await responseToBase64(response)}`;
176
+ }
177
+ function normalizeImageAttachmentUrl(attachment) {
178
+ const base64 = normalizeText(attachment.base64Data ?? attachment.base64 ?? attachment.data);
179
+ if (base64) {
180
+ return toDataImageUrl(normalizeText(attachment.mediaType ?? attachment.mimeType) || "image/png", base64);
181
+ }
182
+ return normalizeText(attachment.url ?? attachment.serverUrl);
183
+ }
184
+ function fileAttachmentText(name, url, mediaType, size) {
185
+ const details = [
186
+ name ? `name=${name}` : undefined,
187
+ url ? `url=${url}` : undefined,
188
+ mediaType ? `mediaType=${mediaType}` : undefined,
189
+ size ? `size=${size}` : undefined,
190
+ ].filter((item) => Boolean(item));
191
+ return `[Attached file: ${details.join(", ")}]`;
192
+ }
193
+ function parseMarkerAttachmentMessage(message) {
194
+ const markerRegex = /\[(图片|文件)\s*:\s*([^\]\n]*?)\s*\]/gi;
195
+ const parts = [];
196
+ let hasImages = false;
197
+ let lastIndex = 0;
198
+ let match = markerRegex.exec(message);
199
+ while (match !== null) {
200
+ appendTextPart(parts, message.slice(lastIndex, match.index));
201
+ const markerType = match[1];
202
+ const markerValue = (match[2] || "").trim();
203
+ if (markerType === "图片" && markerValue) {
204
+ parts.push({ type: "image_url", image_url: { url: markerValue } });
205
+ hasImages = true;
206
+ }
207
+ else if (markerType === "文件" && markerValue) {
208
+ const [name = "", url = "", mediaType = "", size = ""] = markerValue
209
+ .split("|")
210
+ .map((item) => item.trim());
211
+ appendTextPart(parts, fileAttachmentText(name, url, mediaType, size));
212
+ }
213
+ else {
214
+ appendTextPart(parts, match[0]);
215
+ }
216
+ lastIndex = match.index + match[0].length;
217
+ match = markerRegex.exec(message);
218
+ }
219
+ if (parts.length === 0) {
220
+ return { content: message, hasImages: false };
221
+ }
222
+ appendTextPart(parts, message.slice(lastIndex));
223
+ return { content: parts, hasImages };
224
+ }
225
+ function appendPolledMessagesContent(parts, parsed) {
226
+ let hasImages = false;
227
+ const messageGroups = [parsed.directMessages, parsed.groupMessages];
228
+ for (const group of messageGroups) {
229
+ if (!Array.isArray(group)) {
230
+ continue;
231
+ }
232
+ for (const item of group) {
233
+ if (!isRecord(item)) {
234
+ continue;
235
+ }
236
+ const content = normalizeText(item.content);
237
+ if (!content) {
238
+ continue;
239
+ }
240
+ const senderName = normalizeText(item.senderName);
241
+ appendTextPart(parts, senderName ? `Message from ${senderName}:` : "Message:");
242
+ const parsedContent = parseAttachmentMessage(content);
243
+ if (Array.isArray(parsedContent.content)) {
244
+ parts.push(...parsedContent.content);
245
+ }
246
+ else {
247
+ appendTextPart(parts, parsedContent.content);
248
+ }
249
+ hasImages = hasImages || parsedContent.hasImages;
250
+ }
251
+ }
252
+ return hasImages;
253
+ }
254
+ function parseJsonAttachmentMessage(message) {
255
+ let parsed;
256
+ try {
257
+ parsed = JSON.parse(message);
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ if (!isRecord(parsed)) {
263
+ return undefined;
264
+ }
265
+ const parts = [];
266
+ let hasImages = appendPolledMessagesContent(parts, parsed);
267
+ const text = normalizeText(parsed.text) || "";
268
+ const rawAttachments = Array.isArray(parsed.attachments)
269
+ ? parsed.attachments
270
+ : [];
271
+ appendTextPart(parts, text);
272
+ for (const item of rawAttachments) {
273
+ if (!isRecord(item)) {
274
+ continue;
275
+ }
276
+ const attachment = item;
277
+ const kind = String(attachment.type ?? attachment.kind ?? "").toLowerCase();
278
+ if (kind === "image") {
279
+ const imageUrl = normalizeImageAttachmentUrl(attachment);
280
+ if (imageUrl) {
281
+ parts.push({ type: "image_url", image_url: { url: imageUrl } });
282
+ hasImages = true;
283
+ }
284
+ continue;
285
+ }
286
+ const fileUrl = normalizeText(attachment.url ?? attachment.serverUrl) || "";
287
+ appendTextPart(parts, fileAttachmentText(normalizeText(attachment.name) || "", fileUrl, normalizeText(attachment.mediaType ?? attachment.mimeType) || "", normalizeText(attachment.size) || ""));
288
+ }
289
+ if (parts.length === 0) {
290
+ return { content: message, hasImages: false };
291
+ }
292
+ return { content: parts, hasImages };
293
+ }
294
+ function parseAttachmentMessage(message) {
295
+ return (parseJsonAttachmentMessage(message) ?? parseMarkerAttachmentMessage(message));
296
+ }
297
+ async function materializeImageContent(content) {
298
+ if (!Array.isArray(content)) {
299
+ return content;
300
+ }
301
+ const materializedParts = [];
302
+ for (const part of content) {
303
+ if (part.type === "text") {
304
+ materializedParts.push(part);
305
+ continue;
306
+ }
307
+ materializedParts.push({
308
+ type: "image_url",
309
+ image_url: { url: await materializeImageUrl(part.image_url.url) },
310
+ });
311
+ }
312
+ return materializedParts;
313
+ }
314
+ function contentPreview(content) {
315
+ if (typeof content === "string") {
316
+ return content;
317
+ }
318
+ if (!Array.isArray(content)) {
319
+ return "";
320
+ }
321
+ return content
322
+ .map((part) => (part.type === "text" ? part.text : "[Image]"))
323
+ .join(" ")
324
+ .trim();
325
+ }
38
326
  /**
39
327
  * 解析 ACode 工具权限配置。
40
328
  * 主流程:运行时配置优先 -> `.acode/config.json` 次之 -> 兼容旧式 allowedTools/deniedTools 字段。
41
329
  */
42
330
  function resolvePermissionConfig(config, acodeConfig) {
43
331
  const configured = config.permissions ?? acodeConfig.permissions ?? {};
44
- const legacyAllow = config.permissions ? [] : acodeConfig.allowedTools ?? [];
45
- const legacyDeny = config.permissions ? [] : acodeConfig.deniedTools ?? [];
332
+ const legacyAllow = config.permissions
333
+ ? []
334
+ : (acodeConfig.allowedTools ?? []);
335
+ const legacyDeny = config.permissions ? [] : (acodeConfig.deniedTools ?? []);
46
336
  return {
47
- mode: configured.mode ?? 'default',
48
- allow: [...(configured.allow ?? []), ...legacyAllow].map((rule) => rule.trim()).filter(Boolean),
49
- deny: [...(configured.deny ?? []), ...legacyDeny].map((rule) => rule.trim()).filter(Boolean),
337
+ mode: configured.mode ?? "default",
338
+ allow: [...(configured.allow ?? []), ...legacyAllow]
339
+ .map((rule) => rule.trim())
340
+ .filter(Boolean),
341
+ deny: [...(configured.deny ?? []), ...legacyDeny]
342
+ .map((rule) => rule.trim())
343
+ .filter(Boolean),
50
344
  };
51
345
  }
52
346
  function wildcardMatches(pattern, value) {
53
- if (pattern === '*') {
347
+ if (pattern === "*") {
54
348
  return true;
55
349
  }
56
- if (pattern.endsWith('*')) {
350
+ if (pattern.endsWith("*")) {
57
351
  return value.startsWith(pattern.slice(0, -1));
58
352
  }
59
353
  return pattern === value;
60
354
  }
61
355
  function toolPermissionNames(tool) {
62
- if (tool.kind === 'builtin') {
356
+ if (tool.kind === "builtin") {
63
357
  return [tool.exposedName, tool.toolName, `builtin.${tool.toolName}`];
64
358
  }
65
- return [tool.exposedName, tool.toolName, `${tool.serverName}.${tool.toolName}`, `mcp.${tool.serverName}.${tool.toolName}`];
359
+ return [
360
+ tool.exposedName,
361
+ tool.toolName,
362
+ `${tool.serverName}.${tool.toolName}`,
363
+ `mcp.${tool.serverName}.${tool.toolName}`,
364
+ ];
66
365
  }
67
366
  function matchesToolRule(rule, tool) {
68
367
  return toolPermissionNames(tool).some((name) => wildcardMatches(rule, name));
@@ -73,7 +372,7 @@ function assertToolAllowedByRules(tool, rules, message) {
73
372
  }
74
373
  }
75
374
  function assertToolPermission(tool, permissions, commandAllowedTools) {
76
- if (permissions.mode === 'bypassPermissions') {
375
+ if (permissions.mode === "bypassPermissions") {
77
376
  return;
78
377
  }
79
378
  if (permissions.deny.some((rule) => matchesToolRule(rule, tool))) {
@@ -92,12 +391,33 @@ function isToolAllowed(tool, permissions, commandAllowedTools) {
92
391
  }
93
392
  }
94
393
  function normalizeChatCompletionsUrl(baseURL) {
95
- const trimmed = baseURL.replace(/\/+$/g, '');
394
+ const trimmed = baseURL.replace(/\/+$/g, "");
96
395
  if (/\/chat\/completions$/i.test(trimmed)) {
97
396
  return trimmed;
98
397
  }
99
398
  return `${trimmed}/chat/completions`;
100
399
  }
400
+ function summarizeProviderUrl(baseURL) {
401
+ try {
402
+ const url = new URL(baseURL);
403
+ url.search = "";
404
+ url.hash = "";
405
+ const normalizedUrl = new URL(normalizeChatCompletionsUrl(url.toString()));
406
+ return `${normalizedUrl.origin}${normalizedUrl.pathname}`;
407
+ }
408
+ catch {
409
+ return normalizeChatCompletionsUrl(baseURL).replace(/([?&](?:api[_-]?key|token|authorization)=)[^&]+/gi, "$1[redacted]");
410
+ }
411
+ }
412
+ function summarizeLastUserMessage(messages) {
413
+ const lastUserMessage = [...messages]
414
+ .reverse()
415
+ .find((message) => message.role === "user");
416
+ const content = contentPreview(lastUserMessage?.content ?? null).trim();
417
+ if (!content)
418
+ return undefined;
419
+ return content.length > 300 ? `${content.slice(0, 300)}...` : content;
420
+ }
101
421
  function coerceArguments(rawArguments) {
102
422
  try {
103
423
  const parsed = JSON.parse(rawArguments);
@@ -107,56 +427,132 @@ function coerceArguments(rawArguments) {
107
427
  return {};
108
428
  }
109
429
  }
430
+ function getBuiltInToolTimeoutMs(tool, args, fallbackTimeoutMs) {
431
+ if (tool.toolName !== "Bash") {
432
+ return fallbackTimeoutMs;
433
+ }
434
+ const rawTimeout = args.timeout_ms ?? args.timeoutMs;
435
+ const numericTimeout = typeof rawTimeout === "number"
436
+ ? rawTimeout
437
+ : typeof rawTimeout === "string"
438
+ ? Number(rawTimeout)
439
+ : NaN;
440
+ if (!Number.isFinite(numericTimeout) || numericTimeout <= 0) {
441
+ return fallbackTimeoutMs;
442
+ }
443
+ return Math.min(Math.floor(numericTimeout), 10 * 60_000) + 2000;
444
+ }
110
445
  function createFallbackResponse(message, state) {
111
446
  const trimmed = message.trim();
112
- return `ACode 已接收任务。当前已加载 ${state.configMcpCount} 个 MCP 配置、${state.exposedTools.length} 个 MCP Tool 与 ${state.skills.length} 个 Skill。${trimmed ? `输入摘要:${trimmed.slice(0, 200)}` : ''}`;
447
+ return `ACode 已接收任务。当前已加载 ${state.configMcpCount} 个 MCP 配置、${state.exposedTools.length} 个 MCP Tool 与 ${state.skills.length} 个 Skill。${trimmed ? `输入摘要:${trimmed.slice(0, 200)}` : ""}`;
113
448
  }
114
449
  function toToolResultText(result) {
115
450
  if (!isRecord(result)) {
116
- return String(result ?? '');
451
+ return String(result ?? "");
117
452
  }
118
453
  const content = result.content;
119
454
  if (Array.isArray(content)) {
120
455
  const parts = content
121
456
  .map((item) => {
122
457
  if (!isRecord(item)) {
123
- return '';
458
+ return "";
124
459
  }
125
- if (typeof item.text === 'string') {
460
+ if (typeof item.text === "string") {
126
461
  return item.text;
127
462
  }
128
463
  return JSON.stringify(item);
129
464
  })
130
465
  .filter(Boolean);
131
466
  if (parts.length > 0) {
132
- return parts.join('\n');
467
+ return parts.join("\n");
133
468
  }
134
469
  }
135
470
  return JSON.stringify(result);
136
471
  }
472
+ function getErrorMessage(error) {
473
+ return error instanceof Error ? error.message : String(error);
474
+ }
475
+ function isRetryableProviderRequestError(error) {
476
+ const message = getErrorMessage(error).toLowerCase();
477
+ return (message.includes("fetch failed") ||
478
+ message.includes("timed out") ||
479
+ message.includes("econnreset") ||
480
+ message.includes("econnrefused") ||
481
+ message.includes("etimedout") ||
482
+ message.includes("enotfound") ||
483
+ message.includes("502") ||
484
+ message.includes("503") ||
485
+ message.includes("504"));
486
+ }
487
+ function getErrorType(error) {
488
+ if (typeof error === "object" && error !== null) {
489
+ const code = Reflect.get(error, "code");
490
+ if (typeof code === "string" && code) {
491
+ return code;
492
+ }
493
+ const name = Reflect.get(error, "name");
494
+ if (typeof name === "string" && name) {
495
+ return name;
496
+ }
497
+ }
498
+ return "tool_error";
499
+ }
500
+ function toToolErrorResultText(tool, error) {
501
+ return JSON.stringify({
502
+ ok: false,
503
+ tool: tool.exposedName,
504
+ error: {
505
+ type: getErrorType(error),
506
+ message: getErrorMessage(error),
507
+ },
508
+ });
509
+ }
510
+ function summarizeToolArguments(toolArguments) {
511
+ const serialized = JSON.stringify(toolArguments);
512
+ return serialized.length > 500 ? `${serialized.slice(0, 500)}…` : serialized;
513
+ }
514
+ function extractReasoningText(delta) {
515
+ const candidates = [
516
+ delta?.reasoning_content,
517
+ delta?.reasoning_text,
518
+ delta?.reasoning,
519
+ ];
520
+ for (const candidate of candidates) {
521
+ if (typeof candidate === "string" && candidate) {
522
+ return candidate;
523
+ }
524
+ if (isRecord(candidate)) {
525
+ const text = candidate.text ?? candidate.content;
526
+ if (typeof text === "string" && text) {
527
+ return text;
528
+ }
529
+ }
530
+ }
531
+ return "";
532
+ }
137
533
  function buildSkillPrompt(skills) {
138
534
  if (skills.length === 0) {
139
- return '';
535
+ return "";
140
536
  }
141
537
  return [
142
- 'Available skills:',
143
- ...skills.map((skill) => `- ${skill.name}${skill.description ? `: ${skill.description}` : ''}`),
144
- 'Use these skills as guidance when they match the user task.',
145
- ].join('\n');
538
+ "Available skills:",
539
+ ...skills.map((skill) => `- ${skill.name}${skill.description ? `: ${skill.description}` : ""}`),
540
+ "Use these skills as guidance when they match the user task.",
541
+ ].join("\n");
146
542
  }
147
543
  function buildCommandPrompt(commands) {
148
544
  if (commands.length === 0) {
149
- return '';
545
+ return "";
150
546
  }
151
547
  return [
152
- 'Available slash commands:',
153
- ...commands.map((command) => `- /${command.name}${command.description ? `: ${command.description}` : ''}`),
154
- 'When the user invokes a slash command, ACode expands it before model execution.',
155
- ].join('\n');
548
+ "Available slash commands:",
549
+ ...commands.map((command) => `- /${command.name}${command.description ? `: ${command.description}` : ""}`),
550
+ "When the user invokes a slash command, ACode expands it before model execution.",
551
+ ].join("\n");
156
552
  }
157
553
  function toOpenAiTools(tools) {
158
554
  return tools.map((tool) => ({
159
- type: 'function',
555
+ type: "function",
160
556
  function: {
161
557
  name: tool.exposedName,
162
558
  description: tool.description || tool.toolName,
@@ -165,16 +561,49 @@ function toOpenAiTools(tools) {
165
561
  }));
166
562
  }
167
563
  function normalizeToolName(serverName, toolName) {
168
- const normalized = `${serverName}__${toolName}`.replace(/[^a-zA-Z0-9_-]/g, '_');
169
- return normalized.slice(0, 64) || 'mcp_tool';
564
+ const normalized = `${serverName}__${toolName}`.replace(/[^a-zA-Z0-9_-]/g, "_");
565
+ return normalized.slice(0, 64) || "mcp_tool";
170
566
  }
171
567
  function buildRequestHeaders(provider) {
172
- const headers = { 'Content-Type': 'application/json' };
568
+ const headers = {
569
+ "Content-Type": "application/json",
570
+ };
173
571
  if (provider.apiKey) {
174
572
  headers.Authorization = `Bearer ${provider.apiKey}`;
175
573
  }
176
574
  return headers;
177
575
  }
576
+ function parseSseDataLines(chunk) {
577
+ return chunk
578
+ .split(/\r?\n/)
579
+ .filter((line) => line.startsWith("data:"))
580
+ .map((line) => line.slice("data:".length).trim())
581
+ .filter(Boolean);
582
+ }
583
+ function updateToolCallAccumulator(accumulators, delta) {
584
+ const index = Number.isFinite(delta.index) ? Number(delta.index) : 0;
585
+ const current = accumulators.get(index) ?? {
586
+ id: delta.id || `call-${index}`,
587
+ type: "function",
588
+ function: { name: "", arguments: "" },
589
+ };
590
+ if (delta.id) {
591
+ current.id = delta.id;
592
+ }
593
+ if (delta.function?.name) {
594
+ current.function.name += delta.function.name;
595
+ }
596
+ if (delta.function?.arguments) {
597
+ current.function.arguments += delta.function.arguments;
598
+ }
599
+ accumulators.set(index, current);
600
+ }
601
+ function toAccumulatedToolCalls(accumulators) {
602
+ return [...accumulators.entries()]
603
+ .sort(([left], [right]) => left - right)
604
+ .map(([, value]) => value)
605
+ .filter((toolCall) => toolCall.function.name.trim().length > 0);
606
+ }
178
607
  export class ACodeRuntime extends EventEmitter {
179
608
  constructor(config) {
180
609
  super();
@@ -184,8 +613,16 @@ export class ACodeRuntime extends EventEmitter {
184
613
  terminated: false,
185
614
  configMcpCount: 0,
186
615
  skills: [],
187
- provider: { model: 'acode-default' },
188
- permissions: { mode: 'default', allow: [], deny: [] },
616
+ provider: {
617
+ model: "acode-default",
618
+ stream: false,
619
+ requestTimeoutMs: DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
620
+ requestRetryCount: DEFAULT_PROVIDER_REQUEST_RETRY_COUNT,
621
+ mcpToolTimeoutMs: DEFAULT_MCP_TOOL_CALL_TIMEOUT_MS,
622
+ mcpPollingToolTimeoutMs: DEFAULT_MCP_POLLING_TOOL_CALL_TIMEOUT_MS,
623
+ supportsImageInput: false,
624
+ },
625
+ permissions: { mode: "default", allow: [], deny: [] },
189
626
  mcpConnections: [],
190
627
  exposedTools: [],
191
628
  commands: [],
@@ -193,10 +630,14 @@ export class ACodeRuntime extends EventEmitter {
193
630
  conversation: [],
194
631
  };
195
632
  this.abortController = null;
633
+ this.submitQueue = Promise.resolve();
634
+ this.activeTurn = false;
635
+ this.abortRequested = false;
636
+ this.sessionCompleteEmitted = false;
196
637
  this.hookBus = new ACodeHookBus({
197
638
  sessionId: config.sessionId,
198
639
  onError: (event, error) => {
199
- this.emitAcodeEvent('error', {
640
+ this.emitAcodeEvent("error", {
200
641
  message: `ACode hook failed: ${event.name}: ${error instanceof Error ? error.message : String(error)}`,
201
642
  });
202
643
  },
@@ -212,7 +653,7 @@ export class ACodeRuntime extends EventEmitter {
212
653
  timestamp: nowIso(),
213
654
  data,
214
655
  };
215
- this.emit('event', event);
656
+ this.emit("event", event);
216
657
  }
217
658
  /**
218
659
  * 启动 ACode headless 会话。
@@ -224,9 +665,12 @@ export class ACodeRuntime extends EventEmitter {
224
665
  const skills = await loadAcodeSkills(this.config.skillsDir);
225
666
  const provider = resolveProviderConfig(this.config, acodeConfig);
226
667
  const permissions = resolvePermissionConfig(this.config, acodeConfig);
227
- const mcpConnections = await this.connectMcpServers(acodeConfig.mcpServers ?? {});
668
+ const mcpConnections = await this.connectMcpServers(acodeConfig.mcpServers ?? {}, provider.mcpToolTimeoutMs);
228
669
  const builtInTools = createBuiltInFileTools(this.config.workingDirectory);
229
- const exposedTools = [...builtInTools, ...mcpConnections.flatMap((connection) => connection.tools)];
670
+ const exposedTools = [
671
+ ...builtInTools,
672
+ ...mcpConnections.flatMap((connection) => connection.tools),
673
+ ];
230
674
  const commandDirs = this.resolveCommandDirs(acodeConfig);
231
675
  const commands = await loadAcodeCommands(commandDirs);
232
676
  const commandRegistry = new ACodeCommandRegistry(commands);
@@ -235,7 +679,7 @@ export class ACodeRuntime extends EventEmitter {
235
679
  const systemPrompt = [provider.systemPrompt, skillPrompt, commandPrompt]
236
680
  .map((item) => item?.trim())
237
681
  .filter((item) => Boolean(item))
238
- .join('\n\n');
682
+ .join("\n\n");
239
683
  this.state = {
240
684
  started: true,
241
685
  terminated: false,
@@ -247,19 +691,21 @@ export class ACodeRuntime extends EventEmitter {
247
691
  exposedTools,
248
692
  commands,
249
693
  commandRegistry,
250
- conversation: systemPrompt ? [{ role: 'system', content: systemPrompt }] : [],
694
+ conversation: systemPrompt
695
+ ? [{ role: "system", content: systemPrompt }]
696
+ : [],
251
697
  };
252
- await this.hookBus.dispatch('SessionStart', {
698
+ await this.hookBus.dispatch("SessionStart", {
253
699
  workingDirectory: this.config.workingDirectory,
254
700
  model: provider.model,
255
701
  commandCount: commands.length,
256
702
  });
257
- this.emitAcodeEvent('session_started', {
703
+ this.emitAcodeEvent("session_started", {
258
704
  workingDirectory: this.config.workingDirectory,
259
705
  model: provider.model,
260
706
  commandCount: commands.length,
261
707
  });
262
- this.emitAcodeEvent('mcp_status', {
708
+ this.emitAcodeEvent("mcp_status", {
263
709
  configPath: this.config.configPath,
264
710
  installedCount: this.state.configMcpCount,
265
711
  servers: Object.keys(acodeConfig.mcpServers ?? {}),
@@ -271,19 +717,27 @@ export class ACodeRuntime extends EventEmitter {
271
717
  deniedToolRules: permissions.deny,
272
718
  tools: exposedTools.map((tool) => ({
273
719
  name: tool.exposedName,
274
- source: tool.kind === 'mcp' ? `${tool.serverName}.${tool.toolName}` : `builtin.${tool.toolName}`,
720
+ source: tool.kind === "mcp"
721
+ ? `${tool.serverName}.${tool.toolName}`
722
+ : `builtin.${tool.toolName}`,
275
723
  })),
276
724
  });
277
- this.emitAcodeEvent('skill_status', {
725
+ this.emitAcodeEvent("skill_status", {
278
726
  skillsDir: this.config.skillsDir,
279
727
  installedCount: skills.length,
280
- skills: skills.map((skill) => ({ name: skill.name, description: skill.description })),
728
+ skills: skills.map((skill) => ({
729
+ name: skill.name,
730
+ description: skill.description,
731
+ })),
281
732
  });
282
- this.emitAcodeEvent('command_status', {
283
- action: 'commands_loaded',
733
+ this.emitAcodeEvent("command_status", {
734
+ action: "commands_loaded",
284
735
  installedCommandCount: commands.length,
285
736
  commandDirs,
286
- commands: commands.map((command) => ({ name: command.name, description: command.description })),
737
+ commands: commands.map((command) => ({
738
+ name: command.name,
739
+ description: command.description,
740
+ })),
287
741
  });
288
742
  if (this.config.initialPrompt) {
289
743
  await this.submit(this.config.initialPrompt);
@@ -295,68 +749,107 @@ export class ACodeRuntime extends EventEmitter {
295
749
  * MCP tool call 则执行工具并继续同一轮,最后输出 assistant 文本与 usage。
296
750
  */
297
751
  async submit(message) {
752
+ const queued = this.submitQueue.then(() => this.executeSubmit(message));
753
+ this.submitQueue = queued.catch(() => undefined);
754
+ return queued;
755
+ }
756
+ async executeSubmit(message) {
298
757
  if (!this.state.started || this.state.terminated) {
299
- throw new Error('ACode runtime is not active');
758
+ throw new Error("ACode runtime is not active");
300
759
  }
760
+ this.activeTurn = true;
761
+ this.abortRequested = false;
301
762
  const originalPrompt = message.trim();
302
- const promptHookResults = await this.hookBus.dispatch('UserPromptSubmit', { prompt: originalPrompt });
303
- const promptDeny = findDeniedHookResult(promptHookResults);
304
- if (promptDeny) {
305
- this.emitAcodeEvent('error', { message: promptDeny.message || 'ACode prompt was denied by hook' });
306
- this.emitAcodeEvent('turn_complete', { failed: true });
307
- return;
308
- }
309
- const expandedCommand = this.state.commandRegistry.expand(originalPrompt);
310
- const trimmed = expandedCommand?.prompt.trim() || originalPrompt;
311
- const commandAllowedTools = expandedCommand?.command.allowedTools;
312
- if (expandedCommand) {
313
- this.emitAcodeEvent('command_status', {
314
- action: 'slash_command',
315
- command: expandedCommand.command.name,
316
- arguments: expandedCommand.argumentsText,
317
- sourcePath: expandedCommand.command.sourcePath,
318
- });
319
- }
320
- if (!this.state.provider.baseURL || !this.state.provider.apiKey) {
321
- const text = createFallbackResponse(trimmed, this.state);
322
- this.emitAcodeEvent('assistant_delta', { text });
323
- this.emitAcodeEvent('turn_complete', { usage: { inputTokens: trimmed.length, outputTokens: text.length } });
324
- return;
325
- }
326
- this.state.conversation.push({ role: 'user', content: trimmed });
327
763
  try {
764
+ const promptHookResults = await this.hookBus.dispatch("UserPromptSubmit", { prompt: originalPrompt });
765
+ const promptDeny = findDeniedHookResult(promptHookResults);
766
+ if (promptDeny) {
767
+ this.emitAcodeEvent("error", {
768
+ message: promptDeny.message || "ACode prompt was denied by hook",
769
+ });
770
+ this.emitAcodeEvent("turn_complete", { failed: true });
771
+ return;
772
+ }
773
+ const expandedCommand = this.state.commandRegistry.expand(originalPrompt);
774
+ const trimmed = expandedCommand?.prompt.trim() || originalPrompt;
775
+ const commandAllowedTools = expandedCommand?.command.allowedTools;
776
+ if (expandedCommand) {
777
+ this.emitAcodeEvent("command_status", {
778
+ action: "slash_command",
779
+ command: expandedCommand.command.name,
780
+ arguments: expandedCommand.argumentsText,
781
+ sourcePath: expandedCommand.command.sourcePath,
782
+ });
783
+ }
784
+ if (!this.state.provider.baseURL || !this.state.provider.apiKey) {
785
+ const text = createFallbackResponse(trimmed, this.state);
786
+ this.emitAcodeEvent("assistant_delta", { text });
787
+ this.emitAcodeEvent("turn_complete", {
788
+ usage: { inputTokens: trimmed.length, outputTokens: text.length },
789
+ });
790
+ return;
791
+ }
792
+ const parsedContent = parseAttachmentMessage(trimmed);
793
+ if (parsedContent.hasImages && !this.state.provider.supportsImageInput) {
794
+ throw new Error("ACode provider/model is not configured for image input. Enable provider.default.models[model].modalities.input including 'image' or options.supportsImageInput=true.");
795
+ }
796
+ const userContent = await materializeImageContent(parsedContent.content);
797
+ this.state.conversation.push({ role: "user", content: userContent });
328
798
  const result = await this.runModelTurn(commandAllowedTools);
329
- if (result.text) {
330
- this.emitAcodeEvent('assistant_delta', { text: result.text });
799
+ if (result.text && !this.state.provider.stream) {
800
+ this.emitAcodeEvent("assistant_delta", { text: result.text });
331
801
  }
332
- this.emitAcodeEvent('turn_complete', {
333
- usage: result.usage ?? { inputTokens: trimmed.length, outputTokens: result.text.length },
802
+ this.emitAcodeEvent("turn_complete", {
803
+ usage: result.usage ?? {
804
+ inputTokens: trimmed.length,
805
+ outputTokens: result.text.length,
806
+ },
807
+ ...(result.responseBody ? { responseBody: result.responseBody } : {}),
808
+ ...(result.finishReason ? { finishReason: result.finishReason } : {}),
334
809
  });
335
810
  }
336
811
  catch (error) {
337
- this.emitAcodeEvent('error', {
812
+ if (this.abortRequested) {
813
+ this.emitAcodeEvent("turn_complete", { aborted: true });
814
+ return;
815
+ }
816
+ this.emitAcodeEvent("error", {
338
817
  message: error instanceof Error ? error.message : String(error),
339
818
  });
340
- this.emitAcodeEvent('turn_complete', { failed: true });
819
+ this.emitAcodeEvent("turn_complete", { failed: true });
820
+ }
821
+ finally {
822
+ this.activeTurn = false;
823
+ this.abortRequested = false;
341
824
  }
342
825
  }
343
- /** 中止当前轮次;当前骨架无长任务,仅发出完成状态。 */
826
+ /** 中止当前轮次;由执行轮次统一发出一个终态事件,避免 aborted/failed 双终态。 */
344
827
  async abort() {
828
+ this.abortRequested = this.activeTurn;
345
829
  this.abortController?.abort();
346
830
  this.abortController = null;
347
- if (!this.state.terminated) {
348
- this.emitAcodeEvent('turn_complete', { aborted: true });
349
- }
350
831
  }
351
832
  /** 终止 ACode 会话并发出 session_complete。 */
352
833
  async stop() {
353
834
  if (this.state.terminated) {
354
835
  return;
355
836
  }
837
+ this.state.terminated = true;
838
+ this.abortRequested = this.activeTurn;
839
+ this.abortController?.abort();
840
+ this.abortController = null;
841
+ await runBestEffort("ACode active turn shutdown", this.submitQueue, (message) => {
842
+ this.emitAcodeEvent("error", { message });
843
+ });
356
844
  await this.disconnectMcpServers();
357
- await this.hookBus.dispatch('Stop', { reason: 'session_stop' });
845
+ await runBestEffort("ACode Stop hooks", this.hookBus.dispatch("Stop", { reason: "session_stop" }), (message) => {
846
+ this.emitAcodeEvent("error", { message });
847
+ });
358
848
  this.state.terminated = true;
359
- this.emitAcodeEvent('session_complete', { exitCode: 0 });
849
+ if (!this.sessionCompleteEmitted) {
850
+ this.sessionCompleteEmitted = true;
851
+ this.emitAcodeEvent("session_complete", { exitCode: 0 });
852
+ }
360
853
  }
361
854
  resolveCommandDirs(acodeConfig) {
362
855
  const configured = [
@@ -364,23 +857,39 @@ export class ACodeRuntime extends EventEmitter {
364
857
  ...(acodeConfig.commandDirs ?? []),
365
858
  ...(acodeConfig.commands?.directories ?? []),
366
859
  ];
367
- return [...new Set(configured.map((directory) => directory.trim()).filter(Boolean))];
860
+ return [
861
+ ...new Set(configured.map((directory) => directory.trim()).filter(Boolean)),
862
+ ];
368
863
  }
369
864
  async runModelTurn(commandAllowedTools) {
370
- let finalText = '';
865
+ let finalText = "";
371
866
  let finalUsage;
867
+ let finalResponseBody;
868
+ let finalFinishReason;
372
869
  for (let step = 0; step < 5; step += 1) {
373
870
  const response = await this.requestChatCompletion(commandAllowedTools);
871
+ if (this.state.terminated) {
872
+ throw new Error("ACode runtime was stopped during the active turn");
873
+ }
374
874
  const choice = response.choices?.[0];
875
+ finalResponseBody = JSON.stringify(response, null, 2);
876
+ finalFinishReason =
877
+ typeof choice?.finish_reason === "string"
878
+ ? choice.finish_reason
879
+ : undefined;
375
880
  const message = choice?.message;
376
- const content = typeof message?.content === 'string' ? message.content : '';
377
- const toolCalls = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
881
+ const content = typeof message?.content === "string" ? message.content : "";
882
+ const toolCalls = Array.isArray(message?.tool_calls)
883
+ ? message.tool_calls
884
+ : [];
378
885
  finalUsage = {
379
886
  inputTokens: response.usage?.prompt_tokens ?? response.usage?.input_tokens ?? 0,
380
- outputTokens: response.usage?.completion_tokens ?? response.usage?.output_tokens ?? 0,
887
+ outputTokens: response.usage?.completion_tokens ??
888
+ response.usage?.output_tokens ??
889
+ 0,
381
890
  };
382
891
  this.state.conversation.push({
383
- role: 'assistant',
892
+ role: "assistant",
384
893
  content: content || null,
385
894
  tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
386
895
  });
@@ -388,57 +897,241 @@ export class ACodeRuntime extends EventEmitter {
388
897
  finalText = content;
389
898
  }
390
899
  if (toolCalls.length === 0) {
391
- return { text: finalText, usage: finalUsage };
900
+ return {
901
+ text: finalText,
902
+ usage: finalUsage,
903
+ responseBody: finalResponseBody,
904
+ finishReason: finalFinishReason,
905
+ };
392
906
  }
393
907
  await this.handleToolCalls(toolCalls, commandAllowedTools);
394
908
  }
395
- return { text: finalText || 'ACode 已达到本轮 MCP 工具调用上限。', usage: finalUsage };
909
+ return {
910
+ text: finalText || "ACode 已达到本轮 MCP 工具调用上限。",
911
+ usage: finalUsage,
912
+ responseBody: finalResponseBody,
913
+ finishReason: finalFinishReason,
914
+ };
396
915
  }
397
916
  async requestChatCompletion(commandAllowedTools) {
398
917
  const provider = this.state.provider;
399
- if (!provider.baseURL) {
400
- throw new Error('ACode provider baseURL is not configured');
918
+ const baseURL = provider.baseURL;
919
+ if (!baseURL) {
920
+ throw new Error("ACode provider baseURL is not configured");
401
921
  }
402
- this.abortController = new AbortController();
403
922
  const body = {
404
923
  model: provider.model,
405
924
  messages: this.state.conversation,
406
- stream: false,
925
+ stream: provider.stream,
407
926
  };
408
927
  const allowedTools = this.state.exposedTools.filter((tool) => isToolAllowed(tool, this.state.permissions, commandAllowedTools));
409
928
  if (allowedTools.length > 0) {
410
929
  body.tools = toOpenAiTools(allowedTools);
411
- body.tool_choice = 'auto';
930
+ body.tool_choice = "auto";
412
931
  }
413
- const response = await fetch(normalizeChatCompletionsUrl(provider.baseURL), {
414
- method: 'POST',
415
- headers: buildRequestHeaders(provider),
416
- body: JSON.stringify(body),
417
- signal: this.abortController.signal,
932
+ this.emitAcodeEvent("ai_request", {
933
+ model: provider.model,
934
+ endpoint: summarizeProviderUrl(baseURL),
935
+ stream: provider.stream,
936
+ messageCount: this.state.conversation.length,
937
+ toolCount: allowedTools.length,
938
+ lastUserMessagePreview: summarizeLastUserMessage(this.state.conversation),
939
+ requestBody: JSON.stringify(body, null, 2),
418
940
  });
419
- this.abortController = null;
420
- if (!response.ok) {
421
- throw new Error(`ACode provider request failed: ${response.status} ${await response.text()}`);
941
+ const maxAttempts = provider.requestRetryCount + 1;
942
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
943
+ this.abortController = new AbortController();
944
+ let timedOut = false;
945
+ try {
946
+ return await withTimeout((async () => {
947
+ const response = await withTimeout(fetch(normalizeChatCompletionsUrl(baseURL), {
948
+ method: "POST",
949
+ headers: buildRequestHeaders(provider),
950
+ body: JSON.stringify(body),
951
+ signal: this.abortController?.signal,
952
+ }), provider.requestTimeoutMs, "ACode provider request", () => {
953
+ timedOut = true;
954
+ this.abortController?.abort();
955
+ });
956
+ if (!response.ok) {
957
+ throw new Error(`ACode provider request failed: ${response.status} ${await response.text()}`);
958
+ }
959
+ const contentType = response.headers?.get?.("content-type") || "";
960
+ if (provider.stream &&
961
+ contentType.toLowerCase().includes("text/event-stream")) {
962
+ return await this.readChatCompletionStream(response, provider.requestTimeoutMs);
963
+ }
964
+ const payload = (await withTimeout(response.json(), provider.requestTimeoutMs, "ACode provider response body", () => {
965
+ timedOut = true;
966
+ this.abortController?.abort();
967
+ }));
968
+ return isRecord(payload) ? payload : {};
969
+ })(), 0, "ACode provider request");
970
+ }
971
+ catch (error) {
972
+ let requestError;
973
+ if (error instanceof Error && error.name === "AbortError") {
974
+ requestError = timedOut
975
+ ? new Error(`ACode provider request timed out after ${provider.requestTimeoutMs}ms`)
976
+ : new Error("ACode provider request aborted");
977
+ }
978
+ else {
979
+ requestError =
980
+ error instanceof Error ? error : new Error(String(error));
981
+ }
982
+ const shouldRetry = attempt < maxAttempts &&
983
+ !this.abortRequested &&
984
+ !this.state.terminated &&
985
+ isRetryableProviderRequestError(requestError);
986
+ if (!shouldRetry) {
987
+ throw requestError;
988
+ }
989
+ this.emitAcodeEvent("error", {
990
+ message: `ACode provider request attempt ${attempt}/${maxAttempts} failed; retrying: ${requestError.message}`,
991
+ });
992
+ await delay(DEFAULT_PROVIDER_REQUEST_RETRY_DELAY_MS * attempt);
993
+ }
994
+ finally {
995
+ this.abortController = null;
996
+ }
422
997
  }
423
- const payload = await response.json();
424
- return isRecord(payload) ? payload : {};
998
+ throw new Error("ACode provider request failed after all retries");
999
+ }
1000
+ async readChatCompletionStream(response, idleTimeoutMs) {
1001
+ if (!response.body) {
1002
+ throw new Error("ACode provider stream response has no body");
1003
+ }
1004
+ const reader = response.body.getReader();
1005
+ const decoder = new TextDecoder();
1006
+ const toolCallAccumulators = new Map();
1007
+ let text = "";
1008
+ let buffer = "";
1009
+ const consumeFrame = (frame) => {
1010
+ for (const data of parseSseDataLines(frame)) {
1011
+ if (data === "[DONE]") {
1012
+ continue;
1013
+ }
1014
+ const payload = JSON.parse(data);
1015
+ if (!isRecord(payload)) {
1016
+ continue;
1017
+ }
1018
+ const choice = payload.choices?.[0];
1019
+ const delta = choice?.delta;
1020
+ const reasoningText = extractReasoningText(delta);
1021
+ if (reasoningText) {
1022
+ this.emitAcodeEvent("thinking_delta", { text: reasoningText });
1023
+ }
1024
+ const content = typeof delta?.content === "string" ? delta.content : "";
1025
+ if (content) {
1026
+ text += content;
1027
+ this.emitAcodeEvent("assistant_delta", { text: content });
1028
+ }
1029
+ const toolDeltas = Array.isArray(delta?.tool_calls)
1030
+ ? delta.tool_calls
1031
+ : [];
1032
+ for (const toolDelta of toolDeltas) {
1033
+ updateToolCallAccumulator(toolCallAccumulators, toolDelta);
1034
+ }
1035
+ }
1036
+ };
1037
+ while (true) {
1038
+ const { done, value } = await withTimeout(reader.read(), idleTimeoutMs, "ACode provider stream idle", () => this.abortController?.abort());
1039
+ if (done) {
1040
+ break;
1041
+ }
1042
+ buffer += decoder.decode(value, { stream: true });
1043
+ const frames = buffer.split(/\r?\n\r?\n/);
1044
+ buffer = frames.pop() ?? "";
1045
+ for (const frame of frames) {
1046
+ consumeFrame(frame);
1047
+ }
1048
+ }
1049
+ buffer += decoder.decode();
1050
+ if (buffer.trim()) {
1051
+ consumeFrame(buffer);
1052
+ }
1053
+ return {
1054
+ choices: [
1055
+ {
1056
+ message: {
1057
+ content: text || null,
1058
+ tool_calls: toAccumulatedToolCalls(toolCallAccumulators),
1059
+ },
1060
+ },
1061
+ ],
1062
+ };
425
1063
  }
426
1064
  async handleToolCalls(toolCalls, commandAllowedTools) {
427
1065
  for (const toolCall of toolCalls) {
428
1066
  const tool = this.state.exposedTools.find((item) => item.exposedName === toolCall.function.name);
429
1067
  if (!tool) {
430
1068
  this.state.conversation.push({
431
- role: 'tool',
1069
+ role: "tool",
432
1070
  tool_call_id: toolCall.id,
433
1071
  content: `ACode MCP tool not found: ${toolCall.function.name}`,
434
1072
  });
435
1073
  continue;
436
1074
  }
437
- if (tool.kind === 'builtin') {
1075
+ try {
1076
+ if (tool.kind === "builtin") {
1077
+ const toolArguments = coerceArguments(toolCall.function.arguments);
1078
+ assertToolPermission(tool, this.state.permissions, commandAllowedTools);
1079
+ const preResults = await this.hookBus.dispatch("PreToolUse", {
1080
+ source: "builtin",
1081
+ toolName: tool.toolName,
1082
+ exposedName: tool.exposedName,
1083
+ arguments: toolArguments,
1084
+ });
1085
+ const denied = findDeniedHookResult(preResults);
1086
+ if (denied) {
1087
+ throw new Error(denied.message ||
1088
+ `ACode tool denied by hook: ${tool.exposedName}`);
1089
+ }
1090
+ this.emitAcodeEvent("mcp_status", {
1091
+ action: "call_tool",
1092
+ server: "builtin",
1093
+ tool: tool.toolName,
1094
+ exposedName: tool.exposedName,
1095
+ toolUseId: toolCall.id,
1096
+ arguments: toolArguments,
1097
+ argumentsPreview: summarizeToolArguments(toolArguments),
1098
+ });
1099
+ const result = await withTimeout(tool.execute(toolArguments), getBuiltInToolTimeoutMs(tool, toolArguments, this.state.provider.mcpToolTimeoutMs), `ACode built-in tool ${tool.toolName}`);
1100
+ await this.hookBus.dispatch("PostToolUse", {
1101
+ source: "builtin",
1102
+ toolName: tool.toolName,
1103
+ exposedName: tool.exposedName,
1104
+ result: toToolResultText(result),
1105
+ });
1106
+ const resultText = toToolResultText(result);
1107
+ this.emitAcodeEvent("mcp_status", {
1108
+ action: "tool_result",
1109
+ server: "builtin",
1110
+ tool: tool.toolName,
1111
+ result: resultText,
1112
+ });
1113
+ this.state.conversation.push({
1114
+ role: "tool",
1115
+ tool_call_id: toolCall.id,
1116
+ content: resultText,
1117
+ });
1118
+ await this.appendSyntheticMultimodalUserMessage(resultText);
1119
+ continue;
1120
+ }
1121
+ const connection = this.state.mcpConnections.find((item) => item.serverName === tool.serverName);
1122
+ if (!connection) {
1123
+ this.state.conversation.push({
1124
+ role: "tool",
1125
+ tool_call_id: toolCall.id,
1126
+ content: `ACode MCP server not connected: ${tool.serverName}`,
1127
+ });
1128
+ continue;
1129
+ }
438
1130
  const toolArguments = coerceArguments(toolCall.function.arguments);
439
1131
  assertToolPermission(tool, this.state.permissions, commandAllowedTools);
440
- const preResults = await this.hookBus.dispatch('PreToolUse', {
441
- source: 'builtin',
1132
+ const preResults = await this.hookBus.dispatch("PreToolUse", {
1133
+ source: "mcp",
1134
+ serverName: tool.serverName,
442
1135
  toolName: tool.toolName,
443
1136
  exposedName: tool.exposedName,
444
1137
  arguments: toolArguments,
@@ -447,70 +1140,88 @@ export class ACodeRuntime extends EventEmitter {
447
1140
  if (denied) {
448
1141
  throw new Error(denied.message || `ACode tool denied by hook: ${tool.exposedName}`);
449
1142
  }
450
- this.emitAcodeEvent('mcp_status', { action: 'call_tool', server: 'builtin', tool: tool.toolName });
451
- const result = await tool.execute(toolArguments);
452
- await this.hookBus.dispatch('PostToolUse', {
453
- source: 'builtin',
1143
+ this.emitAcodeEvent("mcp_status", {
1144
+ action: "call_tool",
1145
+ server: tool.serverName,
1146
+ tool: tool.toolName,
1147
+ exposedName: tool.exposedName,
1148
+ toolUseId: toolCall.id,
1149
+ arguments: toolArguments,
1150
+ argumentsPreview: summarizeToolArguments(toolArguments),
1151
+ });
1152
+ const toolTimeoutMs = isMcpPollingToolName(tool.toolName)
1153
+ ? this.state.provider.mcpPollingToolTimeoutMs
1154
+ : this.state.provider.mcpToolTimeoutMs;
1155
+ const result = await withTimeout(connection.client.callTool({
1156
+ name: tool.toolName,
1157
+ arguments: toolArguments,
1158
+ }, undefined, { timeout: toolTimeoutMs }), toolTimeoutMs, `ACode MCP tool ${tool.serverName}/${tool.toolName}`);
1159
+ await this.hookBus.dispatch("PostToolUse", {
1160
+ source: "mcp",
1161
+ serverName: tool.serverName,
454
1162
  toolName: tool.toolName,
455
1163
  exposedName: tool.exposedName,
456
1164
  result: toToolResultText(result),
457
1165
  });
458
- this.state.conversation.push({
459
- role: 'tool',
460
- tool_call_id: toolCall.id,
461
- content: toToolResultText(result),
1166
+ const resultText = toToolResultText(result);
1167
+ this.emitAcodeEvent("mcp_status", {
1168
+ action: "tool_result",
1169
+ server: tool.serverName,
1170
+ tool: tool.toolName,
1171
+ result: resultText,
462
1172
  });
463
- continue;
464
- }
465
- const connection = this.state.mcpConnections.find((item) => item.serverName === tool.serverName);
466
- if (!connection) {
467
1173
  this.state.conversation.push({
468
- role: 'tool',
1174
+ role: "tool",
469
1175
  tool_call_id: toolCall.id,
470
- content: `ACode MCP server not connected: ${tool.serverName}`,
1176
+ content: resultText,
471
1177
  });
472
- continue;
1178
+ await this.appendSyntheticMultimodalUserMessage(resultText);
473
1179
  }
474
- const toolArguments = coerceArguments(toolCall.function.arguments);
475
- assertToolPermission(tool, this.state.permissions, commandAllowedTools);
476
- const preResults = await this.hookBus.dispatch('PreToolUse', {
477
- source: 'mcp',
478
- serverName: tool.serverName,
479
- toolName: tool.toolName,
480
- exposedName: tool.exposedName,
481
- arguments: toolArguments,
482
- });
483
- const denied = findDeniedHookResult(preResults);
484
- if (denied) {
485
- throw new Error(denied.message || `ACode tool denied by hook: ${tool.exposedName}`);
1180
+ catch (error) {
1181
+ this.appendToolFailureResult(toolCall, tool, error);
486
1182
  }
487
- this.emitAcodeEvent('mcp_status', { action: 'call_tool', server: tool.serverName, tool: tool.toolName });
488
- const result = await connection.client.callTool({
489
- name: tool.toolName,
490
- arguments: toolArguments,
491
- });
492
- await this.hookBus.dispatch('PostToolUse', {
493
- source: 'mcp',
494
- serverName: tool.serverName,
495
- toolName: tool.toolName,
496
- exposedName: tool.exposedName,
497
- result: toToolResultText(result),
498
- });
499
- this.state.conversation.push({
500
- role: 'tool',
501
- tool_call_id: toolCall.id,
502
- content: toToolResultText(result),
1183
+ }
1184
+ }
1185
+ async appendSyntheticMultimodalUserMessage(toolResultText) {
1186
+ const parsedContent = parseAttachmentMessage(toolResultText);
1187
+ if (!parsedContent.hasImages) {
1188
+ return;
1189
+ }
1190
+ if (!this.state.provider.supportsImageInput) {
1191
+ this.emitAcodeEvent("error", {
1192
+ message: "ACode skipped image content from tool result because the provider/model is not configured for image input.",
503
1193
  });
1194
+ return;
504
1195
  }
1196
+ const content = await materializeImageContent(parsedContent.content);
1197
+ this.state.conversation.push({ role: "user", content });
1198
+ }
1199
+ appendToolFailureResult(toolCall, tool, error) {
1200
+ const resultText = toToolErrorResultText(tool, error);
1201
+ this.emitAcodeEvent("error", {
1202
+ message: `ACode tool failed: ${tool.exposedName}: ${getErrorMessage(error)}`,
1203
+ });
1204
+ this.emitAcodeEvent("mcp_status", {
1205
+ action: "tool_result",
1206
+ server: tool.kind === "mcp" ? tool.serverName : "builtin",
1207
+ tool: tool.toolName,
1208
+ result: resultText,
1209
+ failed: true,
1210
+ });
1211
+ this.state.conversation.push({
1212
+ role: "tool",
1213
+ tool_call_id: toolCall.id,
1214
+ content: resultText,
1215
+ });
505
1216
  }
506
- async connectMcpServers(servers) {
1217
+ async connectMcpServers(servers, timeoutMs) {
507
1218
  const connections = [];
508
1219
  for (const [serverName, serverSpec] of Object.entries(servers)) {
509
1220
  try {
510
- const client = new Client({ name: 'acode', version: '0.1.0' });
1221
+ const client = new Client({ name: "acode", version: "0.1.0" });
511
1222
  const transport = this.createMcpTransport(serverSpec);
512
- await client.connect(transport);
513
- const { tools } = await client.listTools();
1223
+ await withTimeout(client.connect(transport), timeoutMs, `ACode MCP server ${serverName} connect`);
1224
+ const { tools } = await withTimeout(client.listTools(), timeoutMs, `ACode MCP server ${serverName} listTools`);
514
1225
  connections.push({
515
1226
  serverName,
516
1227
  client,
@@ -518,15 +1229,17 @@ export class ACodeRuntime extends EventEmitter {
518
1229
  tools: tools.map((tool) => ({
519
1230
  serverName,
520
1231
  toolName: tool.name,
521
- kind: 'mcp',
1232
+ kind: "mcp",
522
1233
  exposedName: normalizeToolName(serverName, tool.name),
523
1234
  description: tool.description,
524
- inputSchema: isJsonObject(tool.inputSchema) ? tool.inputSchema : { type: 'object', properties: {} },
1235
+ inputSchema: isJsonObject(tool.inputSchema)
1236
+ ? tool.inputSchema
1237
+ : { type: "object", properties: {} },
525
1238
  })),
526
1239
  });
527
1240
  }
528
1241
  catch (error) {
529
- this.emitAcodeEvent('error', {
1242
+ this.emitAcodeEvent("error", {
530
1243
  message: `ACode MCP server failed to connect: ${serverName}: ${error instanceof Error ? error.message : String(error)}`,
531
1244
  });
532
1245
  }
@@ -534,38 +1247,37 @@ export class ACodeRuntime extends EventEmitter {
534
1247
  return connections;
535
1248
  }
536
1249
  createMcpTransport(serverSpec) {
537
- if (serverSpec.type === 'stdio' || serverSpec.command) {
1250
+ if (serverSpec.type === "stdio" || serverSpec.command) {
538
1251
  if (!serverSpec.command) {
539
- throw new Error('stdio MCP server requires command');
1252
+ throw new Error("stdio MCP server requires command");
540
1253
  }
541
1254
  return new StdioClientTransport({
542
1255
  command: serverSpec.command,
543
1256
  args: serverSpec.args,
544
1257
  cwd: serverSpec.cwd || this.config.workingDirectory,
545
1258
  env: { ...(this.config.env ?? {}), ...(serverSpec.env ?? {}) },
546
- stderr: 'pipe',
1259
+ stderr: "pipe",
547
1260
  });
548
1261
  }
549
1262
  if (!serverSpec.url) {
550
- throw new Error('remote MCP server requires url');
1263
+ throw new Error("remote MCP server requires url");
551
1264
  }
552
- const requestInit = serverSpec.headers ? { headers: serverSpec.headers } : undefined;
553
- if (serverSpec.type === 'sse') {
1265
+ const requestInit = serverSpec.headers
1266
+ ? { headers: serverSpec.headers }
1267
+ : undefined;
1268
+ if (serverSpec.type === "sse") {
554
1269
  return new SSEClientTransport(new URL(serverSpec.url), { requestInit });
555
1270
  }
556
- return new StreamableHTTPClientTransport(new URL(serverSpec.url), { requestInit });
1271
+ return new StreamableHTTPClientTransport(new URL(serverSpec.url), {
1272
+ requestInit,
1273
+ });
557
1274
  }
558
1275
  async disconnectMcpServers() {
559
1276
  const connections = this.state.mcpConnections;
560
1277
  this.state.mcpConnections = [];
561
1278
  this.state.exposedTools = [];
562
- await Promise.all(connections.map(async (connection) => {
563
- try {
564
- await connection.transport.close();
565
- }
566
- catch {
567
- // Best-effort shutdown; stale MCP transports must not block runtime termination.
568
- }
569
- }));
1279
+ await Promise.all(connections.map((connection) => runBestEffort(`ACode MCP server ${connection.serverName} close`, connection.transport.close(), (message) => {
1280
+ this.emitAcodeEvent("error", { message });
1281
+ }, this.state.provider.mcpToolTimeoutMs)));
570
1282
  }
571
1283
  }