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

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 (154) hide show
  1. package/.env.example +57 -14
  2. package/CHANGELOG.md +228 -0
  3. package/README.md +228 -50
  4. package/dist/agent.d.ts +28 -2
  5. package/dist/agent.js +341 -106
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.js +23 -22
  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.js +2 -2
  16. package/dist/canonical-data.js.map +1 -1
  17. package/dist/cli.js +184 -36
  18. package/dist/cli.js.map +1 -1
  19. package/dist/code-tools.d.ts +8 -2
  20. package/dist/code-tools.js +709 -117
  21. package/dist/code-tools.js.map +1 -1
  22. package/dist/compaction.d.ts +49 -3
  23. package/dist/compaction.js +382 -186
  24. package/dist/compaction.js.map +1 -1
  25. package/dist/config.d.ts +17 -2
  26. package/dist/config.js +47 -5
  27. package/dist/config.js.map +1 -1
  28. package/dist/fetch-page.js +9 -33
  29. package/dist/fetch-page.js.map +1 -1
  30. package/dist/file-edit.d.ts +8 -0
  31. package/dist/file-edit.js +63 -0
  32. package/dist/file-edit.js.map +1 -0
  33. package/dist/history.d.ts +16 -3
  34. package/dist/history.js +117 -19
  35. package/dist/history.js.map +1 -1
  36. package/dist/legacy-compaction.d.ts +20 -0
  37. package/dist/legacy-compaction.js +209 -0
  38. package/dist/legacy-compaction.js.map +1 -0
  39. package/dist/midturn-checkpoint-hook.d.ts +31 -0
  40. package/dist/midturn-checkpoint-hook.js +38 -0
  41. package/dist/midturn-checkpoint-hook.js.map +1 -0
  42. package/dist/mock.js +5 -0
  43. package/dist/mock.js.map +1 -1
  44. package/dist/mode.js +4 -2
  45. package/dist/mode.js.map +1 -1
  46. package/dist/model-router-media.d.ts +1 -0
  47. package/dist/model-router-media.js +71 -1
  48. package/dist/model-router-media.js.map +1 -1
  49. package/dist/openai-responses.js +2 -0
  50. package/dist/openai-responses.js.map +1 -1
  51. package/dist/process-manager.js +2 -1
  52. package/dist/process-manager.js.map +1 -1
  53. package/dist/prompts.d.ts +2 -0
  54. package/dist/prompts.js +6 -2
  55. package/dist/prompts.js.map +1 -1
  56. package/dist/protocol.d.ts +5 -1
  57. package/dist/protocol.js +24 -2
  58. package/dist/protocol.js.map +1 -1
  59. package/dist/provider.d.ts +12 -5
  60. package/dist/provider.js +60 -52
  61. package/dist/provider.js.map +1 -1
  62. package/dist/repetitive-output.d.ts +12 -0
  63. package/dist/repetitive-output.js +80 -0
  64. package/dist/repetitive-output.js.map +1 -0
  65. package/dist/replay-responses.d.ts +17 -0
  66. package/dist/replay-responses.js +100 -0
  67. package/dist/replay-responses.js.map +1 -0
  68. package/dist/routify-web-fetch.js +20 -7
  69. package/dist/routify-web-fetch.js.map +1 -1
  70. package/dist/sandbox-file-write.d.ts +10 -0
  71. package/dist/sandbox-file-write.js +58 -0
  72. package/dist/sandbox-file-write.js.map +1 -0
  73. package/dist/server.js +17 -5
  74. package/dist/server.js.map +1 -1
  75. package/dist/session-memory-prompts.d.ts +5 -0
  76. package/dist/session-memory-prompts.js +7 -0
  77. package/dist/session-memory-prompts.js.map +1 -0
  78. package/dist/snapshot-client.d.ts +10 -0
  79. package/dist/snapshot-client.js +39 -0
  80. package/dist/snapshot-client.js.map +1 -0
  81. package/dist/spec.js +18 -10
  82. package/dist/spec.js.map +1 -1
  83. package/dist/speech-audio.d.ts +9 -0
  84. package/dist/speech-audio.js +130 -0
  85. package/dist/speech-audio.js.map +1 -0
  86. package/dist/speech-model.d.ts +3 -0
  87. package/dist/speech-model.js +8 -0
  88. package/dist/speech-model.js.map +1 -0
  89. package/dist/tools.d.ts +5 -0
  90. package/dist/tools.js +378 -132
  91. package/dist/tools.js.map +1 -1
  92. package/dist/tui.js +13 -10
  93. package/dist/tui.js.map +1 -1
  94. package/dist/types.d.ts +28 -3
  95. package/dist/voice-registry.d.ts +2 -0
  96. package/dist/voice-registry.js +10 -2
  97. package/dist/voice-registry.js.map +1 -1
  98. package/dist/web-fetch-output.d.ts +9 -0
  99. package/dist/web-fetch-output.js +38 -0
  100. package/dist/web-fetch-output.js.map +1 -0
  101. package/dist/workspace.d.ts +2 -0
  102. package/dist/workspace.js +11 -6
  103. package/dist/workspace.js.map +1 -1
  104. package/docs/BINARY_DISTRIBUTION.md +27 -16
  105. package/docs/MODES_AND_RELEASES.md +28 -5
  106. package/docs/SDK.md +5 -0
  107. package/modes/code-arena/capture-provenance.json +57 -0
  108. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  109. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  110. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  111. package/modes/code-arena/full-stack/tools.json +186 -186
  112. package/modes/code-arena/html/system-prompt.txt +1 -1
  113. package/modes/code-arena/html/tools.json +123 -123
  114. package/modes/code-arena/react/system-prompt.txt +4 -3
  115. package/modes/code-arena/react/template/package.json +4 -4
  116. package/modes/code-arena/react/template/src/App.tsx +1 -1
  117. package/modes/code-arena/react/template/src/main.tsx +1 -1
  118. package/modes/code-arena/react/template/tsconfig.json +6 -0
  119. package/modes/code-arena/react/template/vite.config.ts +1 -0
  120. package/modes/code-arena/react/tools.json +178 -184
  121. package/package.json +14 -4
  122. package/scripts/README.md +62 -0
  123. package/scripts/check-changelog.mjs +66 -0
  124. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  125. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  126. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  127. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  128. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  129. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  130. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  131. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  132. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  133. package/vendor/audio/darwin-x64/manifest.json +39 -0
  134. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  135. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  136. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  137. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  138. package/vendor/audio/linux-arm64/manifest.json +39 -0
  139. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  140. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  141. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  142. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  143. package/vendor/audio/linux-x64/manifest.json +39 -0
  144. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  145. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  146. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  147. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  148. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  149. package/vendor/audio/sources/sources.lock.json +30 -0
  150. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  151. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  152. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  153. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  154. 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";
