@qwen-code/arena-agent 0.0.10-dev.0 → 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 (158) hide show
  1. package/.env.example +57 -14
  2. package/CHANGELOG.md +228 -0
  3. package/README.md +240 -57
  4. package/dist/agent.d.ts +30 -2
  5. package/dist/agent.js +351 -104
  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 +197 -34
  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 +383 -170
  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 +35 -1
  34. package/dist/history.js +400 -16
  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 +18 -11
  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/sdk.js +9 -1
  74. package/dist/sdk.js.map +1 -1
  75. package/dist/server.js +127 -44
  76. package/dist/server.js.map +1 -1
  77. package/dist/session-memory-prompts.d.ts +5 -0
  78. package/dist/session-memory-prompts.js +7 -0
  79. package/dist/session-memory-prompts.js.map +1 -0
  80. package/dist/snapshot-client.d.ts +10 -0
  81. package/dist/snapshot-client.js +39 -0
  82. package/dist/snapshot-client.js.map +1 -0
  83. package/dist/spec.js +18 -10
  84. package/dist/spec.js.map +1 -1
  85. package/dist/speech-audio.d.ts +9 -0
  86. package/dist/speech-audio.js +130 -0
  87. package/dist/speech-audio.js.map +1 -0
  88. package/dist/speech-model.d.ts +3 -0
  89. package/dist/speech-model.js +8 -0
  90. package/dist/speech-model.js.map +1 -0
  91. package/dist/tools.d.ts +5 -0
  92. package/dist/tools.js +744 -121
  93. package/dist/tools.js.map +1 -1
  94. package/dist/tui.js +20 -12
  95. package/dist/tui.js.map +1 -1
  96. package/dist/types.d.ts +28 -3
  97. package/dist/voice-registry.d.ts +2 -0
  98. package/dist/voice-registry.js +10 -2
  99. package/dist/voice-registry.js.map +1 -1
  100. package/dist/web/app.js +17 -3
  101. package/dist/web/app.js.map +1 -1
  102. package/dist/web-fetch-output.d.ts +9 -0
  103. package/dist/web-fetch-output.js +38 -0
  104. package/dist/web-fetch-output.js.map +1 -0
  105. package/dist/workspace.d.ts +2 -0
  106. package/dist/workspace.js +11 -6
  107. package/dist/workspace.js.map +1 -1
  108. package/docs/BINARY_DISTRIBUTION.md +27 -16
  109. package/docs/MODES_AND_RELEASES.md +28 -5
  110. package/docs/SDK.md +10 -4
  111. package/modes/code-arena/capture-provenance.json +57 -0
  112. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  113. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  114. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  115. package/modes/code-arena/full-stack/tools.json +186 -186
  116. package/modes/code-arena/html/system-prompt.txt +1 -1
  117. package/modes/code-arena/html/tools.json +123 -123
  118. package/modes/code-arena/react/system-prompt.txt +4 -3
  119. package/modes/code-arena/react/template/package.json +4 -4
  120. package/modes/code-arena/react/template/src/App.tsx +1 -1
  121. package/modes/code-arena/react/template/src/main.tsx +1 -1
  122. package/modes/code-arena/react/template/tsconfig.json +6 -0
  123. package/modes/code-arena/react/template/vite.config.ts +1 -0
  124. package/modes/code-arena/react/tools.json +178 -184
  125. package/package.json +15 -4
  126. package/scripts/README.md +62 -0
  127. package/scripts/check-changelog.mjs +66 -0
  128. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  129. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  130. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  131. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  132. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  133. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  134. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  135. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  136. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  137. package/vendor/audio/darwin-x64/manifest.json +39 -0
  138. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  139. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  140. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  141. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  142. package/vendor/audio/linux-arm64/manifest.json +39 -0
  143. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  144. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  145. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  146. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  147. package/vendor/audio/linux-x64/manifest.json +39 -0
  148. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  149. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  150. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  151. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  152. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  153. package/vendor/audio/sources/sources.lock.json +30 -0
  154. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  155. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  156. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  157. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  158. 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
  }
