@qwen-code/arena-agent 0.0.10-dev.1 → 0.0.10-dev.11

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 (162) hide show
  1. package/.env.example +68 -14
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +307 -57
  4. package/dist/agent.d.ts +28 -2
  5. package/dist/agent.js +389 -109
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.js +32 -35
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/ask-user.js +2 -0
  10. package/dist/ask-user.js.map +1 -1
  11. package/dist/attachments.js +4 -3
  12. package/dist/attachments.js.map +1 -1
  13. package/dist/brave-search.js +1 -3
  14. package/dist/brave-search.js.map +1 -1
  15. package/dist/canonical-data.d.ts +2 -0
  16. package/dist/canonical-data.js +4 -2
  17. package/dist/canonical-data.js.map +1 -1
  18. package/dist/cli.js +184 -36
  19. package/dist/cli.js.map +1 -1
  20. package/dist/code-tools.d.ts +8 -2
  21. package/dist/code-tools.js +709 -117
  22. package/dist/code-tools.js.map +1 -1
  23. package/dist/compaction.d.ts +50 -3
  24. package/dist/compaction.js +390 -185
  25. package/dist/compaction.js.map +1 -1
  26. package/dist/config.d.ts +23 -2
  27. package/dist/config.js +52 -5
  28. package/dist/config.js.map +1 -1
  29. package/dist/connector-mcp.d.ts +47 -0
  30. package/dist/connector-mcp.js +314 -0
  31. package/dist/connector-mcp.js.map +1 -0
  32. package/dist/connectors.d.ts +8 -0
  33. package/dist/connectors.js +57 -0
  34. package/dist/connectors.js.map +1 -0
  35. package/dist/fetch-page.js +9 -33
  36. package/dist/fetch-page.js.map +1 -1
  37. package/dist/file-edit.d.ts +8 -0
  38. package/dist/file-edit.js +63 -0
  39. package/dist/file-edit.js.map +1 -0
  40. package/dist/history.d.ts +16 -3
  41. package/dist/history.js +117 -19
  42. package/dist/history.js.map +1 -1
  43. package/dist/midturn-checkpoint-hook.d.ts +44 -0
  44. package/dist/midturn-checkpoint-hook.js +38 -0
  45. package/dist/midturn-checkpoint-hook.js.map +1 -0
  46. package/dist/mock.js +5 -0
  47. package/dist/mock.js.map +1 -1
  48. package/dist/mode.js +4 -2
  49. package/dist/mode.js.map +1 -1
  50. package/dist/model-router-media.d.ts +1 -0
  51. package/dist/model-router-media.js +121 -1
  52. package/dist/model-router-media.js.map +1 -1
  53. package/dist/openai-responses.js +2 -0
  54. package/dist/openai-responses.js.map +1 -1
  55. package/dist/process-manager.js +2 -1
  56. package/dist/process-manager.js.map +1 -1
  57. package/dist/prompts.d.ts +2 -1
  58. package/dist/prompts.js +15 -37
  59. package/dist/prompts.js.map +1 -1
  60. package/dist/protocol.d.ts +5 -1
  61. package/dist/protocol.js +24 -2
  62. package/dist/protocol.js.map +1 -1
  63. package/dist/provider.d.ts +12 -5
  64. package/dist/provider.js +63 -52
  65. package/dist/provider.js.map +1 -1
  66. package/dist/repetitive-output.d.ts +12 -0
  67. package/dist/repetitive-output.js +80 -0
  68. package/dist/repetitive-output.js.map +1 -0
  69. package/dist/replay-responses.d.ts +17 -0
  70. package/dist/replay-responses.js +100 -0
  71. package/dist/replay-responses.js.map +1 -0
  72. package/dist/routify-web-fetch.js +20 -7
  73. package/dist/routify-web-fetch.js.map +1 -1
  74. package/dist/sandbox-file-write.d.ts +10 -0
  75. package/dist/sandbox-file-write.js +58 -0
  76. package/dist/sandbox-file-write.js.map +1 -0
  77. package/dist/server.js +17 -5
  78. package/dist/server.js.map +1 -1
  79. package/dist/session-memory-prompts.d.ts +5 -0
  80. package/dist/session-memory-prompts.js +7 -0
  81. package/dist/session-memory-prompts.js.map +1 -0
  82. package/dist/snapshot-client.d.ts +10 -0
  83. package/dist/snapshot-client.js +39 -0
  84. package/dist/snapshot-client.js.map +1 -0
  85. package/dist/spec.d.ts +5 -4
  86. package/dist/spec.js +31 -17
  87. package/dist/spec.js.map +1 -1
  88. package/dist/speech-audio.d.ts +9 -0
  89. package/dist/speech-audio.js +130 -0
  90. package/dist/speech-audio.js.map +1 -0
  91. package/dist/speech-model.d.ts +3 -0
  92. package/dist/speech-model.js +8 -0
  93. package/dist/speech-model.js.map +1 -0
  94. package/dist/summary-compaction.d.ts +21 -0
  95. package/dist/summary-compaction.js +211 -0
  96. package/dist/summary-compaction.js.map +1 -0
  97. package/dist/tools.d.ts +8 -1
  98. package/dist/tools.js +407 -135
  99. package/dist/tools.js.map +1 -1
  100. package/dist/tui.js +13 -10
  101. package/dist/tui.js.map +1 -1
  102. package/dist/types.d.ts +30 -3
  103. package/dist/voice-registry.d.ts +2 -0
  104. package/dist/voice-registry.js +10 -2
  105. package/dist/voice-registry.js.map +1 -1
  106. package/dist/web-fetch-output.d.ts +9 -0
  107. package/dist/web-fetch-output.js +38 -0
  108. package/dist/web-fetch-output.js.map +1 -0
  109. package/dist/workspace.d.ts +2 -0
  110. package/dist/workspace.js +11 -6
  111. package/dist/workspace.js.map +1 -1
  112. package/docs/BINARY_DISTRIBUTION.md +27 -16
  113. package/docs/MODES_AND_RELEASES.md +60 -6
  114. package/docs/SDK.md +5 -0
  115. package/modes/code-arena/capture-provenance.json +57 -0
  116. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  117. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  118. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  119. package/modes/code-arena/full-stack/tools.json +186 -186
  120. package/modes/code-arena/html/system-prompt.txt +1 -1
  121. package/modes/code-arena/html/tools.json +123 -123
  122. package/modes/code-arena/react/system-prompt.txt +4 -3
  123. package/modes/code-arena/react/template/package.json +4 -4
  124. package/modes/code-arena/react/template/src/App.tsx +1 -1
  125. package/modes/code-arena/react/template/src/main.tsx +1 -1
  126. package/modes/code-arena/react/template/tsconfig.json +6 -0
  127. package/modes/code-arena/react/template/vite.config.ts +1 -0
  128. package/modes/code-arena/react/tools.json +178 -184
  129. package/package.json +15 -4
  130. package/scripts/README.md +80 -0
  131. package/scripts/check-changelog.mjs +66 -0
  132. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  133. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  134. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  135. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  136. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  137. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  138. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  139. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  140. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  141. package/vendor/audio/darwin-x64/manifest.json +39 -0
  142. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  143. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  144. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  145. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  146. package/vendor/audio/linux-arm64/manifest.json +39 -0
  147. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  148. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  149. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  150. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  151. package/vendor/audio/linux-x64/manifest.json +39 -0
  152. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  153. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  154. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  155. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  156. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  157. package/vendor/audio/sources/sources.lock.json +30 -0
  158. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  159. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  160. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  161. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  162. package/vendor/audio/win32-x64/manifest.json +39 -0