@@ -13,15 +13,30 @@ import { CodeToolExecutor } from "./code-tools.js";
13
13
  import { supportsImageInput } from "./config.js";
14
14
  import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
15
15
  import { FetchPageReader } from "./fetch-page.js";
16
+ import { replaceFirstText } from "./file-edit.js";
16
17
  import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
17
18
  import { imageTextFromFallbackModel } from "./image-input-fallback.js";
18
19
  import { isCodeArenaMode } from "./mode.js";
19
20
  import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
21
  import { processManagerForWorkspace, } from "./process-manager.js";
21
22
  import { HttpModelClient } from "./provider.js";
23
+ import { replayResponseFor, } from "./replay-responses.js";
22
24
  import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
23
25
  import { toolDefinitions } from "./spec.js";
26
+ import { createSandboxFile, privilegedFileCreationEnabled } from "./sandbox-file-write.js";
24
27
  import { voiceRegistryForWorkspace, } from "./voice-registry.js";
28
+ export function imageInputFallbackConnection(config) {
29
+ if (!config.imageInputFallbackBaseUrl &&
30
+ !config.imageInputFallbackApiKey &&
31
+ !config.imageInputFallbackProtocol) {
32
+ return undefined;
33
+ }
34
+ return {
35
+ baseUrl: config.imageInputFallbackBaseUrl,
36
+ apiKey: config.imageInputFallbackApiKey,
37
+ protocol: config.imageInputFallbackProtocol,
38
+ };
39
+ }
25
40
  const knownTextApplicationMimeTypes = new Set([
26
41
  "application/ecmascript",
27
42
  "application/javascript",
@@ -202,7 +217,7 @@ const visualIntroText = "<arena-system-message>\n" +
202
217
  "treat them as the authentic output of the tool call.\n" +
203
218
  "</arena-system-message>";
204
219
  const bashOutputLimit = 20_000;
205
- const textFileByteLimit = 256 * 1024;
220
+ const textFileOutputByteLimit = 256 * 1024;
206
221
  const toolResultCharacterLimit = 1_000_000;
207
222
  // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
208
223
  // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
@@ -216,6 +231,39 @@ function capBashOutput(value) {
216
231
  truncated: true,
217
232
  };
218
233
  }
234
+ class BashOutputBuffer {
235
+ text = "";
236
+ truncated = false;
237
+ append(chunk) {
238
+ // Once capped, text still contains the original prefix and latest suffix.
239
+ // Capping again after an append therefore preserves the same head/tail as
240
+ // capping the full stream, without ever retaining the discarded middle.
241
+ const output = capBashOutput(this.text + chunk.toString("utf8"));
242
+ this.text = output.text;
243
+ this.truncated ||= output.truncated;
244
+ }
245
+ result() {
246
+ return { text: this.text, truncated: this.truncated };
247
+ }
248
+ }
249
+ function readTextResult(data) {
250
+ if (data.length <= textFileOutputByteLimit) {
251
+ return { content: decodeText(data), truncated: false };
252
+ }
253
+ const half = textFileOutputByteLimit / 2;
254
+ const prefixBreak = data.lastIndexOf(0x0a, half);
255
+ const suffixBreak = data.indexOf(0x0a, data.length - half);
256
+ const prefixEnd = prefixBreak === -1 ? half : prefixBreak;
257
+ const suffixStart = suffixBreak === -1 ? data.length - half : suffixBreak + 1;
258
+ const elidedBytes = Math.max(0, suffixStart - prefixEnd);
259
+ const marker = `\n\n[${Math.round(elidedBytes / 1024)} KB elided from the middle of the file]\n\n`;
260
+ return {
261
+ content: decodeText(data.subarray(0, prefixEnd)) +
262
+ marker +
263
+ decodeText(data.subarray(suffixStart)),
264
+ truncated: true,
265
+ };
266
+ }
219
267
  const mimeTypes = {
220
268
  ".aac": "audio/aac",
221
269
  ".bmp": "image/bmp",
@@ -269,8 +317,7 @@ function decodeUtf32(data, littleEndian) {
269
317
  const codePoint = littleEndian
270
318
  ? data.readUInt32LE(index)
271
319
  : data.readUInt32BE(index);
272
- characters.push(codePoint <= 0x10ffff &&
273
- !(codePoint >= 0xd800 && codePoint <= 0xdfff)
320
+ characters.push(codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)
274
321
  ? String.fromCodePoint(codePoint)
275
322
  : "\ufffd");
276
323
  }
@@ -377,12 +424,22 @@ function receivedType(value) {
377
424
  return "array";
378
425
  return typeof value;
379
426
  }
427
+ function arenaEnumIssue(values, pathParts) {
428
+ return {
429
+ code: "invalid_value", values, path: pathParts,
430
+ message: `Invalid option: expected one of ${values.map((item) => JSON.stringify(item)).join("|")}`,
431
+ };
432
+ }
380
433
  function arenaValidationIssue(error, value) {
381
434
  const pathParts = jsonPointerPath(error.instancePath);
382
435
  if (error.keyword === "required") {
383
436
  const missingProperty = String(error.params.missingProperty);
384
437
  const parentSchema = error.parentSchema;
385
- const expected = zodTypeName(parentSchema?.properties?.[missingProperty]?.type || "unknown");
438
+ const missingSchema = parentSchema?.properties?.[missingProperty];
439
+ if (missingSchema?.enum) {
440
+ return arenaEnumIssue(missingSchema.enum, [...pathParts, missingProperty]);
441
+ }
442
+ const expected = zodTypeName(missingSchema?.type || "unknown");
386
443
  return {
387
444
  expected,
388
445
  code: "invalid_type",
@@ -443,19 +500,11 @@ function arenaValidationIssue(error, value) {
443
500
  };
444
501
  }
445
502
  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
- };
503
+ return arenaEnumIssue(error.params.allowedValues, pathParts);
455
504
  }
456
505
  if (error.keyword === "format") {
457
506
  return {
458
- origin: "string",
507
+ ...(error.params.format === "uri" ? {} : { origin: "string" }),
459
508
  code: "invalid_format",
460
509
  format: error.params.format === "uri" ? "url" : error.params.format,
461
510
  path: pathParts,
@@ -501,8 +550,8 @@ function arenaValidationIssues(error, value) {
501
550
  }
502
551
  return issues;
503
552
  }
504
- function arenaValidationError(toolName, value, errors) {
505
- const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value));
553
+ function arenaValidationError(toolName, value, errors, customIssues = []) {
554
+ const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value)).concat(customIssues);
506
555
  return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
507
556
  `${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
508
557
  }
@@ -548,12 +597,15 @@ class ArenaToolExecutor {
548
597
  config;
549
598
  workspace;
550
599
  workspaceReal;
600
+ tmpReal;
601
+ varTmpReal;
551
602
  validators = new Map();
552
603
  pendingVisualContent = new Map();
553
604
  askUserHandler;
554
605
  imageInputFallbackClient;
555
606
  signal;
556
607
  workspaceContext;
608
+ nativeSandboxPaths;
557
609
  pageReader;
558
610
  routifyPageReader;
559
611
  processManager;
@@ -575,13 +627,24 @@ class ArenaToolExecutor {
575
627
  this.workspace = path.resolve(config.workspace);
576
628
  mkdirSync(this.workspace, { recursive: true });
577
629
  this.workspaceReal = realpathSync(this.workspace);
630
+ // A Linux /home/user workspace is already an OS-level sandbox. Other
631
+ // callers still confine ordinary workspace paths, while /tmp and /var/tmp
632
+ // always refer to the actual system directories seen by Bash.
633
+ this.nativeSandboxPaths =
634
+ process.platform === "linux" && this.workspaceReal === "/home/user";
635
+ this.tmpReal = realpathSync("/tmp");
636
+ this.varTmpReal = realpathSync("/var/tmp");
578
637
  this.processManager = processManagerForWorkspace(this.workspaceReal);
579
638
  this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
580
639
  this.askUserHandler = options.askUserHandler;
581
640
  this.askUserEnabled = Boolean(options.askUserHandler);
582
641
  if (config.imageInputFallbackModel?.trim()) {
642
+ const connection = imageInputFallbackConnection(config);
583
643
  this.imageInputFallbackClient =
584
- options.modelClient || new HttpModelClient(config);
644
+ options.imageInputFallbackClient ||
645
+ (connection
646
+ ? new HttpModelClient(config, { connection })
647
+ : options.modelClient || new HttpModelClient(config));
585
648
  }
586
649
  this.signal = options.signal;
587
650
  this.workspaceContext = options.workspaceContext;
@@ -612,6 +675,102 @@ class ArenaToolExecutor {
612
675
  const disallowed = new Set(this.config.disallowedTools || []);
613
676
  return definitions.filter((definition) => !disallowed.has(definition.function.name));
614
677
  }
678
+ parseArguments(call) {
679
+ const rawArguments = typeof call.function?.arguments === "string"
680
+ ? call.function.arguments
681
+ : "{}";
682
+ let value;
683
+ try {
684
+ // Captured empty arguments validate as an empty object, while whitespace
685
+ // and other incomplete JSON still produce parsing errors.
686
+ value = JSON.parse(rawArguments === "" ? "{}" : rawArguments);
687
+ }
688
+ catch (error) {
689
+ return { rawArguments, invalidJsonText: rawArguments, error };
690
+ }
691
+ const malformedArguments = value !== null && typeof value === "object" && !Array.isArray(value)
692
+ ? value.__malformedToolArguments
693
+ : undefined;
694
+ if (typeof malformedArguments === "string") {
695
+ if (malformedArguments === "")
696
+ return { rawArguments, value: {} };
697
+ try {
698
+ JSON.parse(malformedArguments);
699
+ }
700
+ catch (error) {
701
+ return { rawArguments, invalidJsonText: malformedArguments, error };
702
+ }
703
+ }
704
+ return { rawArguments, value };
705
+ }
706
+ validateArguments(name, value, validate) {
707
+ const validatedArguments = structuredClone(value);
708
+ if (!validate(validatedArguments)) {
709
+ return {
710
+ validationMessage: arenaValidationError(name, value, validate.errors),
711
+ };
712
+ }
713
+ if (name === "ask_user") {
714
+ const issues = validatedArguments.questions.flatMap((question, index) => {
715
+ const ids = question.options.map((option) => option.id);
716
+ return new Set(ids).size === ids.length ? [] : [{
717
+ code: "custom", path: ["questions", index, "options"],
718
+ message: "Option IDs must be unique within a question",
719
+ }];
720
+ });
721
+ if (issues.length)
722
+ return { validationMessage: arenaValidationError(name, value, [], issues) };
723
+ }
724
+ return { value: validatedArguments };
725
+ }
726
+ inspect(call) {
727
+ const id = String(call.id || "");
728
+ const name = String(call.function?.name || "");
729
+ const originalRawArguments = typeof call.function?.arguments === "string"
730
+ ? call.function.arguments
731
+ : "{}";
732
+ const parsed = this.parseArguments(call);
733
+ const validate = this.validators.get(name);
734
+ if (this.config.disallowedTools?.includes(name) || !validate) {
735
+ return {
736
+ id,
737
+ name,
738
+ rawArguments: originalRawArguments,
739
+ ...(parsed.error === undefined ? { arguments: parsed.value } : {}),
740
+ validation: "unknown_tool",
741
+ validationMessage: this.config.disallowedTools?.includes(name)
742
+ ? `tool is disallowed: ${name}`
743
+ : unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
744
+ };
745
+ }
746
+ if (parsed.error !== undefined) {
747
+ return {
748
+ id,
749
+ name,
750
+ rawArguments: parsed.rawArguments,
751
+ validation: "invalid_json",
752
+ validationMessage: arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
753
+ };
754
+ }
755
+ const validated = this.validateArguments(name, parsed.value, validate);
756
+ if (validated.validationMessage !== undefined) {
757
+ return {
758
+ id,
759
+ name,
760
+ rawArguments: parsed.rawArguments,
761
+ arguments: parsed.value,
762
+ validation: "invalid_arguments",
763
+ validationMessage: validated.validationMessage,
764
+ };
765
+ }
766
+ return {
767
+ id,
768
+ name,
769
+ rawArguments: parsed.rawArguments,
770
+ arguments: parsed.value,
771
+ validation: "valid",
772
+ };
773
+ }
615
774
  async execute(call, context) {
616
775
  const callId = String(call.id || "");
617
776
  const name = String(call.function?.name || "");
@@ -637,45 +796,54 @@ class ArenaToolExecutor {
637
796
  content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
638
797
  };
639
798
  }
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) {
799
+ const parsed = this.parseArguments(call);
800
+ if (parsed.error !== undefined) {
648
801
  return {
649
802
  role: "tool",
650
803
  tool_call_id: callId,
651
- content: arenaJsonParsingError(name, argumentsText, error),
804
+ content: arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
652
805
  };
653
806
  }
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);
807
+ const argumentsValue = parsed.value;
808
+ // Replay mode (CODE_HARNESS_REPLAY_RESPONSES set): serve recorded
809
+ // outputs BEFORE schema validation. Recorded arguments can violate the
810
+ // current schema — PII-redacted placeholder URLs fail format=url, or
811
+ // the schema evolved since recording (voice_identity vs voice_id) —
812
+ // and the recording must still replay verbatim. Canned lookup consumes
813
+ // the entry, so this must be the single serving point: dispatch() only
814
+ // sees calls without a recorded response. Unlisted calls fall through
815
+ // to validation and real execution.
816
+ const replayed = replayResponseFor(name, argumentsValue);
817
+ if (replayed) {
818
+ for (const artifact of replayed.artifacts ?? []) {
819
+ this.materializeReplayArtifact(artifact);
662
820
  }
663
- catch (error) {
821
+ const replayContent = typeof replayed.output === "string"
822
+ ? replayed.output
823
+ : json(replayed.output);
824
+ if (replayContent.length > toolResultCharacterLimit) {
664
825
  return {
665
826
  role: "tool",
666
827
  tool_call_id: callId,
667
- content: arenaJsonParsingError(name, malformedArguments, error),
828
+ content: json({
829
+ status: "error",
830
+ message: `Tool result from ${name} (${replayContent.length} characters) exceeds the ` +
831
+ `maximum allowed size (${toolResultCharacterLimit} characters). ` +
832
+ "Retry with narrower arguments or inspect the data in smaller portions.",
833
+ }),
668
834
  };
669
835
  }
836
+ return { role: "tool", tool_call_id: callId, content: replayContent };
670
837
  }
671
- const validatedArguments = structuredClone(argumentsValue);
672
- if (!validate(validatedArguments)) {
838
+ const validated = this.validateArguments(name, argumentsValue, validate);
839
+ if (validated.validationMessage !== undefined) {
673
840
  return {
674
841
  role: "tool",
675
842
  tool_call_id: callId,
676
- content: arenaValidationError(name, argumentsValue, validate.errors),
843
+ content: validated.validationMessage,
677
844
  };
678
845
  }
846
+ const validatedArguments = validated.value;
679
847
  const startedAt = Date.now();
680
848
  let result;
681
849
  try {
@@ -731,10 +899,9 @@ class ArenaToolExecutor {
731
899
  const message = errorMessage(error);
732
900
  const durationMs = Date.now() - startedAt;
733
901
  if (name === "bash") {
734
- const stderr = this.virtualizeBashOutput(message);
735
902
  return {
736
903
  status: "shell_error",
737
- stderr,
904
+ stderr: message,
738
905
  stdout: "",
739
906
  exit_code: null,
740
907
  duration_ms: durationMs,
@@ -820,6 +987,8 @@ class ArenaToolExecutor {
820
987
  };
821
988
  }
822
989
  async dispatch(name, values, toolCallId, context) {
990
+ // Canned replay responses are served in execute() before schema
991
+ // validation (the lookup consumes entries — single serving point).
823
992
  switch (name) {
824
993
  case "bash":
825
994
  return this.bash(String(values.command), String(values.cwd ||
@@ -872,32 +1041,76 @@ class ArenaToolExecutor {
872
1041
  throw new Error(`unknown tool: ${name}`);
873
1042
  }
874
1043
  }
1044
+ // Copy a pre-staged replay artifact blob into the workspace. Recorded
1045
+ // artifact paths must stay workspace-relative: an absolute path or one
1046
+ // escaping the workspace is a corrupt recording, so reject it loudly.
1047
+ materializeReplayArtifact(artifact) {
1048
+ const normalized = path.posix.normalize(artifact.path);
1049
+ if (path.posix.isAbsolute(artifact.path) ||
1050
+ path.posix.isAbsolute(normalized) ||
1051
+ normalized === ".." ||
1052
+ normalized.startsWith("../")) {
1053
+ throw new Error(`replay artifact path escapes the workspace: ${JSON.stringify(artifact.path)}`);
1054
+ }
1055
+ const target = path.resolve(this.workspace, normalized);
1056
+ mkdirSync(path.dirname(target), { recursive: true });
1057
+ copyFileSync(artifact.file, target);
1058
+ }
875
1059
  async confinedPath(raw) {
876
1060
  if (!raw)
877
1061
  throw new Error("path must not be empty");
878
1062
  let candidate;
879
- if (raw === "/home/user") {
880
- candidate = this.workspace;
1063
+ let confinementRoot = this.workspace;
1064
+ let confinementRootReal = this.workspaceReal;
1065
+ const expanded = raw === "~"
1066
+ ? "/home/user"
1067
+ : raw.startsWith("~/")
1068
+ ? `/home/user/${raw.slice(2)}`
1069
+ : raw;
1070
+ const virtualAbsolute = path.posix.resolve("/home/user", expanded);
1071
+ let suffix;
1072
+ if (virtualAbsolute === "/var/tmp" ||
1073
+ virtualAbsolute.startsWith("/var/tmp/")) {
1074
+ confinementRoot = "/var/tmp";
1075
+ confinementRootReal = this.varTmpReal;
1076
+ suffix = virtualAbsolute.slice("/var/tmp".length).replace(/^\/+/, "");
1077
+ candidate = path.join(confinementRoot, suffix);
881
1078
  }
882
- else if (raw.startsWith("/home/user/")) {
883
- candidate = path.join(this.workspace, raw.slice("/home/user/".length));
1079
+ else if (virtualAbsolute === "/tmp" ||
1080
+ virtualAbsolute.startsWith("/tmp/")) {
1081
+ confinementRoot = "/tmp";
1082
+ confinementRootReal = this.tmpReal;
1083
+ suffix = virtualAbsolute.slice("/tmp".length).replace(/^\/+/, "");
1084
+ candidate = path.join(confinementRoot, suffix);
884
1085
  }
885
- else if (!this.workspaceContext && raw === "~") {
1086
+ else if (this.nativeSandboxPaths && path.posix.isAbsolute(raw)) {
1087
+ confinementRoot = "/";
1088
+ confinementRootReal = "/";
1089
+ candidate = raw;
1090
+ }
1091
+ else if (!this.workspaceContext) {
1092
+ if (virtualAbsolute === "/home/user" ||
1093
+ virtualAbsolute.startsWith("/home/user/")) {
1094
+ suffix = virtualAbsolute.slice("/home/user".length).replace(/^\/+/, "");
1095
+ }
1096
+ else {
1097
+ throw new Error(`path is outside the workspace: ${raw}`);
1098
+ }
1099
+ candidate = path.join(confinementRoot, suffix);
1100
+ }
1101
+ else if (raw === "/home/user") {
886
1102
  candidate = this.workspace;
887
1103
  }
888
- else if (!this.workspaceContext && raw.startsWith("~/")) {
889
- candidate = path.join(this.workspace, raw.slice(2));
1104
+ else if (raw.startsWith("/home/user/")) {
1105
+ candidate = path.join(this.workspace, raw.slice("/home/user/".length));
890
1106
  }
891
1107
  else {
892
- const defaultCwd = this.workspaceContext
893
- ? path.resolve(this.workspaceContext.cwd)
894
- : this.workspace;
1108
+ const defaultCwd = path.resolve(this.workspaceContext.cwd);
895
1109
  candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
896
1110
  }
897
1111
  const absolute = path.resolve(candidate);
898
- const lexicalRelative = path.relative(this.workspace, absolute);
899
- if (lexicalRelative.startsWith("..") ||
900
- path.isAbsolute(lexicalRelative)) {
1112
+ const lexicalRelative = path.relative(confinementRoot, absolute);
1113
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
901
1114
  throw new Error(`path is outside the workspace: ${raw}`);
902
1115
  }
903
1116
  let ancestor = absolute;
@@ -909,33 +1122,45 @@ class ArenaToolExecutor {
909
1122
  }
910
1123
  const ancestorReal = await realpath(ancestor);
911
1124
  const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
912
- const realRelative = path.relative(this.workspaceReal, resolved);
1125
+ const realRelative = path.relative(confinementRootReal, resolved);
913
1126
  if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
914
1127
  throw new Error(`path is outside the workspace: ${raw}`);
915
1128
  }
916
1129
  return resolved;
917
1130
  }
918
1131
  relativePath(target) {
1132
+ for (const [root, virtualRoot] of [
1133
+ [this.varTmpReal, "/var/tmp"],
1134
+ [this.tmpReal, "/tmp"],
1135
+ ]) {
1136
+ const relative = path.relative(root, target);
1137
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
1138
+ const suffix = relative.split(path.sep).join("/");
1139
+ return suffix ? `${virtualRoot}/${suffix}` : virtualRoot;
1140
+ }
1141
+ }
919
1142
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
920
1143
  }
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");
930
- }
931
- return virtualized;
1144
+ displayWorkspacePath(rawPath) {
1145
+ if (!this.workspaceContext) {
1146
+ if (rawPath === "/home/user" || rawPath === "~")
1147
+ return ".";
1148
+ if (rawPath.startsWith("/home/user/")) {
1149
+ return rawPath.slice("/home/user/".length);
1150
+ }
1151
+ if (rawPath.startsWith("~/"))
1152
+ return rawPath.slice(2);
1153
+ }
1154
+ return rawPath;
932
1155
  }
933
1156
  sandboxEnvironment() {
934
1157
  const env = {
935
1158
  ...process.env,
936
1159
  ARENA_WORKSPACE: this.workspace,
937
- LANG: "C.UTF-8",
938
- LC_ALL: "C.UTF-8",
1160
+ // Agent Arena captures use C character classification (grep -P, wc -w).
1161
+ // This controls command behavior, not the UTF-8 encoding of stored files.
1162
+ LANG: "C",
1163
+ LC_ALL: "C",
939
1164
  };
940
1165
  delete env.FORCE_COLOR;
941
1166
  delete env.CLICOLOR_FORCE;
@@ -943,28 +1168,30 @@ class ArenaToolExecutor {
943
1168
  }
944
1169
  async bash(command, cwd, timeoutSeconds) {
945
1170
  const workdir = await this.confinedPath(cwd);
946
- if (!(await stat(workdir)).isDirectory()) {
1171
+ const metadata = await stat(workdir).catch((error) => {
1172
+ // A missing cwd prevents shell startup; captured Bash stderr is empty.
1173
+ // Keep other filesystem failures and start_process diagnostics intact.
1174
+ if (error.code === "ENOENT")
1175
+ throw new Error("");
1176
+ throw error;
1177
+ });
1178
+ if (!metadata.isDirectory()) {
947
1179
  throw new Error(`cwd is not a directory: ${cwd}`);
948
1180
  }
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
1181
  return new Promise((resolve, reject) => {
955
1182
  const env = this.sandboxEnvironment();
956
1183
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
957
1184
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
958
1185
  // 必须杀整组,并在宽限期后强制结算兜底。
959
- const child = spawn("/bin/bash", ["-lc", translatedCommand], {
1186
+ const child = spawn("/bin/bash", ["-lc", command], {
960
1187
  cwd: workdir,
961
1188
  env,
962
1189
  stdio: ["ignore", "pipe", "pipe"],
963
1190
  detached: process.platform !== "win32",
964
1191
  });
965
1192
  const startedAt = Date.now();
966
- let stdout = "";
967
- let stderr = "";
1193
+ const stdout = new BashOutputBuffer();
1194
+ const stderr = new BashOutputBuffer();
968
1195
  let timedOut = false;
969
1196
  let settled = false;
970
1197
  let forceSettle;
@@ -1001,8 +1228,8 @@ class ArenaToolExecutor {
1001
1228
  reject(this.signal.reason || new Error("operation cancelled"));
1002
1229
  return;
1003
1230
  }
1004
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
1005
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
1231
+ const out = stdout.result();
1232
+ const err = stderr.result();
1006
1233
  resolve({
1007
1234
  status: timedOut ? "timeout" : signal ? "killed" : "completed",
1008
1235
  stderr: err.text,
@@ -1032,10 +1259,10 @@ class ArenaToolExecutor {
1032
1259
  };
1033
1260
  this.signal?.addEventListener("abort", abort, { once: true });
1034
1261
  child.stdout.on("data", (chunk) => {
1035
- stdout += chunk.toString("utf8");
1262
+ stdout.append(chunk);
1036
1263
  });
1037
1264
  child.stderr.on("data", (chunk) => {
1038
- stderr += chunk.toString("utf8");
1265
+ stderr.append(chunk);
1039
1266
  });
1040
1267
  child.once("error", (error) => {
1041
1268
  if (settled)
@@ -1055,12 +1282,9 @@ class ArenaToolExecutor {
1055
1282
  if (!(await stat(workdir)).isDirectory()) {
1056
1283
  throw new Error(`cwd is not a directory: ${cwd}`);
1057
1284
  }
1058
- const translatedCommand = this.workspaceContext
1059
- ? command
1060
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1061
1285
  return this.processManager.start({
1062
1286
  name,
1063
- command: translatedCommand,
1287
+ command,
1064
1288
  cwd: workdir,
1065
1289
  startupWaitSeconds,
1066
1290
  env: this.sandboxEnvironment(),
@@ -1069,10 +1293,12 @@ class ArenaToolExecutor {
1069
1293
  }
1070
1294
  async askUser(questions, toolCallId, context) {
1071
1295
  if (this.askUserHandler) {
1072
- return this.askUserHandler(toolCallId, questions, context ? {
1073
- ...context,
1074
- signal: this.signal || context.signal,
1075
- } : undefined);
1296
+ return this.askUserHandler(toolCallId, questions, context
1297
+ ? {
1298
+ ...context,
1299
+ signal: this.signal || context.signal,
1300
+ }
1301
+ : undefined);
1076
1302
  }
1077
1303
  // ask_user is not offered in non-interactive sessions; if the model still
1078
1304
  // emits it (e.g. resumed history or a hallucinated call), report an error
@@ -1152,23 +1378,25 @@ class ArenaToolExecutor {
1152
1378
  catch {
1153
1379
  return {
1154
1380
  status: "error",
1155
- message: `Failed to read workspace file: ${rawPath}`,
1381
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1156
1382
  };
1157
1383
  }
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)) {
1384
+ // Agent Arena replaces the first match and tolerates multiline whitespace
1385
+ // drift. Code Arena deliberately uses a separate unique-match policy.
1386
+ const edited = replaceFirstText(source, oldText, newText, {
1387
+ whitespaceInsensitiveMultiline: true,
1388
+ });
1389
+ if (edited === undefined) {
1165
1390
  return {
1166
1391
  status: "error",
1167
1392
  message: "Context not found. Read the file to verify the text exists.",
1168
1393
  };
1169
1394
  }
1170
1395
  try {
1171
- await writeFile(target, source.replace(oldText, newText), "utf8");
1396
+ // Replacement text is literal. String.replace(string, string) expands
1397
+ // $&, $`, $' and $$ sequences and therefore corrupts JavaScript/jQuery
1398
+ // snippets observed in production trajectories.
1399
+ await writeFile(target, edited, "utf8");
1172
1400
  }
