@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.
- package/.env.example +57 -14
- package/CHANGELOG.md +228 -0
- package/README.md +228 -50
- package/dist/agent.d.ts +28 -2
- package/dist/agent.js +341 -106
- package/dist/agent.js.map +1 -1
- package/dist/anthropic.js +23 -22
- package/dist/anthropic.js.map +1 -1
- package/dist/ask-user.js +2 -0
- package/dist/ask-user.js.map +1 -1
- package/dist/attachments.js +4 -3
- package/dist/attachments.js.map +1 -1
- package/dist/brave-search.js +1 -3
- package/dist/brave-search.js.map +1 -1
- package/dist/canonical-data.js +2 -2
- package/dist/canonical-data.js.map +1 -1
- package/dist/cli.js +184 -36
- package/dist/cli.js.map +1 -1
- package/dist/code-tools.d.ts +8 -2
- package/dist/code-tools.js +709 -117
- package/dist/code-tools.js.map +1 -1
- package/dist/compaction.d.ts +49 -3
- package/dist/compaction.js +382 -186
- package/dist/compaction.js.map +1 -1
- package/dist/config.d.ts +17 -2
- package/dist/config.js +47 -5
- package/dist/config.js.map +1 -1
- package/dist/fetch-page.js +9 -33
- package/dist/fetch-page.js.map +1 -1
- package/dist/file-edit.d.ts +8 -0
- package/dist/file-edit.js +63 -0
- package/dist/file-edit.js.map +1 -0
- package/dist/history.d.ts +16 -3
- package/dist/history.js +117 -19
- package/dist/history.js.map +1 -1
- package/dist/legacy-compaction.d.ts +20 -0
- package/dist/legacy-compaction.js +209 -0
- package/dist/legacy-compaction.js.map +1 -0
- package/dist/midturn-checkpoint-hook.d.ts +31 -0
- package/dist/midturn-checkpoint-hook.js +38 -0
- package/dist/midturn-checkpoint-hook.js.map +1 -0
- package/dist/mock.js +5 -0
- package/dist/mock.js.map +1 -1
- package/dist/mode.js +4 -2
- package/dist/mode.js.map +1 -1
- package/dist/model-router-media.d.ts +1 -0
- package/dist/model-router-media.js +71 -1
- package/dist/model-router-media.js.map +1 -1
- package/dist/openai-responses.js +2 -0
- package/dist/openai-responses.js.map +1 -1
- package/dist/process-manager.js +2 -1
- package/dist/process-manager.js.map +1 -1
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +6 -2
- package/dist/prompts.js.map +1 -1
- package/dist/protocol.d.ts +5 -1
- package/dist/protocol.js +24 -2
- package/dist/protocol.js.map +1 -1
- package/dist/provider.d.ts +12 -5
- package/dist/provider.js +60 -52
- package/dist/provider.js.map +1 -1
- package/dist/repetitive-output.d.ts +12 -0
- package/dist/repetitive-output.js +80 -0
- package/dist/repetitive-output.js.map +1 -0
- package/dist/replay-responses.d.ts +17 -0
- package/dist/replay-responses.js +100 -0
- package/dist/replay-responses.js.map +1 -0
- package/dist/routify-web-fetch.js +20 -7
- package/dist/routify-web-fetch.js.map +1 -1
- package/dist/sandbox-file-write.d.ts +10 -0
- package/dist/sandbox-file-write.js +58 -0
- package/dist/sandbox-file-write.js.map +1 -0
- package/dist/server.js +17 -5
- package/dist/server.js.map +1 -1
- package/dist/session-memory-prompts.d.ts +5 -0
- package/dist/session-memory-prompts.js +7 -0
- package/dist/session-memory-prompts.js.map +1 -0
- package/dist/snapshot-client.d.ts +10 -0
- package/dist/snapshot-client.js +39 -0
- package/dist/snapshot-client.js.map +1 -0
- package/dist/spec.js +18 -10
- package/dist/spec.js.map +1 -1
- package/dist/speech-audio.d.ts +9 -0
- package/dist/speech-audio.js +130 -0
- package/dist/speech-audio.js.map +1 -0
- package/dist/speech-model.d.ts +3 -0
- package/dist/speech-model.js +8 -0
- package/dist/speech-model.js.map +1 -0
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +378 -132
- package/dist/tools.js.map +1 -1
- package/dist/tui.js +13 -10
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +28 -3
- package/dist/voice-registry.d.ts +2 -0
- package/dist/voice-registry.js +10 -2
- package/dist/voice-registry.js.map +1 -1
- package/dist/web-fetch-output.d.ts +9 -0
- package/dist/web-fetch-output.js +38 -0
- package/dist/web-fetch-output.js.map +1 -0
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +11 -6
- package/dist/workspace.js.map +1 -1
- package/docs/BINARY_DISTRIBUTION.md +27 -16
- package/docs/MODES_AND_RELEASES.md +28 -5
- package/docs/SDK.md +5 -0
- package/modes/code-arena/capture-provenance.json +57 -0
- package/modes/code-arena/full-stack/system-prompt.txt +1 -1
- package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
- package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
- package/modes/code-arena/full-stack/tools.json +186 -186
- package/modes/code-arena/html/system-prompt.txt +1 -1
- package/modes/code-arena/html/tools.json +123 -123
- package/modes/code-arena/react/system-prompt.txt +4 -3
- package/modes/code-arena/react/template/package.json +4 -4
- package/modes/code-arena/react/template/src/App.tsx +1 -1
- package/modes/code-arena/react/template/src/main.tsx +1 -1
- package/modes/code-arena/react/template/tsconfig.json +6 -0
- package/modes/code-arena/react/template/vite.config.ts +1 -0
- package/modes/code-arena/react/tools.json +178 -184
- package/package.json +14 -4
- package/scripts/README.md +62 -0
- package/scripts/check-changelog.mjs +66 -0
- package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
- package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
- package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
- package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
- package/vendor/audio/darwin-arm64/manifest.json +39 -0
- package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
- package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
- package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
- package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
- package/vendor/audio/darwin-x64/manifest.json +39 -0
- package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
- package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
- package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
- package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
- package/vendor/audio/linux-arm64/manifest.json +39 -0
- package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
- package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
- package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
- package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
- package/vendor/audio/linux-x64/manifest.json +39 -0
- package/vendor/audio/sources/build-audio-encoder.sh +76 -0
- package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
- package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
- package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
- package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
- package/vendor/audio/sources/sources.lock.json +30 -0
- package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
- package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
- package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
- package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
- 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
|
|
3
|
-
import { open, readFile, realpath, stat, writeFile
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
641
|
-
|
|
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,
|
|
804
|
+
content: arenaJsonParsingError(name, parsed.invalidJsonText, parsed.error),
|
|
652
805
|
};
|
|
653
806
|
}
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
672
|
-
if (
|
|
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:
|
|
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
|
-
|
|
880
|
-
|
|
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 (
|
|
883
|
-
|
|
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 (
|
|
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 (
|
|
889
|
-
candidate = path.join(this.workspace, raw.slice(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
922
|
-
if (this.workspaceContext)
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
}
|
|
931
|
-
return
|
|
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
|
-
|
|
938
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
967
|
-
|
|
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 =
|
|
1005
|
-
const err =
|
|
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
|
|
1262
|
+
stdout.append(chunk);
|
|
1036
1263
|
});
|
|
1037
1264
|
child.stderr.on("data", (chunk) => {
|
|
1038
|
-
stderr
|
|
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
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1492
|
+
const text = readTextResult(data);
|
|
1274
1493
|
return {
|
|
1275
1494
|
kind: "text",
|
|
1276
|
-
lines:
|
|
1277
|
-
size:
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
1363
|
-
const
|
|
1364
|
-
|
|
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([
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
}
|