@@ -349,6 +396,182 @@ function json(value) {
349
396
  function errorMessage(error) {
350
397
  return error instanceof Error ? error.message : String(error);
351
398
  }
399
+ function jsonPointerPath(instancePath) {
400
+ if (!instancePath)
401
+ return [];
402
+ return instancePath
403
+ .slice(1)
404
+ .split("/")
405
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
406
+ .map((part) => (/^(0|[1-9]\d*)$/.test(part) ? Number(part) : part));
407
+ }
408
+ function valueAtPath(value, pathParts) {
409
+ let current = value;
410
+ for (const part of pathParts) {
411
+ if (current === null || typeof current !== "object")
412
+ return undefined;
413
+ current = current[part];
414
+ }
415
+ return current;
416
+ }
417
+ function zodTypeName(type) {
418
+ return type === "integer" ? "number" : type;
419
+ }
420
+ function receivedType(value) {
421
+ if (value === null)
422
+ return "null";
423
+ if (Array.isArray(value))
424
+ return "array";
425
+ return typeof value;
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
+ }
433
+ function arenaValidationIssue(error, value) {
434
+ const pathParts = jsonPointerPath(error.instancePath);
435
+ if (error.keyword === "required") {
436
+ const missingProperty = String(error.params.missingProperty);
437
+ const parentSchema = error.parentSchema;
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");
443
+ return {
444
+ expected,
445
+ code: "invalid_type",
446
+ path: [...pathParts, missingProperty],
447
+ message: `Invalid input: expected ${expected}, received undefined`,
448
+ };
449
+ }
450
+ if (error.keyword === "type") {
451
+ const expected = zodTypeName(String(error.params.type));
452
+ return {
453
+ expected,
454
+ code: "invalid_type",
455
+ path: pathParts,
456
+ message: `Invalid input: expected ${expected}, received ` +
457
+ receivedType(valueAtPath(value, pathParts)),
458
+ };
459
+ }
460
+ if (error.keyword === "maxLength" || error.keyword === "minLength") {
461
+ const tooBig = error.keyword === "maxLength";
462
+ const limitKey = tooBig ? "maximum" : "minimum";
463
+ const limit = Number(error.params.limit);
464
+ return {
465
+ origin: "string",
466
+ code: tooBig ? "too_big" : "too_small",
467
+ [limitKey]: limit,
468
+ inclusive: true,
469
+ path: pathParts,
470
+ message: `${tooBig ? "Too big" : "Too small"}: expected string to have ` +
471
+ `${tooBig ? "<=" : ">="}${limit} characters`,
472
+ };
473
+ }
474
+ if (error.keyword === "maximum" || error.keyword === "minimum") {
475
+ const tooBig = error.keyword === "maximum";
476
+ const limitKey = tooBig ? "maximum" : "minimum";
477
+ const limit = Number(error.params.limit);
478
+ return {
479
+ origin: "number",
480
+ code: tooBig ? "too_big" : "too_small",
481
+ [limitKey]: limit,
482
+ inclusive: true,
483
+ path: pathParts,
484
+ message: `${tooBig ? "Too big" : "Too small"}: expected number to be ` +
485
+ `${tooBig ? "<=" : ">="}${limit}`,
486
+ };
487
+ }
488
+ if (error.keyword === "maxItems" || error.keyword === "minItems") {
489
+ const tooBig = error.keyword === "maxItems";
490
+ const limitKey = tooBig ? "maximum" : "minimum";
491
+ const limit = Number(error.params.limit);
492
+ return {
493
+ origin: "array",
494
+ code: tooBig ? "too_big" : "too_small",
495
+ [limitKey]: limit,
496
+ inclusive: true,
497
+ path: pathParts,
498
+ message: `${tooBig ? "Too big" : "Too small"}: expected array to have ` +
499
+ `${tooBig ? "<=" : ">="}${limit} items`,
500
+ };
501
+ }
502
+ if (error.keyword === "enum") {
503
+ return arenaEnumIssue(error.params.allowedValues, pathParts);
504
+ }
505
+ if (error.keyword === "format") {
506
+ return {
507
+ ...(error.params.format === "uri" ? {} : { origin: "string" }),
508
+ code: "invalid_format",
509
+ format: error.params.format === "uri" ? "url" : error.params.format,
510
+ path: pathParts,
511
+ message: error.params.format === "uri" ? "Invalid URL" : "Invalid string",
512
+ };
513
+ }
514
+ return {
515
+ code: "custom",
516
+ path: pathParts,
517
+ message: error.message || "Invalid input",
518
+ };
519
+ }
520
+ function arenaValidationIssues(error, value) {
521
+ const issues = [arenaValidationIssue(error, value)];
522
+ if (error.keyword !== "type" || error.params.type !== "array")
523
+ return issues;
524
+ const pathParts = jsonPointerPath(error.instancePath);
525
+ const received = valueAtPath(value, pathParts);
526
+ if (typeof received !== "string")
527
+ return issues;
528
+ const parentSchema = error.parentSchema;
529
+ const maximum = parentSchema?.maxItems;
530
+ if (typeof maximum === "number" && received.length > maximum) {
531
+ issues.push({
532
+ origin: "string",
533
+ code: "too_big",
534
+ maximum,
535
+ inclusive: true,
536
+ path: pathParts,
537
+ message: `Too big: expected string to have <=${maximum} characters`,
538
+ });
539
+ }
540
+ const minimum = parentSchema?.minItems;
541
+ if (typeof minimum === "number" && received.length < minimum) {
542
+ issues.push({
543
+ origin: "string",
544
+ code: "too_small",
545
+ minimum,
546
+ inclusive: true,
547
+ path: pathParts,
548
+ message: `Too small: expected string to have >=${minimum} characters`,
549
+ });
550
+ }
551
+ return issues;
552
+ }
553
+ function arenaValidationError(toolName, value, errors, customIssues = []) {
554
+ const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value)).concat(customIssues);
555
+ return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
556
+ `${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
557
+ }
558
+ function arenaJsonParsingError(toolName, text, error) {
559
+ return (`Invalid input for tool ${toolName}: JSON parsing failed: Text: ${text}.\n` +
560
+ `Error message: ${errorMessage(error)}`);
561
+ }
562
+ function unavailableToolError(toolName, availableToolNames) {
563
+ return (`Model tried to call unavailable tool '${toolName}'. Available tools: ` +
564
+ `${[...availableToolNames, "compact"].join(", ")}.`);
565
+ }
566
+ function prefixedFailure(prefix, message) {
567
+ return message.startsWith(prefix) ? message : `${prefix}${message}`;
568
+ }
569
+ function fetchPageFailure(message) {
570
+ const status = message.match(/(?:fetch_page|Firecrawl|Jina Reader) failed \((\d{3})\b/i)?.[1];
571
+ return status
572
+ ? `Failed to fetch page (HTTP ${status})`
573
+ : "Failed to fetch page";
574
+ }
352
575
  function displayByteSize(value) {
353
576
  if (value >= 1024 * 1024) {
354
577
  return `${(value / (1024 * 1024)).toFixed(1)} MB`;
@@ -374,17 +597,27 @@ class ArenaToolExecutor {
374
597
  config;
375
598
  workspace;
376
599
  workspaceReal;
600
+ tmpReal;
601
+ varTmpReal;
377
602
  validators = new Map();
378
603
  pendingVisualContent = new Map();
379
604
  askUserHandler;
380
605
  imageInputFallbackClient;
381
606
  signal;
382
607
  workspaceContext;
608
+ nativeSandboxPaths;
383
609
  pageReader;
384
610
  routifyPageReader;
385
611
  processManager;
386
612
  voiceRegistry;
387
- ajv = new Ajv({ allErrors: true, strict: false });
613
+ ajv = new Ajv({
614
+ allErrors: true,
615
+ strict: false,
616
+ verbose: true,
617
+ // The captured runtime uses Zod object parsing: unknown object keys are
618
+ // stripped before dispatch instead of being emitted as validation issues.
619
+ removeAdditional: "all",
620
+ });
388
621
  // ask_user needs a handler: TUI/WebUI collect real user input, while
389
622
  // headless `-p` may delegate to a configured remote User Agent. add_voice
390
623
  // remains available without one and deterministically registers a fallback.
@@ -394,13 +627,24 @@ class ArenaToolExecutor {
394
627
  this.workspace = path.resolve(config.workspace);
395
628
  mkdirSync(this.workspace, { recursive: true });
396
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");
397
637
  this.processManager = processManagerForWorkspace(this.workspaceReal);
398
638
  this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
399
639
  this.askUserHandler = options.askUserHandler;
400
640
  this.askUserEnabled = Boolean(options.askUserHandler);
401
641
  if (config.imageInputFallbackModel?.trim()) {
642
+ const connection = imageInputFallbackConnection(config);
402
643
  this.imageInputFallbackClient =
403
- options.modelClient || new HttpModelClient(config);
644
+ options.imageInputFallbackClient ||
645
+ (connection
646
+ ? new HttpModelClient(config, { connection })
647
+ : options.modelClient || new HttpModelClient(config));
404
648
  }
405
649
  this.signal = options.signal;
406
650
  this.workspaceContext = options.workspaceContext;
@@ -409,6 +653,13 @@ class ArenaToolExecutor {
409
653
  signal: options.signal,
410
654
  });
411
655
  addFormats(this.ajv);
656
+ // WHATWG URL parsing matches the captured fetch_page behavior. ajv-formats'
657
+ // RFC URI checker rejects a recorded URL containing `|` in its query even
658
+ // though Arena accepted and fetched it successfully.
659
+ this.ajv.addFormat("uri", {
660
+ type: "string",
661
+ validate: (value) => URL.canParse(value),
662
+ });
412
663
  for (const definition of toolDefinitions(this.workspaceContext)) {
413
664
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
414
665
  }
@@ -424,6 +675,102 @@ class ArenaToolExecutor {
424
675
  const disallowed = new Set(this.config.disallowedTools || []);
425
676
  return definitions.filter((definition) => !disallowed.has(definition.function.name));
426
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
+ }
427
774
  async execute(call, context) {
428
775
  const callId = String(call.id || "");
429
776
  const name = String(call.function?.name || "");
@@ -431,23 +778,91 @@ class ArenaToolExecutor {
431
778
  try {
432
779
  // 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
433
780
  // 之前短路,否则命令会照常 spawn 直到自然结束。
434
- this.signal?.throwIfAborted();
781
+ if (this.signal?.aborted) {
782
+ return {
783
+ role: "tool",
784
+ tool_call_id: callId,
785
+ content: json({ status: "aborted" }),
786
+ };
787
+ }
435
788
  if (this.config.disallowedTools?.includes(name)) {
436
789
  throw new Error(`tool is disallowed: ${name}`);
437
790
  }
438
- const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
439
- ? call.function.arguments
440
- : "{}");
441
791
  const validate = this.validators.get(name);
442
- if (!validate)
443
- throw new Error(`unknown tool: ${name}`);
444
- if (!validate(argumentsValue)) {
445
- const errors = (validate.errors || [])
446
- .map((error) => `${error.instancePath || "/"} ${error.message}`)
447
- .join("; ");
448
- throw new Error(`invalid arguments: ${errors}`);
792
+ if (!validate) {
793
+ return {
794
+ role: "tool",
795
+ tool_call_id: callId,
796
+ content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
797
+ };
798
+ }
799
+ const parsed = this.parseArguments(call);
800
+ if (parsed.error !== undefined) {
801
+ return {
802
+ role: "tool",
803
+ tool_call_id: callId,
804
+ content: arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
805
+ };
806
+ }
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);
820
+ }
821
+ const replayContent = typeof replayed.output === "string"
822
+ ? replayed.output
823
+ : json(replayed.output);
824
+ if (replayContent.length > toolResultCharacterLimit) {
825
+ return {
826
+ role: "tool",
827
+ tool_call_id: callId,
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
+ }),
834
+ };
835
+ }
836
+ return { role: "tool", tool_call_id: callId, content: replayContent };
837
+ }
838
+ const validated = this.validateArguments(name, argumentsValue, validate);
839
+ if (validated.validationMessage !== undefined) {
840
+ return {
841
+ role: "tool",
842
+ tool_call_id: callId,
843
+ content: validated.validationMessage,
844
+ };
845
+ }
846
+ const validatedArguments = validated.value;
847
+ const startedAt = Date.now();
848
+ let result;
849
+ try {
850
+ result = await this.dispatch(name, validatedArguments, callId, context);
851
+ }
852
+ catch (error) {
853
+ if (error instanceof AskUserHandlerError) {
854
+ return {
855
+ role: "tool",
856
+ tool_call_id: callId,
857
+ content: json({ status: "error", message: error.message }),
858
+ };
859
+ }
860
+ return {
861
+ role: "tool",
862
+ tool_call_id: callId,
863
+ content: json(this.runtimeFailureResult(name, validatedArguments, error, startedAt)),
864
+ };
449
865
  }
450
- const result = await this.dispatch(name, argumentsValue, callId, context);
451
866
  const content = typeof result === "string" ? result : json(result);
452
867
  if (content.length > toolResultCharacterLimit) {
453
868
  return {
@@ -468,13 +883,6 @@ class ArenaToolExecutor {
468
883
  };
469
884
  }
470
885
  catch (error) {
471
- if (error instanceof AskUserHandlerError) {
472
- return {
473
- role: "tool",
474
- tool_call_id: callId,
475
- content: json({ status: "error", message: error.message }),
476
- };
477
- }
478
886
  return {
479
887
  role: "tool",
480
888
  tool_call_id: callId,
@@ -485,6 +893,82 @@ class ArenaToolExecutor {
485
893
  };
486
894
  }
487
895
  }
896
+ runtimeFailureResult(name, values, error, startedAt) {
897
+ if (this.signal?.aborted)
898
+ return { status: "aborted" };
899
+ const message = errorMessage(error);
900
+ const durationMs = Date.now() - startedAt;
901
+ if (name === "bash") {
902
+ return {
903
+ status: "shell_error",
904
+ stderr: message,
905
+ stdout: "",
906
+ exit_code: null,
907
+ duration_ms: durationMs,
908
+ stderr_truncated: false,
909
+ stdout_truncated: false,
910
+ };
911
+ }
912
+ if (name === "start_process") {
913
+ const cwd = String(values.cwd || "/home/user");
914
+ const code = error?.code;
915
+ const logTail = code === "ENOENT"
916
+ ? `[invalid_argument] cwd '${cwd}' does not exist`
917
+ : message;
918
+ return {
919
+ status: "shell_error",
920
+ process_id: null,
921
+ pid: null,
922
+ exit_code: null,
923
+ log_tail: logTail,
924
+ listening_ports: [],
925
+ new_ports: [],
926
+ duration_ms: durationMs,
927
+ };
928
+ }
929
+ if (name === "get_process_output") {
930
+ return {
931
+ status: "shell_error",
932
+ log_tail: message,
933
+ exit_code: null,
934
+ listening_ports: [],
935
+ };
936
+ }
937
+ if (name === "stop_process") {
938
+ return { status: "shell_error", log_tail: message };
939
+ }
940
+ if (name === "fetch_page") {
941
+ return { status: "error", error: fetchPageFailure(message) };
942
+ }
943
+ if (name === "add_voice") {
944
+ return {
945
+ status: "error",
946
+ message: prefixedFailure("Failed to audition voices: ", message),
947
+ };
948
+ }
949
+ if (name === "generate_image") {
950
+ return {
951
+ status: "error",
952
+ message: prefixedFailure("Failed to generate image: ", message),
953
+ };
954
+ }
955
+ if (name === "generate_speech") {
956
+ return {
957
+ status: "error",
958
+ message: prefixedFailure("Failed to generate speech: ", message),
959
+ };
960
+ }
961
+ if (name === "image_search") {
962
+ return {
963
+ status: "error",
964
+ message: prefixedFailure("Image search failed: ", message),
965
+ };
966
+ }
967
+ if (name === "present_file") {
968
+ return { status: "error", message: "Error calling present file tool" };
969
+ }
970
+ return { status: "error", message };
971
+ }
488
972
  takeVisualMessage(toolCallIds) {
489
973
  // 生产契约:同一组 tool call 的全部图片汇入一条 user message,
490
974
  // 由一个 arena-system-message 文本 part 领头。
@@ -503,6 +987,8 @@ class ArenaToolExecutor {
503
987
  };
504
988
  }
505
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).
506
992
  switch (name) {
507
993
  case "bash":
508
994
  return this.bash(String(values.command), String(values.cwd ||
@@ -555,32 +1041,76 @@ class ArenaToolExecutor {
555
1041
  throw new Error(`unknown tool: ${name}`);
556
1042
  }
557
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
+ }
558
1059
  async confinedPath(raw) {
559
1060
  if (!raw)
560
1061
  throw new Error("path must not be empty");
561
1062
  let candidate;
562
- if (raw === "/home/user") {
563
- 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);
564
1078
  }
565
- else if (raw.startsWith("/home/user/")) {
566
- 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);
1085
+ }
1086
+ else if (this.nativeSandboxPaths && path.posix.isAbsolute(raw)) {
1087
+ confinementRoot = "/";
1088
+ confinementRootReal = "/";
1089
+ candidate = raw;
567
1090
  }
568
- else if (!this.workspaceContext && raw === "~") {
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") {
569
1102
  candidate = this.workspace;
570
1103
  }
571
- else if (!this.workspaceContext && raw.startsWith("~/")) {
572
- 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));
573
1106
  }
574
1107
  else {
575
- const defaultCwd = this.workspaceContext
576
- ? path.resolve(this.workspaceContext.cwd)
577
- : this.workspace;
1108
+ const defaultCwd = path.resolve(this.workspaceContext.cwd);
578
1109
  candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
579
1110
  }
580
1111
  const absolute = path.resolve(candidate);
581
- const lexicalRelative = path.relative(this.workspace, absolute);
582
- if (lexicalRelative.startsWith("..") ||
583
- path.isAbsolute(lexicalRelative)) {
1112
+ const lexicalRelative = path.relative(confinementRoot, absolute);
1113
+ if (lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
584
1114
  throw new Error(`path is outside the workspace: ${raw}`);
585
1115
  }
586
1116
  let ancestor = absolute;
@@ -592,33 +1122,45 @@ class ArenaToolExecutor {
592
1122
  }
593
1123
  const ancestorReal = await realpath(ancestor);
594
1124
  const resolved = path.resolve(ancestorReal, path.relative(ancestor, absolute));
595
- const realRelative = path.relative(this.workspaceReal, resolved);
1125
+ const realRelative = path.relative(confinementRootReal, resolved);
596
1126
  if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
597
1127
  throw new Error(`path is outside the workspace: ${raw}`);
598
1128
  }
599
1129
  return resolved;
600
1130
  }
601
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
+ }
602
1142
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
603
1143
  }
604
- virtualizeBashOutput(value) {
605
- if (this.workspaceContext)
606
- return value;
607
- let virtualized = value;
608
- const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
609
- .filter((root) => root !== path.parse(root).root)
610
- .sort((left, right) => right.length - left.length);
611
- for (const root of workspaceRoots) {
612
- virtualized = virtualized.replaceAll(root, "/home/user");
613
- }
614
- 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;
615
1155
  }
616
1156
  sandboxEnvironment() {
617
1157
  const env = {
618
1158
  ...process.env,
619
1159
  ARENA_WORKSPACE: this.workspace,
620
- LANG: "C.UTF-8",
621
- 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",
622
1164
  };
623
1165
  delete env.FORCE_COLOR;
624
1166
  delete env.CLICOLOR_FORCE;
@@ -626,28 +1168,30 @@ class ArenaToolExecutor {
626
1168
  }
627
1169
  async bash(command, cwd, timeoutSeconds) {
628
1170
  const workdir = await this.confinedPath(cwd);
629
- 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()) {
630
1179
  throw new Error(`cwd is not a directory: ${cwd}`);
631
1180
  }
632
- // Legacy callers without runtime context retain the captured /home/user
633
- // contract. Runtime-aware callers execute and report their actual paths.
634
- const translatedCommand = this.workspaceContext
635
- ? command
636
- : command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
637
1181
  return new Promise((resolve, reject) => {
638
1182
  const env = this.sandboxEnvironment();
639
1183
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
640
1184
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
641
1185
  // 必须杀整组,并在宽限期后强制结算兜底。
642
- const child = spawn("/bin/bash", ["-lc", translatedCommand], {
1186
+ const child = spawn("/bin/bash", ["-lc", command], {
643
1187
  cwd: workdir,
644
1188
  env,
645
1189
  stdio: ["ignore", "pipe", "pipe"],
646
1190
  detached: process.platform !== "win32",
647
1191
  });
648
1192
  const startedAt = Date.now();
649
- let stdout = "";
650
- let stderr = "";
1193
+ const stdout = new BashOutputBuffer();
1194
+ const stderr = new BashOutputBuffer();
651
1195
  let timedOut = false;
652
1196
  let settled = false;
653
1197
  let forceSettle;
@@ -684,8 +1228,8 @@ class ArenaToolExecutor {
684
1228
  reject(this.signal.reason || new Error("operation cancelled"));
685
1229
  return;
686
1230
  }
687
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
688
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
1231
+ const out = stdout.result();
1232
+ const err = stderr.result();
689
1233
  resolve({
690
1234
  status: timedOut ? "timeout" : signal ? "killed" : "completed",
691
1235
  stderr: err.text,
@@ -715,10 +1259,10 @@ class ArenaToolExecutor {
715
1259
  };
716
1260
  this.signal?.addEventListener("abort", abort, { once: true });
717
1261
  child.stdout.on("data", (chunk) => {
718
- stdout += chunk.toString("utf8");
1262
+ stdout.append(chunk);
719
1263
  });
720
1264
  child.stderr.on("data", (chunk) => {
721
- stderr += chunk.toString("utf8");
1265
+ stderr.append(chunk);
722
1266
  });
723
1267
  child.once("error", (error) => {
724
1268
  if (settled)
@@ -738,12 +1282,9 @@ class ArenaToolExecutor {
738
1282
  if (!(await stat(workdir)).isDirectory()) {
739
1283
  throw new Error(`cwd is not a directory: ${cwd}`);
740
1284
  }
741
- const translatedCommand = this.workspaceContext
742
- ? command
743
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
744
1285
  return this.processManager.start({
745
1286
  name,
746
- command: translatedCommand,
1287
+ command,
747
1288
  cwd: workdir,
748
1289
  startupWaitSeconds,
749
1290
  env: this.sandboxEnvironment(),
@@ -752,10 +1293,12 @@ class ArenaToolExecutor {
752
1293
  }
753
1294
  async askUser(questions, toolCallId, context) {
754
1295
  if (this.askUserHandler) {
755
- return this.askUserHandler(toolCallId, questions, context ? {
756
- ...context,
757
- signal: this.signal || context.signal,
758
- } : undefined);
1296
+ return this.askUserHandler(toolCallId, questions, context
1297
+ ? {
1298
+ ...context,
1299
+ signal: this.signal || context.signal,
1300
+ }
1301
+ : undefined);
759
1302
  }
760
1303
  // ask_user is not offered in non-interactive sessions; if the model still
761
1304
  // emits it (e.g. resumed history or a hallucinated call), report an error
@@ -820,28 +1363,55 @@ class ArenaToolExecutor {
820
1363
  };
821
1364
  }
822
1365
  async editFile(rawPath, oldText, newText) {
823
- const target = await this.confinedPath(rawPath);
824
- const source = await readFile(target, "utf8");
825
- // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
826
- // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
827
- // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
828
- // 注意:spec/tools.json 的 edit_file description(与生产逐字一致,受
829
- // T1(c) 门禁保护)声称支持 fuzzy matching——那是上游自身的描述与行为
830
- // 不一致,按实测行为对齐,描述不改。见 TRAJECTORY_FIDELITY.md §5。
831
- if (!source.includes(oldText)) {
1366
+ let target;
1367
+ let source;
1368
+ try {
1369
+ target = await this.confinedPath(rawPath);
1370
+ if (!(await stat(target)).isFile()) {
1371
+ return {
1372
+ status: "error",
1373
+ message: `File not found: ${rawPath}`,
1374
+ };
1375
+ }
1376
+ source = await readFile(target, "utf8");
1377
+ }
1378
+ catch {
1379
+ return {
1380
+ status: "error",
1381
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
1382
+ };
1383
+ }
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) {
832
1390
  return {
833
1391
  status: "error",
834
1392
  message: "Context not found. Read the file to verify the text exists.",
835
1393
  };
836
1394
  }
837
- await writeFile(target, source.replace(oldText, newText), "utf8");
1395
+ try {
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");
1400
+ }
1401
+ catch {
1402
+ return {
1403
+ status: "error",
1404
+ message: `Failed to write workspace file: ${rawPath}`,
1405
+ };
1406
+ }
838
1407
  return { status: "success", message: `Edited ${rawPath}.` };
839
1408
  }
840
1409
  async readWorkspaceFile(rawPath, toolCallId) {
841
- const target = await this.confinedPath(rawPath);
1410
+ let target;
842
1411
  let fileSize;
843
1412
  let sample;
844
1413
  try {
1414
+ target = await this.confinedPath(rawPath);
845
1415
  const metadata = await stat(target);
846
1416
  if (!metadata.isFile())
847
1417
  throw new Error("not a regular file");
@@ -858,26 +1428,25 @@ class ArenaToolExecutor {
858
1428
  }
859
1429
  }
860
1430
  catch {
861
- // 逐字对齐生产错误文案,路径按调用方原样回显。
862
1431
  return {
863
1432
  status: "error",
864
- message: `Failed to read workspace file: ${rawPath}`,
1433
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
865
1434
  };
866
1435
  }
867
1436
  const extension = path.extname(target).toLowerCase();
868
1437
  const kind = workspaceFileKind(target, sample);
869
- if (kind === "text" && fileSize > textFileByteLimit) {
870
- return {
871
- status: "error",
872
- message: `File content (${displayByteSize(fileSize)}) exceeds the maximum allowed size ` +
873
- `(${displayByteSize(textFileByteLimit)}). Use bash with sed/head/tail to read ` +
874
- "specific portions of the file, or grep/rg to search for specific content " +
875
- "instead of reading the whole file.",
876
- };
877
- }
878
1438
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
879
1439
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
880
1440
  }
1441
+ // Current Arena v2 captures consistently expose local PDFs as binary
1442
+ // metadata. PDF text extraction belongs to fetch_page, not read_file.
1443
+ if (extension === ".pdf") {
1444
+ return {
1445
+ kind: "unsupported",
1446
+ mediaType: "application/pdf",
1447
+ size: fileSize,
1448
+ };
1449
+ }
881
1450
  const mainModelSupportsImages = kind === "image" ? supportsImageInput(this.config) : false;
882
1451
  const fallbackModel = mainModelSupportsImages
883
1452
  ? undefined
@@ -892,7 +1461,7 @@ class ArenaToolExecutor {
892
1461
  catch {
893
1462
  return {
894
1463
  status: "error",
895
- message: `Failed to read workspace file: ${rawPath}`,
1464
+ message: `Failed to read workspace file: ${this.displayWorkspacePath(rawPath)}`,
896
1465
  };
897
1466
  }
898
1467
  if (isStructuredDocument(target)) {
@@ -920,12 +1489,13 @@ class ArenaToolExecutor {
920
1489
  return result;
921
1490
  }
922
1491
  if (kind === "text") {
923
- const content = decodeText(data);
1492
+ const text = readTextResult(data);
924
1493
  return {
925
1494
  kind: "text",
926
- lines: content.split("\n").length,
927
- size: Buffer.byteLength(content, "utf8"),
928
- 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 } : {}),
929
1499
  };
930
1500
  }
931
1501
  const mimeType = mime.getType(target) ||
@@ -975,9 +1545,29 @@ class ArenaToolExecutor {
975
1545
  };
976
1546
  }
977
1547
  async writeWorkspaceFile(rawPath, content) {
978
- const target = await this.confinedPath(rawPath);
979
- mkdirSync(path.dirname(target), { recursive: true });
980
- await writeFile(target, content, "utf8");
1548
+ try {
1549
+ const target = await this.confinedPath(rawPath);
1550
+ mkdirSync(path.dirname(target), { recursive: true });
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
+ }
1564
+ }
1565
+ catch {
1566
+ return {
1567
+ status: "error",
1568
+ message: `Failed to write workspace file: ${rawPath}`,
1569
+ };
1570
+ }
981
1571
  return { status: "success" };
982
1572
  }
983
1573
  async webSearch(query, depth) {
@@ -993,18 +1583,21 @@ class ArenaToolExecutor {
993
1583
  const slug = query
994
1584
  .toLowerCase()
995
1585
  .replace(/[^a-z0-9]+/g, "-")
996
- .slice(0, 40);
1586
+ .replace(/^-+|-+$/g, "")
1587
+ .slice(0, 40) || "results";
997
1588
  for (const value of results) {
998
1589
  if (saved.length >= count)
999
1590
  break;
1000
1591
  const item = record(value);
1001
1592
  const properties = record(item.properties);
1002
1593
  const thumbnail = record(item.thumbnail);
1003
- const sourceUrl = stringValue(properties.url) || stringValue(thumbnail.src);
1004
- const thumbnailUrl = stringValue(thumbnail.src) || sourceUrl;
1005
- 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)
1006
1599
  continue;
1007
- const downloadUrls = [...new Set([sourceUrl, thumbnailUrl])];
1600
+ const downloadUrls = [...new Set([imageUrl, thumbnailUrl])];
1008
1601
  for (const downloadUrl of downloadUrls) {
1009
1602
  try {
1010
1603
  const imageResponse = await fetch(downloadUrl, {
@@ -1019,7 +1612,9 @@ class ArenaToolExecutor {
1019
1612
  .split(";")[0]
1020
1613
  ?.trim()
1021
1614
  .toLowerCase();
1022
- 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";
1023
1618
  const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
1024
1619
  mkdirSync(path.dirname(target), { recursive: true });
1025
1620
  await writeFile(target, await responseBytes(imageResponse));
@@ -1038,22 +1633,40 @@ class ArenaToolExecutor {
1038
1633
  }
1039
1634
  }
1040
1635
  }
1636
+ if (!saved.length) {
1637
+ return {
1638
+ status: "error",
1639
+ message: "Image search failed: Strict safe search filtered all results for this query. " +
1640
+ "Try a different or more specific query.",
1641
+ };
1642
+ }
1041
1643
  return { status: "success", results: saved };
1042
1644
  }
1043
1645
  async presentFile(rawPath) {
1044
- const target = await this.confinedPath(rawPath);
1045
- if (!(await stat(target)).isFile())
1046
- throw new Error(rawPath);
1047
- return { path: rawPath, status: "success" };
1646
+ try {
1647
+ const target = await this.confinedPath(rawPath);
1648
+ if (!(await stat(target)).isFile())
1649
+ throw new Error(rawPath);
1650
+ return { path: rawPath, status: "success" };
1651
+ }
1652
+ catch {
1653
+ return { status: "error", message: "Error calling present file tool" };
1654
+ }
1048
1655
  }
1049
1656
  async generateImage(rawPath, prompt, images) {
1657
+ if (!rawPath) {
1658
+ return { status: "error", message: "File path cannot be empty." };
1659
+ }
1050
1660
  const target = await this.confinedPath(rawPath);
1051
1661
  const extension = path.extname(target).toLowerCase();
1052
1662
  if (![".jpg", ".jpeg", ".png"].includes(extension)) {
1053
- 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
+ };
1054
1667
  }
1055
1668
  mkdirSync(path.dirname(target), { recursive: true });
1056
- 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";
1057
1670
  const references = [];
1058
1671
  for (const imagePath of images || []) {
1059
1672
  const source = await this.confinedPath(imagePath);
@@ -1062,7 +1675,13 @@ class ArenaToolExecutor {
1062
1675
  if (!mimeType?.startsWith("image/")) {
1063
1676
  throw new Error(`unsupported reference image format: ${imagePath}`);
1064
1677
  }
1065
- const bytes = await readFile(source);
1678
+ let bytes;
1679
+ try {
1680
+ bytes = await readFile(source);
1681
+ }
1682
+ catch {
1683
+ throw new Error(`Failed to read workspace file: ${imagePath}`);
1684
+ }
1066
1685
  if (bytes.length > 20 * 1024 * 1024) {
1067
1686
  throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
1068
1687
  }
@@ -1082,7 +1701,7 @@ class ArenaToolExecutor {
1082
1701
  await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
1083
1702
  return {
1084
1703
  status: "success",
1085
- file_path: this.relativePath(target),
1704
+ file_path: rawPath,
1086
1705
  };
1087
1706
  }
1088
1707
  async generateSpeech(rawPath, text, voiceId, language) {
@@ -1095,7 +1714,7 @@ class ArenaToolExecutor {
1095
1714
  ".opus": "opus",
1096
1715
  }[extension];
1097
1716
  if (!format) {
1098
- 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");
1099
1718
  }
1100
1719
  mkdirSync(path.dirname(target), { recursive: true });
1101
1720
  const registered = this.voiceRegistry.resolve(voiceId);
@@ -1111,14 +1730,15 @@ class ArenaToolExecutor {
1111
1730
  const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
1112
1731
  await writeFile(target, await generateSpeechWithModelRouter({
1113
1732
  format,
1114
- model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
1733
+ model: registered.model,
1734
+ language: language || registered.language,
1115
1735
  signal: this.signal,
1116
1736
  text,
1117
1737
  voice,
1118
1738
  }));
1119
1739
  return {
1120
1740
  status: "success",
1121
- file_path: this.relativePath(target),
1741
+ file_path: rawPath,
1122
1742
  };
1123
1743
  }
1124
1744
  }
@@ -1143,6 +1763,9 @@ export class ToolExecutor {
1143
1763
  availableTools() {
1144
1764
  return this.implementation.availableTools();
1145
1765
  }
1766
+ inspect(call) {
1767
+ return this.implementation.inspect(call);
1768
+ }
1146
1769
  execute(call, context) {
1147
1770
  return this.implementation.execute(call, context);
1148
1771
  }