1173
1401
  catch {
1174
1402
  return {
@@ -1179,10 +1407,11 @@ class ArenaToolExecutor {
1179
1407
  return { status: "success", message: `Edited ${rawPath}.` };
1180
1408
  }
1181
1409
  async readWorkspaceFile(rawPath, toolCallId) {
1182
- const target = await this.confinedPath(rawPath);
1410
+ let target;
1183
1411
  let fileSize;
1184
1412
  let sample;
1185
1413
  try {
1414
+ target = await this.confinedPath(rawPath);
1186
1415
  const metadata = await stat(target);
1187
1416
  if (!metadata.isFile())
1188
1417
  throw new Error("not a regular file");
@@ -1199,23 +1428,13 @@ class ArenaToolExecutor {
1199
1428
  }
1200
1429
  }
1201
1430
  catch {
1202
- // 逐字对齐生产错误文案,路径按调用方原样回显。
1203
1431
  return {
1204
1432
  status: "error",
1205
- message: `Failed to read workspace file: ${rawPath}`,
1433
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1206
1434
  };
1207
1435
  }
1208
1436
  const extension = path.extname(target).toLowerCase();
1209
1437
  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
1438
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
1220
1439
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
1221
1440
  }
@@ -1242,7 +1461,7 @@ class ArenaToolExecutor {
1242
1461
  catch {
1243
1462
  return {
1244
1463
  status: "error",
1245
- message: `Failed to read workspace file: ${rawPath}`,
1464
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1246
1465
  };
1247
1466
  }
1248
1467
  if (isStructuredDocument(target)) {
@@ -1270,12 +1489,13 @@ class ArenaToolExecutor {
1270
1489
  return result;
1271
1490
  }
1272
1491
  if (kind === "text") {
1273
- const content = decodeText(data);
1492
+ const text = readTextResult(data);
1274
1493
  return {
1275
1494
  kind: "text",
1276
- lines: content.split("\n").length,
1277
- size: Buffer.byteLength(content, "utf8"),
1278
- content,
1495
+ lines: data.reduce((count, byte) => count + (byte === 0x0a ? 1 : 0), 1),
1496
+ size: data.length,
1497
+ content: text.content,
1498
+ ...(text.truncated ? { truncated: true } : {}),
1279
1499
  };
1280
1500
  }
1281
1501
  const mimeType = mime.getType(target) ||
@@ -1325,11 +1545,22 @@ class ArenaToolExecutor {
1325
1545
  };
1326
1546
  }
1327
1547
  async writeWorkspaceFile(rawPath, content) {
1328
- let target;
1329
1548
  try {
1330
- target = await this.confinedPath(rawPath);
1549
+ const target = await this.confinedPath(rawPath);
1331
1550
  mkdirSync(path.dirname(target), { recursive: true });
1332
- await writeFile(target, content, "utf8");
1551
+ try {
1552
+ await writeFile(target, content, "utf8");
1553
+ }
1554
+ catch (error) {
1555
+ const code = error.code;
1556
+ if (!["EACCES", "EPERM"].includes(code || "") || !privilegedFileCreationEnabled({
1557
+ platform: process.platform, workspace: this.workspaceReal,
1558
+ docker: existsSync("/.dockerenv"), flag: process.env.ARENA_SANDBOX_PRIVILEGED_FILE_CREATION,
1559
+ uid: process.getuid?.(),
1560
+ }))
1561
+ throw error;
1562
+ await createSandboxFile(target, content);
1563
+ }
1333
1564
  }
1334
1565
  catch {
1335
1566
  return {
@@ -1352,18 +1583,21 @@ class ArenaToolExecutor {
1352
1583
  const slug = query
1353
1584
  .toLowerCase()
1354
1585
  .replace(/[^a-z0-9]+/g, "-")
1355
- .slice(0, 40);
1586
+ .replace(/^-+|-+$/g, "")
1587
+ .slice(0, 40) || "results";
1356
1588
  for (const value of results) {
1357
1589
  if (saved.length >= count)
1358
1590
  break;
1359
1591
  const item = record(value);
1360
1592
  const properties = record(item.properties);
1361
1593
  const thumbnail = record(item.thumbnail);
1362
- const sourceUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1363
- const thumbnailUrl = stringValue(thumbnail.src) || sourceUrl;
1364
- if (!sourceUrl)
1594
+ // Brave's result URL is the source page; properties.url is an image asset.
1595
+ const sourceUrl = stringValue(item.url);
1596
+ const imageUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1597
+ const thumbnailUrl = stringValue(thumbnail.src) || imageUrl;
1598
+ if (!imageUrl)
1365
1599
  continue;
1366
- const downloadUrls = [...new Set([sourceUrl, thumbnailUrl])];
1600
+ const downloadUrls = [...new Set([imageUrl, thumbnailUrl])];
1367
1601
  for (const downloadUrl of downloadUrls) {
1368
1602
  try {
1369
1603
  const imageResponse = await fetch(downloadUrl, {
@@ -1378,7 +1612,9 @@ class ArenaToolExecutor {
1378
1612
  .split(";")[0]
1379
1613
  ?.trim()
1380
1614
  .toLowerCase();
1381
- const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1615
+ const extension = normalizedType === "image/jpeg"
1616
+ ? ".jpg"
1617
+ : Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
1382
1618
  const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1383
1619
  mkdirSync(path.dirname(target), { recursive: true });
1384
1620
  await writeFile(target, await responseBytes(imageResponse));
@@ -1418,13 +1654,19 @@ class ArenaToolExecutor {
1418
1654
  }
1419
1655
  }
1420
1656
  async generateImage(rawPath, prompt, images) {
1657
+ if (!rawPath) {
1658
+ return { status: "error", message: "File path cannot be empty." };
1659
+ }
1421
1660
  const target = await this.confinedPath(rawPath);
1422
1661
  const extension = path.extname(target).toLowerCase();
1423
1662
  if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1424
- throw new Error("file_path must end in .jpg, .jpeg, or .png");
1663
+ return {
1664
+ status: "error",
1665
+ message: "File path must end in .jpg, .jpeg, or .png.",
1666
+ };
1425
1667
  }
1426
1668
  mkdirSync(path.dirname(target), { recursive: true });
1427
- const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
1669
+ const model = process.env.ARENA_IMAGE_MODEL || "gemini-3.1-flash-image";
1428
1670
  const references = [];
1429
1671
  for (const imagePath of images || []) {
1430
1672
  const source = await this.confinedPath(imagePath);
@@ -1459,7 +1701,7 @@ class ArenaToolExecutor {
1459
1701
  await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1460
1702
  return {
1461
1703
  status: "success",
1462
- file_path: this.relativePath(target),
1704
+ file_path: rawPath,
1463
1705
  };
1464
1706
  }
1465
1707
  async generateSpeech(rawPath, text, voiceId, language) {
@@ -1472,7 +1714,7 @@ class ArenaToolExecutor {
1472
1714
  ".opus": "opus",
1473
1715
  }[extension];
1474
1716
  if (!format) {
1475
- throw new Error("ModelRouter Doubao TTS output supports .mp3, .wav, .ogg, or .opus");
1717
+ throw new Error("ModelRouter TTS output supports .mp3, .wav, .ogg, or .opus");
1476
1718
  }
1477
1719
  mkdirSync(path.dirname(target), { recursive: true });
1478
1720
  const registered = this.voiceRegistry.resolve(voiceId);
@@ -1488,14 +1730,15 @@ class ArenaToolExecutor {
1488
1730
  const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
1489
1731
  await writeFile(target, await generateSpeechWithModelRouter({
1490
1732
  format,
1491
- model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
1733
+ model: registered.model,
1734
+ language: language || registered.language,
1492
1735
  signal: this.signal,
1493
1736
  text,
1494
1737
  voice,
1495
1738
  }));
1496
1739
  return {
1497
1740
  status: "success",
1498
- file_path: this.relativePath(target),
1741
+ file_path: rawPath,
1499
1742
  };
1500
1743
  }
1501
1744
  }
@@ -1520,6 +1763,9 @@ export class ToolExecutor {
1520
1763
  availableTools() {
1521
1764
  return this.implementation.availableTools();
1522
1765
  }
1766
+ inspect(call) {
1767
+ return this.implementation.inspect(call);
1768
+ }
1523
1769
  execute(call, context) {
1524
1770
  return this.implementation.execute(call, context);
1525
1771
  }