package/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { existsSync, mkdirSync, realpathSync, } from "node:fs";
3
- import { open, readFile, realpath, stat, writeFile, } from "node:fs/promises";
2
+ import { copyFileSync, existsSync, mkdirSync, realpathSync } from "node:fs";
3
+ import { open, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { Ajv } from "ajv";
6
6
  import addFormats from "ajv-formats";
@@ -10,18 +10,34 @@ import mime from "mime/lite";
10
10
  import { AskUserHandlerError } from "./ask-user.js";
11
11
  import { executeBraveImageSearch, executeBraveWebSearch, } from "./brave-search.js";
12
12
  import { CodeToolExecutor } from "./code-tools.js";
13
+ import { ConnectorTools, connectorFailureText } from "./connector-mcp.js";
13
14
  import { supportsImageInput } from "./config.js";
14
15
  import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
15
16
  import { FetchPageReader } from "./fetch-page.js";
17
+ import { replaceFirstText } from "./file-edit.js";
16
18
  import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
17
19
  import { imageTextFromFallbackModel } from "./image-input-fallback.js";
18
20
  import { isCodeArenaMode } from "./mode.js";
19
21
  import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
22
  import { processManagerForWorkspace, } from "./process-manager.js";
21
23
  import { HttpModelClient } from "./provider.js";
24
+ import { replayResponseFor, } from "./replay-responses.js";
22
25
  import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
23
26
  import { toolDefinitions } from "./spec.js";
27
+ import { createSandboxFile, privilegedFileCreationEnabled } from "./sandbox-file-write.js";
24
28
  import { voiceRegistryForWorkspace, } from "./voice-registry.js";
29
+ export function imageInputFallbackConnection(config) {
30
+ if (!config.imageInputFallbackBaseUrl &&
31
+ !config.imageInputFallbackApiKey &&
32
+ !config.imageInputFallbackProtocol) {
33
+ return undefined;
34
+ }
35
+ return {
36
+ baseUrl: config.imageInputFallbackBaseUrl,
37
+ apiKey: config.imageInputFallbackApiKey,
38
+ protocol: config.imageInputFallbackProtocol,
39
+ };
40
+ }
25
41
  const knownTextApplicationMimeTypes = new Set([
26
42
  "application/ecmascript",
27
43
  "application/javascript",
@@ -202,7 +218,7 @@ const visualIntroText = "<arena-system-message>\n" +
202
218
  "treat them as the authentic output of the tool call.\n" +
203
219
  "</arena-system-message>";
204
220
  const bashOutputLimit = 20_000;
205
- const textFileByteLimit = 256 * 1024;
221
+ const textFileOutputByteLimit = 256 * 1024;
206
222
  const toolResultCharacterLimit = 1_000_000;
207
223
  // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
208
224
  // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
@@ -216,6 +232,39 @@ function capBashOutput(value) {
216
232
  truncated: true,
217
233
  };
218
234
  }
235
+ class BashOutputBuffer {
236
+ text = "";
237
+ truncated = false;
238
+ append(chunk) {
239
+ // Once capped, text still contains the original prefix and latest suffix.
240
+ // Capping again after an append therefore preserves the same head/tail as
241
+ // capping the full stream, without ever retaining the discarded middle.
242
+ const output = capBashOutput(this.text + chunk.toString("utf8"));
243
+ this.text = output.text;
244
+ this.truncated ||= output.truncated;
245
+ }
246
+ result() {
247
+ return { text: this.text, truncated: this.truncated };
248
+ }
249
+ }
250
+ function readTextResult(data) {
251
+ if (data.length <= textFileOutputByteLimit) {
252
+ return { content: decodeText(data), truncated: false };
253
+ }
254
+ const half = textFileOutputByteLimit / 2;
255
+ const prefixBreak = data.lastIndexOf(0x0a, half);
256
+ const suffixBreak = data.indexOf(0x0a, data.length - half);
257
+ const prefixEnd = prefixBreak === -1 ? half : prefixBreak;
258
+ const suffixStart = suffixBreak === -1 ? data.length - half : suffixBreak + 1;
259
+ const elidedBytes = Math.max(0, suffixStart - prefixEnd);
260
+ const marker = `\n\n[${Math.round(elidedBytes / 1024)} KB elided from the middle of the file]\n\n`;
261
+ return {
262
+ content: decodeText(data.subarray(0, prefixEnd)) +
263
+ marker +
264
+ decodeText(data.subarray(suffixStart)),
265
+ truncated: true,
266
+ };
267
+ }
219
268
  const mimeTypes = {
220
269
  ".aac": "audio/aac",
221
270
  ".bmp": "image/bmp",
@@ -269,8 +318,7 @@ function decodeUtf32(data, littleEndian) {
269
318
  const codePoint = littleEndian
270
319
  ? data.readUInt32LE(index)
271
320
  : data.readUInt32BE(index);
272
- characters.push(codePoint <= 0x10ffff &&
273
- !(codePoint >= 0xd800 && codePoint <= 0xdfff)
321
+ characters.push(codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
274
322
  ? String.fromCodePoint(codePoint)
275
323
  : "\ufffd");
276
324
  }
@@ -377,12 +425,22 @@ function receivedType(value) {
377
425
  return "array";
378
426
  return typeof value;
379
427
  }
428
+ function arenaEnumIssue(values, pathParts) {
429
+ return {
430
+ code: "invalid_value", values, path: pathParts,
431
+ message: `Invalid option: expected one of ${values.map((item) => JSON.stringify(item)).join("|")}`,
432
+ };
433
+ }
380
434
  function arenaValidationIssue(error, value) {
381
435
  const pathParts = jsonPointerPath(error.instancePath);
382
436
  if (error.keyword === "required") {
383
437
  const missingProperty = String(error.params.missingProperty);
384
438
  const parentSchema = error.parentSchema;
385
- const expected = zodTypeName(parentSchema?.properties?.[missingProperty]?.type || "unknown");
439
+ const missingSchema = parentSchema?.properties?.[missingProperty];
440
+ if (missingSchema?.enum) {
441
+ return arenaEnumIssue(missingSchema.enum, [...pathParts, missingProperty]);
442
+ }
443
+ const expected = zodTypeName(missingSchema?.type || "unknown");
386
444
  return {
387
445
  expected,
388
446
  code: "invalid_type",
@@ -443,19 +501,11 @@ function arenaValidationIssue(error, value) {
443
501
  };
444
502
  }
445
503
  if (error.keyword === "enum") {
446
- const values = error.params.allowedValues;
447
- return {
448
- code: "invalid_value",
449
- values,
450
- path: pathParts,
451
- message: `Invalid option: expected one of ${values
452
- .map((item) => JSON.stringify(item))
453
- .join("|")}`,
454
- };
504
+ return arenaEnumIssue(error.params.allowedValues, pathParts);
455
505
  }
456
506
  if (error.keyword === "format") {
457
507
  return {
458
- origin: "string",
508
+ ...(error.params.format === "uri" ? {} : { origin: "string" }),
459
509
  code: "invalid_format",
460
510
  format: error.params.format === "uri" ? "url" : error.params.format,
461
511
  path: pathParts,
@@ -501,8 +551,8 @@ function arenaValidationIssues(error, value) {
501
551
  }
502
552
  return issues;
503
553
  }
504
- function arenaValidationError(toolName, value, errors) {
505
- const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value));
554
+ function arenaValidationError(toolName, value, errors, customIssues = []) {
555
+ const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value)).concat(customIssues);
506
556
  return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
507
557
  `${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
508
558
  }
@@ -548,12 +598,16 @@ class ArenaToolExecutor {
548
598
  config;
549
599
  workspace;
550
600
  workspaceReal;
601
+ tmpReal;
602
+ varTmpReal;
551
603
  validators = new Map();
604
+ connectors;
552
605
  pendingVisualContent = new Map();
553
606
  askUserHandler;
554
607
  imageInputFallbackClient;
555
608
  signal;
556
609
  workspaceContext;
610
+ nativeSandboxPaths;
557
611
  pageReader;
558
612
  routifyPageReader;
559
613
  processManager;
@@ -575,16 +629,30 @@ class ArenaToolExecutor {
575
629
  this.workspace = path.resolve(config.workspace);
576
630
  mkdirSync(this.workspace, { recursive: true });
577
631
  this.workspaceReal = realpathSync(this.workspace);
632
+ // A Linux /home/user workspace is already an OS-level sandbox. Other
633
+ // callers still confine ordinary workspace paths, while /tmp and /var/tmp
634
+ // always refer to the actual system directories seen by Bash.
635
+ this.nativeSandboxPaths =
636
+ process.platform === "linux" && this.workspaceReal === "/home/user";
637
+ this.tmpReal = realpathSync("/tmp");
638
+ this.varTmpReal = realpathSync("/var/tmp");
578
639
  this.processManager = processManagerForWorkspace(this.workspaceReal);
579
640
  this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
580
641
  this.askUserHandler = options.askUserHandler;
581
642
  this.askUserEnabled = Boolean(options.askUserHandler);
582
643
  if (config.imageInputFallbackModel?.trim()) {
644
+ const connection = imageInputFallbackConnection(config);
583
645
  this.imageInputFallbackClient =
584
- options.modelClient || new HttpModelClient(config);
646
+ options.imageInputFallbackClient ||
647
+ (connection
648
+ ? new HttpModelClient(config, { connection })
649
+ : options.modelClient || new HttpModelClient(config));
585
650
  }
586
651
  this.signal = options.signal;
587
652
  this.workspaceContext = options.workspaceContext;
653
+ if (config.connectors?.length && !config.disallowedTools?.includes("list_connector_tools")) {
654
+ this.connectors = new ConnectorTools(config.connectors, config.mergeConnectors, config.disallowedTools, options.signal);
655
+ }
588
656
  this.pageReader = new FetchPageReader({ signal: options.signal });
589
657
  this.routifyPageReader = new RoutifyFetchPageReader({
590
658
  signal: options.signal,
@@ -597,7 +665,7 @@ class ArenaToolExecutor {
597
665
  type: "string",
598
666
  validate: (value) => URL.canParse(value),
599
667
  });
600
- for (const definition of toolDefinitions(this.workspaceContext)) {
668
+ for (const definition of toolDefinitions(this.workspaceContext, "arena", config.connectors)) {
601
669
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
602
670
  }
603
671
  }
@@ -605,13 +673,117 @@ class ArenaToolExecutor {
605
673
  // canonical trace set, adjusted for the selected workspace runtime and minus
606
674
  // ask_user when no handler is wired. add_voice stays available headlessly.
607
675
  availableTools() {
608
- let definitions = toolDefinitions(this.workspaceContext);
676
+ let definitions = this.allDefinitions();
609
677
  if (!this.askUserEnabled) {
610
678
  definitions = definitions.filter((definition) => definition.function.name !== "ask_user");
611
679
  }
612
680
  const disallowed = new Set(this.config.disallowedTools || []);
613
681
  return definitions.filter((definition) => !disallowed.has(definition.function.name));
614
682
  }
683
+ allDefinitions() {
684
+ return [...toolDefinitions(this.workspaceContext, "arena", this.config.connectors),
685
+ ...(this.connectors?.definitions() || [])];
686
+ }
687
+ async beginConnectorTurn(history) {
688
+ await this.connectors?.beginTurn(history);
689
+ }
690
+ async closeConnectors() { await this.connectors?.close(); }
691
+ parseArguments(call) {
692
+ const rawArguments = typeof call.function?.arguments === "string"
693
+ ? call.function.arguments
694
+ : "{}";
695
+ let value;
696
+ try {
697
+ // Captured empty arguments validate as an empty object, while whitespace
698
+ // and other incomplete JSON still produce parsing errors.
699
+ value = JSON.parse(rawArguments === "" ? "{}" : rawArguments);
700
+ }
701
+ catch (error) {
702
+ return { rawArguments, invalidJsonText: rawArguments, error };
703
+ }
704
+ const malformedArguments = value !== null && typeof value === "object" && !Array.isArray(value)
705
+ ? value.__malformedToolArguments
706
+ : undefined;
707
+ if (typeof malformedArguments === "string") {
708
+ if (malformedArguments === "")
709
+ return { rawArguments, value: {} };
710
+ try {
711
+ JSON.parse(malformedArguments);
712
+ }
713
+ catch (error) {
714
+ return { rawArguments, invalidJsonText: malformedArguments, error };
715
+ }
716
+ }
717
+ return { rawArguments, value };
718
+ }
719
+ validateArguments(name, value, validate) {
720
+ const validatedArguments = structuredClone(value);
721
+ if (!validate(validatedArguments)) {
722
+ return {
723
+ validationMessage: this.connectors?.has(name) ? connectorFailureText : arenaValidationError(name, value, validate.errors),
724
+ };
725
+ }
726
+ if (name === "ask_user") {
727
+ const issues = validatedArguments.questions.flatMap((question, index) => {
728
+ const ids = question.options.map((option) => option.id);
729
+ return new Set(ids).size === ids.length ? [] : [{
730
+ code: "custom", path: ["questions", index, "options"],
731
+ message: "Option IDs must be unique within a question",
732
+ }];
733
+ });
734
+ if (issues.length)
735
+ return { validationMessage: arenaValidationError(name, value, [], issues) };
736
+ }
737
+ return { value: validatedArguments };
738
+ }
739
+ inspect(call) {
740
+ const id = String(call.id || "");
741
+ const name = String(call.function?.name || "");
742
+ const originalRawArguments = typeof call.function?.arguments === "string"
743
+ ? call.function.arguments
744
+ : "{}";
745
+ const parsed = this.parseArguments(call);
746
+ const validate = this.validators.get(name) || this.connectors?.validator(name);
747
+ if (this.config.disallowedTools?.includes(name) || !validate) {
748
+ return {
749
+ id,
750
+ name,
751
+ rawArguments: originalRawArguments,
752
+ ...(parsed.error === undefined ? { arguments: parsed.value } : {}),
753
+ validation: "unknown_tool",
754
+ validationMessage: this.config.disallowedTools?.includes(name)
755
+ ? `tool is disallowed: ${name}`
756
+ : this.connectors?.recognizes(name) ? connectorFailureText : unavailableToolError(name, this.allDefinitions().map((definition) => definition.function.name)),
757
+ };
758
+ }
759
+ if (parsed.error !== undefined) {
760
+ return {
761
+ id,
762
+ name,
763
+ rawArguments: parsed.rawArguments,
764
+ validation: "invalid_json",
765
+ validationMessage: this.connectors?.has(name) ? connectorFailureText : arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
766
+ };
767
+ }
768
+ const validated = this.validateArguments(name, parsed.value, validate);
769
+ if (validated.validationMessage !== undefined) {
770
+ return {
771
+ id,
772
+ name,
773
+ rawArguments: parsed.rawArguments,
774
+ arguments: parsed.value,
775
+ validation: "invalid_arguments",
776
+ validationMessage: validated.validationMessage,
777
+ };
778
+ }
779
+ return {
780
+ id,
781
+ name,
782
+ rawArguments: parsed.rawArguments,
783
+ arguments: parsed.value,
784
+ validation: "valid",
785
+ };
786
+ }
615
787
  async execute(call, context) {
616
788
  const callId = String(call.id || "");
617
789
  const name = String(call.function?.name || "");
@@ -629,53 +801,62 @@ class ArenaToolExecutor {
629
801
  if (this.config.disallowedTools?.includes(name)) {
630
802
  throw new Error(`tool is disallowed: ${name}`);
631
803
  }
632
- const validate = this.validators.get(name);
804
+ const validate = this.validators.get(name) || this.connectors?.validator(name);
633
805
  if (!validate) {
634
806
  return {
635
807
  role: "tool",
636
808
  tool_call_id: callId,
637
- content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
809
+ content: this.connectors?.recognizes(name) ? connectorFailureText : unavailableToolError(name, this.allDefinitions().map((definition) => definition.function.name)),
638
810
  };
639
811
  }
640
- const argumentsText = typeof call.function?.arguments === "string"
641
- ? call.function.arguments
642
- : "{}";
643
- let argumentsValue;
644
- try {
645
- argumentsValue = JSON.parse(argumentsText);
646
- }
647
- catch (error) {
812
+ const parsed = this.parseArguments(call);
813
+ if (parsed.error !== undefined) {
648
814
  return {
649
815
  role: "tool",
650
816
  tool_call_id: callId,
651
- content: arenaJsonParsingError(name, argumentsText, error),
817
+ content: this.connectors?.has(name) ? connectorFailureText : arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
652
818
  };
653
819
  }
654
- const malformedArguments = argumentsValue !== null &&
655
- typeof argumentsValue === "object" &&
656
- !Array.isArray(argumentsValue)
657
- ? argumentsValue.__malformedToolArguments
658
- : undefined;
659
- if (typeof malformedArguments === "string") {
660
- try {
661
- JSON.parse(malformedArguments);
820
+ const argumentsValue = parsed.value;
821
+ // Replay mode (CODE_HARNESS_REPLAY_RESPONSES set): serve recorded
822
+ // outputs BEFORE schema validation. Recorded arguments can violate the
823
+ // current schema — PII-redacted placeholder URLs fail format=url, or
824
+ // the schema evolved since recording (voice_identity vs voice_id) —
825
+ // and the recording must still replay verbatim. Canned lookup consumes
826
+ // the entry, so this must be the single serving point: dispatch() only
827
+ // sees calls without a recorded response. Unlisted calls fall through
828
+ // to validation and real execution.
829
+ const replayed = replayResponseFor(name, argumentsValue);
830
+ if (replayed) {
831
+ for (const artifact of replayed.artifacts ?? []) {
832
+ this.materializeReplayArtifact(artifact);
662
833
  }
663
- catch (error) {
834
+ const replayContent = typeof replayed.output === "string"
835
+ ? replayed.output
836
+ : json(replayed.output);
837
+ if (replayContent.length > toolResultCharacterLimit) {
664
838
  return {
665
839
  role: "tool",
666
840
  tool_call_id: callId,
667
- content: arenaJsonParsingError(name, malformedArguments, error),
841
+ content: json({
842
+ status: "error",
843
+ message: `Tool result from ${name} (${replayContent.length} characters) exceeds the ` +
844
+ `maximum allowed size (${toolResultCharacterLimit} characters). ` +
845
+ "Retry with narrower arguments or inspect the data in smaller portions.",
846
+ }),
668
847
  };
669
848
  }
849
+ return { role: "tool", tool_call_id: callId, content: replayContent };
670
850
  }
671
- const validatedArguments = structuredClone(argumentsValue);
672
- if (!validate(validatedArguments)) {
851
+ const validated = this.validateArguments(name, argumentsValue, validate);
852
+ if (validated.validationMessage !== undefined) {
673
853
  return {
674
854
  role: "tool",
675
855
  tool_call_id: callId,
676
- content: arenaValidationError(name, argumentsValue, validate.errors),
856
+ content: validated.validationMessage,
677
857
  };
678
858
  }
859
+ const validatedArguments = validated.value;
679
860
  const startedAt = Date.now();
680
861
  let result;
681
862
  try {
@@ -731,10 +912,9 @@ class ArenaToolExecutor {
731
912
  const message = errorMessage(error);
732
913
  const durationMs = Date.now() - startedAt;
733
914
  if (name === "bash") {
734
- const stderr = this.virtualizeBashOutput(message);
735
915
  return {
736
916
  status: "shell_error",
737
- stderr,
917
+ stderr: message,
738
918
  stdout: "",
739
919
  exit_code: null,
740
920
  duration_ms: durationMs,
@@ -820,6 +1000,13 @@ class ArenaToolExecutor {
820
1000
  };
821
1001
  }
822
1002
  async dispatch(name, values, toolCallId, context) {
1003
+ if (name === "list_connector_tools" && this.connectors) {
1004
+ return this.connectors.discover(String(values.service));
1005
+ }
1006
+ if (this.connectors?.has(name))
1007
+ return this.connectors.call(name, values);
1008
+ // Canned replay responses are served in execute() before schema
1009
+ // validation (the lookup consumes entries — single serving point).
823
1010
  switch (name) {
824
1011
  case "bash":
825
1012
  return this.bash(String(values.command), String(values.cwd ||
@@ -872,32 +1059,76 @@ class ArenaToolExecutor {
872
1059
  throw new Error(`unknown tool: ${name}`);
873
1060
  }
874
1061
  }
1062
+ // Copy a pre-staged replay artifact blob into the workspace. Recorded
1063
+ // artifact paths must stay workspace-relative: an absolute path or one
1064
+ // escaping the workspace is a corrupt recording, so reject it loudly.
1065
+ materializeReplayArtifact(artifact) {
1066
+ const normalized = path.posix.normalize(artifact.path);
1067
+ if (path.posix.isAbsolute(artifact.path) ||
1068
+ path.posix.isAbsolute(normalized) ||
1069
+ normalized === ".." ||
1070
+ normalized.startsWith("../")) {
1071
+ throw new Error(`replay artifact path escapes the workspace: ${JSON.stringify(artifact.path)}`);
1072
+ }
1073
+ const target = path.resolve(this.workspace, normalized);
1074
+ mkdirSync(path.dirname(target), { recursive: true });
1075
+ copyFileSync(artifact.file, target);
1076
+ }
875
1077
  async confinedPath(raw) {
876
1078
  if (!raw)
877
1079
  throw new Error("path must not be empty");
878
1080
  let candidate;
879
- if (raw === "/home/user") {
880
- candidate = this.workspace;
1081
+ let confinementRoot = this.workspace;
1082
+ let confinementRootReal = this.workspaceReal;
1083
+ const expanded = raw === "~"
1084
+ ? "/home/user"
1085
+ : raw.startsWith("~/")
1086
+ ? `/home/user/${raw.slice(2)}`
1087
+ : raw;
1088
+ const virtualAbsolute = path.posix.resolve("/home/user", expanded);
1089
+ let suffix;
1090
+ if (virtualAbsolute === "/var/tmp" ||
1091
+ virtualAbsolute.startsWith("/var/tmp/")) {
1092
+ confinementRoot = "/var/tmp";
1093
+ confinementRootReal = this.varTmpReal;
1094
+ suffix = virtualAbsolute.slice("/var/tmp".length).replace(/^\/+/, "");
1095
+ candidate = path.join(confinementRoot, suffix);
881
1096
  }
882
- else if (raw.startsWith("/home/user/")) {
883
- candidate = path.join(this.workspace, raw.slice("/home/user/".length));
1097
+ else if (virtualAbsolute === "/tmp" ||
1098
+ virtualAbsolute.startsWith("/tmp/")) {
1099
+ confinementRoot = "/tmp";
1100
+ confinementRootReal = this.tmpReal;
1101
+ suffix = virtualAbsolute.slice("/tmp".length).replace(/^\/+/, "");
1102
+ candidate = path.join(confinementRoot, suffix);
1103
+ }
1104
+ else if (this.nativeSandboxPaths && path.posix.isAbsolute(raw)) {
1105
+ confinementRoot = "/";
1106
+ confinementRootReal = "/";
1107
+ candidate = raw;
884
1108
  }
885
- else if (!this.workspaceContext && raw === "~") {
1109
+ else if (!this.workspaceContext) {
1110
+ if (virtualAbsolute === "/home/user" ||
1111
+ virtualAbsolute.startsWith("/home/user/")) {
1112
+ suffix = virtualAbsolute.slice("/home/user".length).replace(/^\/+/, "");
1113
+ }
1114
+ else {
1115
+ throw new Error(`path is outside the workspace: ${raw}`);
1116
+ }
1117
+ candidate = path.join(confinementRoot, suffix);
1118
+ }
1119
+ else if (raw === "/home/user") {
886
1120
  candidate = this.workspace;
887
1121
  }
888
- else if (!this.workspaceContext && raw.startsWith("~/")) {
889
- candidate = path.join(this.workspace, raw.slice(2));
1122
+ else if (raw.startsWith("/home/user/")) {
1123
+ candidate = path.join(this.workspace, raw.slice("/home/user/".length));
890
1124
  }
891
1125
  else {
892
- const defaultCwd = this.workspaceContext
893
- ? path.resolve(this.workspaceContext.cwd)
894
- : this.workspace;
1126
+ const defaultCwd = path.resolve(this.workspaceContext.cwd);
895
1127
  candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
896
1128
  }
897
1129
  const absolute = path.resolve(candidate);
898
- const lexicalRelative = path.relative(this.workspace, absolute);
899
- if (lexicalRelative.startsWith("..") ||
900
- path.isAbsolute(lexicalRelative)) {
1130
+ const lexicalRelative = path.relative(confinementRoot, absolute);
1131
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
901
1132
  throw new Error(`path is outside the workspace: ${raw}`);
902
1133
  }
903
1134
  let ancestor = absolute;
@@ -909,33 +1140,45 @@ class ArenaToolExecutor {
909
1140
  }
910
1141
  const ancestorReal = await realpath(ancestor);
911
1142
  const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
912
- const realRelative = path.relative(this.workspaceReal, resolved);
1143
+ const realRelative = path.relative(confinementRootReal, resolved);
913
1144
  if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
914
1145
  throw new Error(`path is outside the workspace: ${raw}`);
915
1146
  }
916
1147
  return resolved;
917
1148
  }
918
1149
  relativePath(target) {
1150
+ for (const [root, virtualRoot] of [
1151
+ [this.varTmpReal, "/var/tmp"],
1152
+ [this.tmpReal, "/tmp"],
1153
+ ]) {
1154
+ const relative = path.relative(root, target);
1155
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
1156
+ const suffix = relative.split(path.sep).join("/");
1157
+ return suffix ? `${virtualRoot}/${suffix}` : virtualRoot;
1158
+ }
1159
+ }
919
1160
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
920
1161
  }
921
- virtualizeBashOutput(value) {
922
- if (this.workspaceContext)
923
- return value;
924
- let virtualized = value;
925
- const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
926
- .filter((root) => root !== path.parse(root).root)
927
- .sort((left, right) => right.length - left.length);
928
- for (const root of workspaceRoots) {
929
- virtualized = virtualized.replaceAll(root, "/home/user");
1162
+ displayWorkspacePath(rawPath) {
1163
+ if (!this.workspaceContext) {
1164
+ if (rawPath === "/home/user" || rawPath === "~")
1165
+ return ".";
1166
+ if (rawPath.startsWith("/home/user/")) {
1167
+ return rawPath.slice("/home/user/".length);
1168
+ }
1169
+ if (rawPath.startsWith("~/"))
1170
+ return rawPath.slice(2);
930
1171
  }
931
- return virtualized;
1172
+ return rawPath;
932
1173
  }
933
1174
  sandboxEnvironment() {
934
1175
  const env = {
935
1176
  ...process.env,
936
1177
  ARENA_WORKSPACE: this.workspace,
937
- LANG: "C.UTF-8",
938
- LC_ALL: "C.UTF-8",
1178
+ // Agent Arena captures use C character classification (grep -P, wc -w).
1179
+ // This controls command behavior, not the UTF-8 encoding of stored files.
1180
+ LANG: "C",
1181
+ LC_ALL: "C",
939
1182
  };
940
1183
  delete env.FORCE_COLOR;
941
1184
  delete env.CLICOLOR_FORCE;
@@ -943,28 +1186,30 @@ class ArenaToolExecutor {
943
1186
  }
944
1187
  async bash(command, cwd, timeoutSeconds) {
945
1188
  const workdir = await this.confinedPath(cwd);
946
- if (!(await stat(workdir)).isDirectory()) {
1189
+ const metadata = await stat(workdir).catch((error) => {
1190
+ // A missing cwd prevents shell startup; captured Bash stderr is empty.
1191
+ // Keep other filesystem failures and start_process diagnostics intact.
1192
+ if (error.code === "ENOENT")
1193
+ throw new Error("");
1194
+ throw error;
1195
+ });
1196
+ if (!metadata.isDirectory()) {
947
1197
  throw new Error(`cwd is not a directory: ${cwd}`);
948
1198
  }
949
- // Legacy callers without runtime context retain the captured /home/user
950
- // contract. Runtime-aware callers execute and report their actual paths.
951
- const translatedCommand = this.workspaceContext
952
- ? command
953
- : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
954
1199
  return new Promise((resolve, reject) => {
955
1200
  const env = this.sandboxEnvironment();
956
1201
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
957
1202
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
958
1203
  // 必须杀整组,并在宽限期后强制结算兜底。
959
- const child = spawn("/bin/bash", ["-lc", translatedCommand], {
1204
+ const child = spawn("/bin/bash", ["-lc", command], {
960
1205
  cwd: workdir,
961
1206
  env,
962
1207
  stdio: ["ignore", "pipe", "pipe"],
963
1208
  detached: process.platform !== "win32",
964
1209
  });
965
1210
  const startedAt = Date.now();
966
- let stdout = "";
967
- let stderr = "";
1211
+ const stdout = new BashOutputBuffer();
1212
+ const stderr = new BashOutputBuffer();
968
1213
  let timedOut = false;
969
1214
  let settled = false;
970
1215
  let forceSettle;
@@ -1001,8 +1246,8 @@ class ArenaToolExecutor {
1001
1246
  reject(this.signal.reason || new Error("operation cancelled"));
1002
1247
  return;
1003
1248
  }
1004
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
1005
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
1249
+ const out = stdout.result();
1250
+ const err = stderr.result();
1006
1251
  resolve({
1007
1252
  status: timedOut ? "timeout" : signal ? "killed" : "completed",
1008
1253
  stderr: err.text,
@@ -1032,10 +1277,10 @@ class ArenaToolExecutor {
1032
1277
  };
1033
1278
  this.signal?.addEventListener("abort", abort, { once: true });
1034
1279
  child.stdout.on("data", (chunk) => {
1035
- stdout += chunk.toString("utf8");
1280
+ stdout.append(chunk);
1036
1281
  });
1037
1282
  child.stderr.on("data", (chunk) => {
1038
- stderr += chunk.toString("utf8");
1283
+ stderr.append(chunk);
1039
1284
  });
1040
1285
  child.once("error", (error) => {
1041
1286
  if (settled)
@@ -1055,12 +1300,9 @@ class ArenaToolExecutor {
1055
1300
  if (!(await stat(workdir)).isDirectory()) {
1056
1301
  throw new Error(`cwd is not a directory: ${cwd}`);
1057
1302
  }
1058
- const translatedCommand = this.workspaceContext
1059
- ? command
1060
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1061
1303
  return this.processManager.start({
1062
1304
  name,
1063
- command: translatedCommand,
1305
+ command,
1064
1306
  cwd: workdir,
1065
1307
  startupWaitSeconds,
1066
1308
  env: this.sandboxEnvironment(),
@@ -1069,10 +1311,12 @@ class ArenaToolExecutor {
1069
1311
  }
1070
1312
  async askUser(questions, toolCallId, context) {
1071
1313
  if (this.askUserHandler) {
1072
- return this.askUserHandler(toolCallId, questions, context ? {
1073
- ...context,
1074
- signal: this.signal || context.signal,
1075
- } : undefined);
1314
+ return this.askUserHandler(toolCallId, questions, context
1315
+ ? {
1316
+ ...context,
1317
+ signal: this.signal || context.signal,
1318
+ }
1319
+ : undefined);
1076
1320
  }
1077
1321
  // ask_user is not offered in non-interactive sessions; if the model still
1078
1322
  // emits it (e.g. resumed history or a hallucinated call), report an error
@@ -1152,23 +1396,25 @@ class ArenaToolExecutor {
1152
1396
  catch {
1153
1397
  return {
1154
1398
  status: "error",
1155
- message: `Failed to read workspace file: ${rawPath}`,
1399
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1156
1400
  };
1157
1401
  }
1158
- // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
1159
- // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
1160
- // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
1161
- // 注意:spec/tools.json 的 edit_file description(与生产逐字一致,受
1162
- // T1(c) 门禁保护)声称支持 fuzzy matching——那是上游自身的描述与行为
1163
- // 不一致,按实测行为对齐,描述不改。见 TRAJECTORY_FIDELITY.md §5。
1164
- if (!source.includes(oldText)) {
1402
+ // Agent Arena replaces the first match and tolerates multiline whitespace
1403
+ // drift. Code Arena deliberately uses a separate unique-match policy.
1404
+ const edited = replaceFirstText(source, oldText, newText, {
1405
+ whitespaceInsensitiveMultiline: true,
1406
+ });
1407
+ if (edited === undefined) {
1165
1408
  return {
1166
1409
  status: "error",
1167
1410
  message: "Context not found. Read the file to verify the text exists.",
1168
1411
  };
1169
1412
  }
1170
1413
  try {
1171
- await writeFile(target, source.replace(oldText, newText), "utf8");
1414
+ // Replacement text is literal. String.replace(string, string) expands
1415
+ // $&, $`, $' and $$ sequences and therefore corrupts JavaScript/jQuery
1416
+ // snippets observed in production trajectories.
1417
+ await writeFile(target, edited, "utf8");
1172
1418
  }
1173
1419
  catch {
1174
1420
  return {
@@ -1179,10 +1425,11 @@ class ArenaToolExecutor {
1179
1425
  return { status: "success", message: `Edited ${rawPath}.` };
1180
1426
  }
1181
1427
  async readWorkspaceFile(rawPath, toolCallId) {
1182
- const target = await this.confinedPath(rawPath);
1428
+ let target;
1183
1429
  let fileSize;
1184
1430
  let sample;
1185
1431
  try {
1432
+ target = await this.confinedPath(rawPath);
1186
1433
  const metadata = await stat(target);
1187
1434
  if (!metadata.isFile())
1188
1435
  throw new Error("not a regular file");
@@ -1199,23 +1446,13 @@ class ArenaToolExecutor {
1199
1446
  }
1200
1447
  }
1201
1448
  catch {
1202
- // 逐字对齐生产错误文案,路径按调用方原样回显。
1203
1449
  return {
1204
1450
  status: "error",
1205
- message: `Failed to read workspace file: ${rawPath}`,
1451
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1206
1452
  };
1207
1453
  }
1208
1454
  const extension = path.extname(target).toLowerCase();
1209
1455
  const kind = workspaceFileKind(target, sample);
1210
- if (kind === "text" && fileSize > textFileByteLimit) {
1211
- return {
1212
- status: "error",
1213
- message: `File content (${displayByteSize(fileSize)}) exceeds the maximum allowed size ` +
1214
- `(${displayByteSize(textFileByteLimit)}). Use bash with sed/head/tail to read ` +
1215
- "specific portions of the file, or grep/rg to search for specific content " +
1216
- "instead of reading the whole file.",
1217
- };
1218
- }
1219
1456
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
1220
1457
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
1221
1458
  }
@@ -1242,7 +1479,7 @@ class ArenaToolExecutor {
1242
1479
  catch {
1243
1480
  return {
1244
1481
  status: "error",
1245
- message: `Failed to read workspace file: ${rawPath}`,
1482
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1246
1483
  };
1247
1484
  }
1248
1485
  if (isStructuredDocument(target)) {
@@ -1270,12 +1507,13 @@ class ArenaToolExecutor {
1270
1507
  return result;
1271
1508
  }
1272
1509
  if (kind === "text") {
1273
- const content = decodeText(data);
1510
+ const text = readTextResult(data);
1274
1511
  return {
1275
1512
  kind: "text",
1276
- lines: content.split("\n").length,
1277
- size: Buffer.byteLength(content, "utf8"),
1278
- content,
1513
+ lines: data.reduce((count, byte) => count + (byte === 0x0a ? 1 : 0), 1),
1514
+ size: data.length,
1515
+ content: text.content,
1516
+ ...(text.truncated ? { truncated: true } : {}),
1279
1517
  };
1280
1518
  }
1281
1519
  const mimeType = mime.getType(target) ||
@@ -1325,11 +1563,22 @@ class ArenaToolExecutor {
1325
1563
  };
1326
1564
  }
1327
1565
  async writeWorkspaceFile(rawPath, content) {
1328
- let target;
1329
1566
  try {
1330
- target = await this.confinedPath(rawPath);
1567
+ const target = await this.confinedPath(rawPath);
1331
1568
  mkdirSync(path.dirname(target), { recursive: true });
1332
- await writeFile(target, content, "utf8");
1569
+ try {
1570
+ await writeFile(target, content, "utf8");
1571
+ }
1572
+ catch (error) {
1573
+ const code = error.code;
1574
+ if (!["EACCES", "EPERM"].includes(code || "") || !privilegedFileCreationEnabled({
1575
+ platform: process.platform, workspace: this.workspaceReal,
1576
+ docker: existsSync("/.dockerenv"), flag: process.env.ARENA_SANDBOX_PRIVILEGED_FILE_CREATION,
1577
+ uid: process.getuid?.(),
1578
+ }))
1579
+ throw error;
1580
+ await createSandboxFile(target, content);
1581
+ }
1333
1582
  }
1334
1583
  catch {
1335
1584
  return {
@@ -1352,18 +1601,21 @@ class ArenaToolExecutor {
1352
1601
  const slug = query
1353
1602
  .toLowerCase()
1354
1603
  .replace(/[^a-z0-9]+/g, "-")
1355
- .slice(0, 40);
1604
+ .replace(/^-+|-+$/g, "")
1605
+ .slice(0, 40) || "results";
1356
1606
  for (const value of results) {
1357
1607
  if (saved.length >= count)
1358
1608
  break;
1359
1609
  const item = record(value);
1360
1610
  const properties = record(item.properties);
1361
1611
  const thumbnail = record(item.thumbnail);
1362
- const sourceUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1363
- const thumbnailUrl = stringValue(thumbnail.src) || sourceUrl;
1364
- if (!sourceUrl)
1612
+ // Brave's result URL is the source page; properties.url is an image asset.
1613
+ const sourceUrl = stringValue(item.url);
1614
+ const imageUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1615
+ const thumbnailUrl = stringValue(thumbnail.src) || imageUrl;
1616
+ if (!imageUrl)
1365
1617
  continue;
1366
- const downloadUrls = [...new Set([sourceUrl, thumbnailUrl])];
1618
+ const downloadUrls = [...new Set([imageUrl, thumbnailUrl])];
1367
1619
  for (const downloadUrl of downloadUrls) {
1368
1620
  try {
1369
1621
  const imageResponse = await fetch(downloadUrl, {
@@ -1378,7 +1630,9 @@ class ArenaToolExecutor {
1378
1630
  .split(";")[0]
1379
1631
  ?.trim()
1380
1632
  .toLowerCase();
1381
- const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1633
+ const extension = normalizedType === "image/jpeg"
1634
+ ? ".jpg"
1635
+ : Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1382
1636
  const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1383
1637
  mkdirSync(path.dirname(target), { recursive: true });
1384
1638
  await writeFile(target, await responseBytes(imageResponse));
@@ -1418,13 +1672,19 @@ class ArenaToolExecutor {
1418
1672
  }
1419
1673
  }
1420
1674
  async generateImage(rawPath, prompt, images) {
1675
+ if (!rawPath) {
1676
+ return { status: "error", message: "File path cannot be empty." };
1677
+ }
1421
1678
  const target = await this.confinedPath(rawPath);
1422
1679
  const extension = path.extname(target).toLowerCase();
1423
1680
  if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1424
- throw new Error("file_path must end in .jpg, .jpeg, or .png");
1681
+ return {
1682
+ status: "error",
1683
+ message: "File path must end in .jpg, .jpeg, or .png.",
1684
+ };
1425
1685
  }
1426
1686
  mkdirSync(path.dirname(target), { recursive: true });
1427
- const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
1687
+ const model = process.env.ARENA_IMAGE_MODEL || "gemini-3.1-flash-image";
1428
1688
  const references = [];
1429
1689
  for (const imagePath of images || []) {
1430
1690
  const source = await this.confinedPath(imagePath);
@@ -1459,7 +1719,7 @@ class ArenaToolExecutor {
1459
1719
  await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1460
1720
  return {
1461
1721
  status: "success",
1462
- file_path: this.relativePath(target),
1722
+ file_path: rawPath,
1463
1723
  };
1464
1724
  }
1465
1725
  async generateSpeech(rawPath, text, voiceId, language) {
@@ -1472,7 +1732,7 @@ class ArenaToolExecutor {
1472
1732
  ".opus": "opus",
1473
1733
  }[extension];
1474
1734
  if (!format) {
1475
- throw new Error("ModelRouter Doubao TTS output supports .mp3, .wav, .ogg, or .opus");
1735
+ throw new Error("ModelRouter TTS output supports .mp3, .wav, .ogg, or .opus");
1476
1736
  }
1477
1737
  mkdirSync(path.dirname(target), { recursive: true });
1478
1738
  const registered = this.voiceRegistry.resolve(voiceId);
@@ -1488,14 +1748,15 @@ class ArenaToolExecutor {
1488
1748
  const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
1489
1749
  await writeFile(target, await generateSpeechWithModelRouter({
1490
1750
  format,
1491
- model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
1751
+ model: registered.model,
1752
+ language: language || registered.language,
1492
1753
  signal: this.signal,
1493
1754
  text,
1494
1755
  voice,
1495
1756
  }));
1496
1757
  return {
1497
1758
  status: "success",
1498
- file_path: this.relativePath(target),
1759
+ file_path: rawPath,
1499
1760
  };
1500
1761
  }
1501
1762
  }
@@ -1520,6 +1781,17 @@ export class ToolExecutor {
1520
1781
  availableTools() {
1521
1782
  return this.implementation.availableTools();
1522
1783
  }
1784
+ async beginConnectorTurn(history) {
1785
+ if (this.implementation instanceof ArenaToolExecutor)
1786
+ await this.implementation.beginConnectorTurn(history);
1787
+ }
1788
+ async closeConnectors() {
1789
+ if (this.implementation instanceof ArenaToolExecutor)
1790
+ await this.implementation.closeConnectors();
1791
+ }
1792
+ inspect(call) {
1793
+ return this.implementation.inspect(call);
1794
+ }
1523
1795
  execute(call, context) {
1524
1796
  return this.implementation.execute(call, context);
1525
1797
  }