@qwen-code/arena-agent 0.0.9-image-input-fallback.0 → 0.0.10-dev.1
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 +34 -13
- package/README.md +168 -60
- package/dist/agent.d.ts +6 -2
- package/dist/agent.js +155 -21
- package/dist/agent.js.map +1 -1
- package/dist/anthropic.d.ts +1 -0
- package/dist/anthropic.js +78 -19
- package/dist/anthropic.js.map +1 -1
- package/dist/canonical-data.js +2 -2
- package/dist/canonical-data.js.map +1 -1
- package/dist/cli.js +65 -19
- package/dist/cli.js.map +1 -1
- package/dist/code-tools.js +18 -161
- package/dist/code-tools.js.map +1 -1
- package/dist/compaction.js +32 -9
- package/dist/compaction.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.js +53 -1
- package/dist/config.js.map +1 -1
- package/dist/history.d.ts +24 -2
- package/dist/history.js +306 -15
- package/dist/history.js.map +1 -1
- package/dist/image.d.ts +2 -0
- package/dist/image.js +88 -7
- package/dist/image.js.map +1 -1
- package/dist/model-capabilities.js +12 -2
- package/dist/model-capabilities.js.map +1 -1
- package/dist/model-diagnostics.js +11 -3
- package/dist/model-diagnostics.js.map +1 -1
- package/dist/model-router-media.d.ts +26 -0
- package/dist/model-router-media.js +274 -0
- package/dist/model-router-media.js.map +1 -0
- package/dist/openai-responses.d.ts +28 -0
- package/dist/openai-responses.js +473 -0
- package/dist/openai-responses.js.map +1 -0
- package/dist/process-manager.d.ts +38 -0
- package/dist/process-manager.js +492 -0
- package/dist/process-manager.js.map +1 -0
- package/dist/prompts.js +3 -1
- package/dist/prompts.js.map +1 -1
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +259 -14
- package/dist/provider.js.map +1 -1
- package/dist/sdk.js +9 -1
- package/dist/sdk.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +149 -59
- package/dist/server.js.map +1 -1
- package/dist/spec.js +15 -1
- package/dist/spec.js.map +1 -1
- package/dist/tools.js +568 -268
- package/dist/tools.js.map +1 -1
- package/dist/tui.d.ts +2 -0
- package/dist/tui.js +22 -13
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/voice-registry.d.ts +30 -0
- package/dist/voice-registry.js +82 -0
- package/dist/voice-registry.js.map +1 -0
- package/dist/web/app.js +25 -3
- package/dist/web/app.js.map +1 -1
- package/docs/SDK.md +9 -7
- package/package.json +8 -2
- package/web/styles.css +6 -0
- package/dist/audit.d.ts +0 -14
- package/dist/audit.js +0 -31
- package/dist/audit.js.map +0 -1
- package/dist/dashscope.d.ts +0 -5
- package/dist/dashscope.js +0 -55
- package/dist/dashscope.js.map +0 -1
- package/dist/fetch-page/aparat.d.ts +0 -30
- package/dist/fetch-page/aparat.js +0 -209
- package/dist/fetch-page/aparat.js.map +0 -1
- package/dist/fetch-page/json-path.d.ts +0 -12
- package/dist/fetch-page/json-path.js +0 -111
- package/dist/fetch-page/json-path.js.map +0 -1
- package/dist/fetch-page/sites.d.ts +0 -26
- package/dist/fetch-page/sites.js +0 -39
- package/dist/fetch-page/sites.js.map +0 -1
- package/dist/fetch-page/text.d.ts +0 -5
- package/dist/fetch-page/text.js +0 -38
- package/dist/fetch-page/text.js.map +0 -1
- package/dist/fetch-page/types.d.ts +0 -17
- package/dist/fetch-page/types.js +0 -3
- package/dist/fetch-page/types.js.map +0 -1
- package/dist/fetch-page/youtube.d.ts +0 -35
- package/dist/fetch-page/youtube.js +0 -282
- package/dist/fetch-page/youtube.js.map +0 -1
- package/dist/web-search.d.ts +0 -5
- package/dist/web-search.js +0 -465
- package/dist/web-search.js.map +0 -1
package/dist/tools.js
CHANGED
|
@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, realpathSync, } from "node:fs";
|
|
3
3
|
import { open, readFile, realpath, stat, writeFile, } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { setTimeout as delay } from "node:timers/promises";
|
|
6
5
|
import { Ajv } from "ajv";
|
|
7
6
|
import addFormats from "ajv-formats";
|
|
8
7
|
import { detect as detectEncoding } from "chardet";
|
|
@@ -14,12 +13,15 @@ import { CodeToolExecutor } from "./code-tools.js";
|
|
|
14
13
|
import { supportsImageInput } from "./config.js";
|
|
15
14
|
import { extractStructuredDocument, isStructuredDocument, } from "./document-reader.js";
|
|
16
15
|
import { FetchPageReader } from "./fetch-page.js";
|
|
17
|
-
import { prepareImageForModel } from "./image.js";
|
|
16
|
+
import { generatedImageBytesForExtension, prepareImageForModel, } from "./image.js";
|
|
18
17
|
import { imageTextFromFallbackModel } from "./image-input-fallback.js";
|
|
19
18
|
import { isCodeArenaMode } from "./mode.js";
|
|
19
|
+
import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
|
|
20
|
+
import { processManagerForWorkspace, } from "./process-manager.js";
|
|
20
21
|
import { HttpModelClient } from "./provider.js";
|
|
21
22
|
import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
|
|
22
23
|
import { toolDefinitions } from "./spec.js";
|
|
24
|
+
import { voiceRegistryForWorkspace, } from "./voice-registry.js";
|
|
23
25
|
const knownTextApplicationMimeTypes = new Set([
|
|
24
26
|
"application/ecmascript",
|
|
25
27
|
"application/javascript",
|
|
@@ -347,6 +349,180 @@ function json(value) {
|
|
|
347
349
|
function errorMessage(error) {
|
|
348
350
|
return error instanceof Error ? error.message : String(error);
|
|
349
351
|
}
|
|
352
|
+
function jsonPointerPath(instancePath) {
|
|
353
|
+
if (!instancePath)
|
|
354
|
+
return [];
|
|
355
|
+
return instancePath
|
|
356
|
+
.slice(1)
|
|
357
|
+
.split("/")
|
|
358
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
|
|
359
|
+
.map((part) => (/^(0|[1-9]\d*)$/.test(part) ? Number(part) : part));
|
|
360
|
+
}
|
|
361
|
+
function valueAtPath(value, pathParts) {
|
|
362
|
+
let current = value;
|
|
363
|
+
for (const part of pathParts) {
|
|
364
|
+
if (current === null || typeof current !== "object")
|
|
365
|
+
return undefined;
|
|
366
|
+
current = current[part];
|
|
367
|
+
}
|
|
368
|
+
return current;
|
|
369
|
+
}
|
|
370
|
+
function zodTypeName(type) {
|
|
371
|
+
return type === "integer" ? "number" : type;
|
|
372
|
+
}
|
|
373
|
+
function receivedType(value) {
|
|
374
|
+
if (value === null)
|
|
375
|
+
return "null";
|
|
376
|
+
if (Array.isArray(value))
|
|
377
|
+
return "array";
|
|
378
|
+
return typeof value;
|
|
379
|
+
}
|
|
380
|
+
function arenaValidationIssue(error, value) {
|
|
381
|
+
const pathParts = jsonPointerPath(error.instancePath);
|
|
382
|
+
if (error.keyword === "required") {
|
|
383
|
+
const missingProperty = String(error.params.missingProperty);
|
|
384
|
+
const parentSchema = error.parentSchema;
|
|
385
|
+
const expected = zodTypeName(parentSchema?.properties?.[missingProperty]?.type || "unknown");
|
|
386
|
+
return {
|
|
387
|
+
expected,
|
|
388
|
+
code: "invalid_type",
|
|
389
|
+
path: [...pathParts, missingProperty],
|
|
390
|
+
message: `Invalid input: expected ${expected}, received undefined`,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (error.keyword === "type") {
|
|
394
|
+
const expected = zodTypeName(String(error.params.type));
|
|
395
|
+
return {
|
|
396
|
+
expected,
|
|
397
|
+
code: "invalid_type",
|
|
398
|
+
path: pathParts,
|
|
399
|
+
message: `Invalid input: expected ${expected}, received ` +
|
|
400
|
+
receivedType(valueAtPath(value, pathParts)),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (error.keyword === "maxLength" || error.keyword === "minLength") {
|
|
404
|
+
const tooBig = error.keyword === "maxLength";
|
|
405
|
+
const limitKey = tooBig ? "maximum" : "minimum";
|
|
406
|
+
const limit = Number(error.params.limit);
|
|
407
|
+
return {
|
|
408
|
+
origin: "string",
|
|
409
|
+
code: tooBig ? "too_big" : "too_small",
|
|
410
|
+
[limitKey]: limit,
|
|
411
|
+
inclusive: true,
|
|
412
|
+
path: pathParts,
|
|
413
|
+
message: `${tooBig ? "Too big" : "Too small"}: expected string to have ` +
|
|
414
|
+
`${tooBig ? "<=" : ">="}${limit} characters`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (error.keyword === "maximum" || error.keyword === "minimum") {
|
|
418
|
+
const tooBig = error.keyword === "maximum";
|
|
419
|
+
const limitKey = tooBig ? "maximum" : "minimum";
|
|
420
|
+
const limit = Number(error.params.limit);
|
|
421
|
+
return {
|
|
422
|
+
origin: "number",
|
|
423
|
+
code: tooBig ? "too_big" : "too_small",
|
|
424
|
+
[limitKey]: limit,
|
|
425
|
+
inclusive: true,
|
|
426
|
+
path: pathParts,
|
|
427
|
+
message: `${tooBig ? "Too big" : "Too small"}: expected number to be ` +
|
|
428
|
+
`${tooBig ? "<=" : ">="}${limit}`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
if (error.keyword === "maxItems" || error.keyword === "minItems") {
|
|
432
|
+
const tooBig = error.keyword === "maxItems";
|
|
433
|
+
const limitKey = tooBig ? "maximum" : "minimum";
|
|
434
|
+
const limit = Number(error.params.limit);
|
|
435
|
+
return {
|
|
436
|
+
origin: "array",
|
|
437
|
+
code: tooBig ? "too_big" : "too_small",
|
|
438
|
+
[limitKey]: limit,
|
|
439
|
+
inclusive: true,
|
|
440
|
+
path: pathParts,
|
|
441
|
+
message: `${tooBig ? "Too big" : "Too small"}: expected array to have ` +
|
|
442
|
+
`${tooBig ? "<=" : ">="}${limit} items`,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
if (error.keyword === "enum") {
|
|
446
|
+
const values = error.params.allowedValues;
|
|
447
|
+
return {
|
|
448
|
+
code: "invalid_value",
|
|
449
|
+
values,
|
|
450
|
+
path: pathParts,
|
|
451
|
+
message: `Invalid option: expected one of ${values
|
|
452
|
+
.map((item) => JSON.stringify(item))
|
|
453
|
+
.join("|")}`,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (error.keyword === "format") {
|
|
457
|
+
return {
|
|
458
|
+
origin: "string",
|
|
459
|
+
code: "invalid_format",
|
|
460
|
+
format: error.params.format === "uri" ? "url" : error.params.format,
|
|
461
|
+
path: pathParts,
|
|
462
|
+
message: error.params.format === "uri" ? "Invalid URL" : "Invalid string",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
code: "custom",
|
|
467
|
+
path: pathParts,
|
|
468
|
+
message: error.message || "Invalid input",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
function arenaValidationIssues(error, value) {
|
|
472
|
+
const issues = [arenaValidationIssue(error, value)];
|
|
473
|
+
if (error.keyword !== "type" || error.params.type !== "array")
|
|
474
|
+
return issues;
|
|
475
|
+
const pathParts = jsonPointerPath(error.instancePath);
|
|
476
|
+
const received = valueAtPath(value, pathParts);
|
|
477
|
+
if (typeof received !== "string")
|
|
478
|
+
return issues;
|
|
479
|
+
const parentSchema = error.parentSchema;
|
|
480
|
+
const maximum = parentSchema?.maxItems;
|
|
481
|
+
if (typeof maximum === "number" && received.length > maximum) {
|
|
482
|
+
issues.push({
|
|
483
|
+
origin: "string",
|
|
484
|
+
code: "too_big",
|
|
485
|
+
maximum,
|
|
486
|
+
inclusive: true,
|
|
487
|
+
path: pathParts,
|
|
488
|
+
message: `Too big: expected string to have <=${maximum} characters`,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
const minimum = parentSchema?.minItems;
|
|
492
|
+
if (typeof minimum === "number" && received.length < minimum) {
|
|
493
|
+
issues.push({
|
|
494
|
+
origin: "string",
|
|
495
|
+
code: "too_small",
|
|
496
|
+
minimum,
|
|
497
|
+
inclusive: true,
|
|
498
|
+
path: pathParts,
|
|
499
|
+
message: `Too small: expected string to have >=${minimum} characters`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return issues;
|
|
503
|
+
}
|
|
504
|
+
function arenaValidationError(toolName, value, errors) {
|
|
505
|
+
const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value));
|
|
506
|
+
return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
|
|
507
|
+
`${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
|
|
508
|
+
}
|
|
509
|
+
function arenaJsonParsingError(toolName, text, error) {
|
|
510
|
+
return (`Invalid input for tool ${toolName}: JSON parsing failed: Text: ${text}.\n` +
|
|
511
|
+
`Error message: ${errorMessage(error)}`);
|
|
512
|
+
}
|
|
513
|
+
function unavailableToolError(toolName, availableToolNames) {
|
|
514
|
+
return (`Model tried to call unavailable tool '${toolName}'. Available tools: ` +
|
|
515
|
+
`${[...availableToolNames, "compact"].join(", ")}.`);
|
|
516
|
+
}
|
|
517
|
+
function prefixedFailure(prefix, message) {
|
|
518
|
+
return message.startsWith(prefix) ? message : `${prefix}${message}`;
|
|
519
|
+
}
|
|
520
|
+
function fetchPageFailure(message) {
|
|
521
|
+
const status = message.match(/(?:fetch_page|Firecrawl|Jina Reader) failed \((\d{3})\b/i)?.[1];
|
|
522
|
+
return status
|
|
523
|
+
? `Failed to fetch page (HTTP ${status})`
|
|
524
|
+
: "Failed to fetch page";
|
|
525
|
+
}
|
|
350
526
|
function displayByteSize(value) {
|
|
351
527
|
if (value >= 1024 * 1024) {
|
|
352
528
|
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
@@ -361,58 +537,6 @@ function record(value) {
|
|
|
361
537
|
function stringValue(value) {
|
|
362
538
|
return typeof value === "string" ? value : "";
|
|
363
539
|
}
|
|
364
|
-
function dashScopeApiKey() {
|
|
365
|
-
const apiKey = process.env.DASHSCOPE_API_KEY;
|
|
366
|
-
if (!apiKey) {
|
|
367
|
-
throw new Error("DashScope tools require DASHSCOPE_API_KEY");
|
|
368
|
-
}
|
|
369
|
-
return apiKey;
|
|
370
|
-
}
|
|
371
|
-
function dashScopeRoot() {
|
|
372
|
-
return (process.env.DASHSCOPE_API_BASE ||
|
|
373
|
-
process.env.DASHSCOPE_BASE_URL ||
|
|
374
|
-
"https://dashscope.aliyuncs.com")
|
|
375
|
-
.replace(/\/+$/, "")
|
|
376
|
-
.replace(/\/(?:api\/v1|compatible-mode\/v1)$/i, "");
|
|
377
|
-
}
|
|
378
|
-
function dashScopeUrl(protocol, pathname) {
|
|
379
|
-
const prefix = protocol === "api" ? "/api/v1" : "/compatible-mode/v1";
|
|
380
|
-
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
381
|
-
return `${dashScopeRoot()}${prefix}${normalized}`;
|
|
382
|
-
}
|
|
383
|
-
async function dashScopeFetch(protocol, pathname, init, label) {
|
|
384
|
-
const headers = new Headers(init.headers);
|
|
385
|
-
headers.set("authorization", `Bearer ${dashScopeApiKey()}`);
|
|
386
|
-
const response = await fetch(dashScopeUrl(protocol, pathname), {
|
|
387
|
-
...init,
|
|
388
|
-
headers,
|
|
389
|
-
});
|
|
390
|
-
if (response.ok)
|
|
391
|
-
return response;
|
|
392
|
-
const body = await response.text();
|
|
393
|
-
let detail = body;
|
|
394
|
-
try {
|
|
395
|
-
const payload = record(JSON.parse(body));
|
|
396
|
-
detail =
|
|
397
|
-
stringValue(payload.message) ||
|
|
398
|
-
stringValue(record(payload.error).message) ||
|
|
399
|
-
body;
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
// Keep the response text as the diagnostic.
|
|
403
|
-
}
|
|
404
|
-
throw new Error(`${label} failed (${response.status} ${response.statusText}): ${detail.slice(0, 2000)}`);
|
|
405
|
-
}
|
|
406
|
-
async function dashScopeJson(protocol, pathname, init, label) {
|
|
407
|
-
const response = await dashScopeFetch(protocol, pathname, init, label);
|
|
408
|
-
return record(await response.json());
|
|
409
|
-
}
|
|
410
|
-
function responseMessage(payload) {
|
|
411
|
-
return (stringValue(payload.message) ||
|
|
412
|
-
stringValue(record(payload.output).message) ||
|
|
413
|
-
stringValue(payload.code) ||
|
|
414
|
-
"unknown provider error");
|
|
415
|
-
}
|
|
416
540
|
async function responseBytes(response, limit = 4 * 1024 * 1024) {
|
|
417
541
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
418
542
|
if (bytes.length > limit) {
|
|
@@ -432,16 +556,27 @@ class ArenaToolExecutor {
|
|
|
432
556
|
workspaceContext;
|
|
433
557
|
pageReader;
|
|
434
558
|
routifyPageReader;
|
|
435
|
-
|
|
559
|
+
processManager;
|
|
560
|
+
voiceRegistry;
|
|
561
|
+
ajv = new Ajv({
|
|
562
|
+
allErrors: true,
|
|
563
|
+
strict: false,
|
|
564
|
+
verbose: true,
|
|
565
|
+
// The captured runtime uses Zod object parsing: unknown object keys are
|
|
566
|
+
// stripped before dispatch instead of being emitted as validation issues.
|
|
567
|
+
removeAdditional: "all",
|
|
568
|
+
});
|
|
436
569
|
// ask_user needs a handler: TUI/WebUI collect real user input, while
|
|
437
|
-
// headless `-p` may delegate to a configured remote User Agent.
|
|
438
|
-
//
|
|
570
|
+
// headless `-p` may delegate to a configured remote User Agent. add_voice
|
|
571
|
+
// remains available without one and deterministically registers a fallback.
|
|
439
572
|
askUserEnabled;
|
|
440
573
|
constructor(config, options = {}) {
|
|
441
574
|
this.config = config;
|
|
442
575
|
this.workspace = path.resolve(config.workspace);
|
|
443
576
|
mkdirSync(this.workspace, { recursive: true });
|
|
444
577
|
this.workspaceReal = realpathSync(this.workspace);
|
|
578
|
+
this.processManager = processManagerForWorkspace(this.workspaceReal);
|
|
579
|
+
this.voiceRegistry = voiceRegistryForWorkspace(this.workspaceReal);
|
|
445
580
|
this.askUserHandler = options.askUserHandler;
|
|
446
581
|
this.askUserEnabled = Boolean(options.askUserHandler);
|
|
447
582
|
if (config.imageInputFallbackModel?.trim()) {
|
|
@@ -455,13 +590,20 @@ class ArenaToolExecutor {
|
|
|
455
590
|
signal: options.signal,
|
|
456
591
|
});
|
|
457
592
|
addFormats(this.ajv);
|
|
593
|
+
// WHATWG URL parsing matches the captured fetch_page behavior. ajv-formats'
|
|
594
|
+
// RFC URI checker rejects a recorded URL containing `|` in its query even
|
|
595
|
+
// though Arena accepted and fetched it successfully.
|
|
596
|
+
this.ajv.addFormat("uri", {
|
|
597
|
+
type: "string",
|
|
598
|
+
validate: (value) => URL.canParse(value),
|
|
599
|
+
});
|
|
458
600
|
for (const definition of toolDefinitions(this.workspaceContext)) {
|
|
459
601
|
this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
|
|
460
602
|
}
|
|
461
603
|
}
|
|
462
604
|
// The tool schemas offered to the model for this session. Identical to the
|
|
463
605
|
// canonical trace set, adjusted for the selected workspace runtime and minus
|
|
464
|
-
// ask_user when no handler is wired.
|
|
606
|
+
// ask_user when no handler is wired. add_voice stays available headlessly.
|
|
465
607
|
availableTools() {
|
|
466
608
|
let definitions = toolDefinitions(this.workspaceContext);
|
|
467
609
|
if (!this.askUserEnabled) {
|
|
@@ -477,23 +619,82 @@ class ArenaToolExecutor {
|
|
|
477
619
|
try {
|
|
478
620
|
// 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
|
|
479
621
|
// 之前短路,否则命令会照常 spawn 直到自然结束。
|
|
480
|
-
this.signal?.
|
|
622
|
+
if (this.signal?.aborted) {
|
|
623
|
+
return {
|
|
624
|
+
role: "tool",
|
|
625
|
+
tool_call_id: callId,
|
|
626
|
+
content: json({ status: "aborted" }),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
481
629
|
if (this.config.disallowedTools?.includes(name)) {
|
|
482
630
|
throw new Error(`tool is disallowed: ${name}`);
|
|
483
631
|
}
|
|
484
|
-
const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
|
|
485
|
-
? call.function.arguments
|
|
486
|
-
: "{}");
|
|
487
632
|
const validate = this.validators.get(name);
|
|
488
|
-
if (!validate)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
.map((
|
|
493
|
-
|
|
494
|
-
|
|
633
|
+
if (!validate) {
|
|
634
|
+
return {
|
|
635
|
+
role: "tool",
|
|
636
|
+
tool_call_id: callId,
|
|
637
|
+
content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const argumentsText = typeof call.function?.arguments === "string"
|
|
641
|
+
? call.function.arguments
|
|
642
|
+
: "{}";
|
|
643
|
+
let argumentsValue;
|
|
644
|
+
try {
|
|
645
|
+
argumentsValue = JSON.parse(argumentsText);
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
return {
|
|
649
|
+
role: "tool",
|
|
650
|
+
tool_call_id: callId,
|
|
651
|
+
content: arenaJsonParsingError(name, argumentsText, error),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const malformedArguments = argumentsValue !== null &&
|
|
655
|
+
typeof argumentsValue === "object" &&
|
|
656
|
+
!Array.isArray(argumentsValue)
|
|
657
|
+
? argumentsValue.__malformedToolArguments
|
|
658
|
+
: undefined;
|
|
659
|
+
if (typeof malformedArguments === "string") {
|
|
660
|
+
try {
|
|
661
|
+
JSON.parse(malformedArguments);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
return {
|
|
665
|
+
role: "tool",
|
|
666
|
+
tool_call_id: callId,
|
|
667
|
+
content: arenaJsonParsingError(name, malformedArguments, error),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const validatedArguments = structuredClone(argumentsValue);
|
|
672
|
+
if (!validate(validatedArguments)) {
|
|
673
|
+
return {
|
|
674
|
+
role: "tool",
|
|
675
|
+
tool_call_id: callId,
|
|
676
|
+
content: arenaValidationError(name, argumentsValue, validate.errors),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const startedAt = Date.now();
|
|
680
|
+
let result;
|
|
681
|
+
try {
|
|
682
|
+
result = await this.dispatch(name, validatedArguments, callId, context);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (error instanceof AskUserHandlerError) {
|
|
686
|
+
return {
|
|
687
|
+
role: "tool",
|
|
688
|
+
tool_call_id: callId,
|
|
689
|
+
content: json({ status: "error", message: error.message }),
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
role: "tool",
|
|
694
|
+
tool_call_id: callId,
|
|
695
|
+
content: json(this.runtimeFailureResult(name, validatedArguments, error, startedAt)),
|
|
696
|
+
};
|
|
495
697
|
}
|
|
496
|
-
const result = await this.dispatch(name, argumentsValue, callId, context);
|
|
497
698
|
const content = typeof result === "string" ? result : json(result);
|
|
498
699
|
if (content.length > toolResultCharacterLimit) {
|
|
499
700
|
return {
|
|
@@ -514,13 +715,6 @@ class ArenaToolExecutor {
|
|
|
514
715
|
};
|
|
515
716
|
}
|
|
516
717
|
catch (error) {
|
|
517
|
-
if (error instanceof AskUserHandlerError) {
|
|
518
|
-
return {
|
|
519
|
-
role: "tool",
|
|
520
|
-
tool_call_id: callId,
|
|
521
|
-
content: json({ status: "error", message: error.message }),
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
718
|
return {
|
|
525
719
|
role: "tool",
|
|
526
720
|
tool_call_id: callId,
|
|
@@ -531,6 +725,83 @@ class ArenaToolExecutor {
|
|
|
531
725
|
};
|
|
532
726
|
}
|
|
533
727
|
}
|
|
728
|
+
runtimeFailureResult(name, values, error, startedAt) {
|
|
729
|
+
if (this.signal?.aborted)
|
|
730
|
+
return { status: "aborted" };
|
|
731
|
+
const message = errorMessage(error);
|
|
732
|
+
const durationMs = Date.now() - startedAt;
|
|
733
|
+
if (name === "bash") {
|
|
734
|
+
const stderr = this.virtualizeBashOutput(message);
|
|
735
|
+
return {
|
|
736
|
+
status: "shell_error",
|
|
737
|
+
stderr,
|
|
738
|
+
stdout: "",
|
|
739
|
+
exit_code: null,
|
|
740
|
+
duration_ms: durationMs,
|
|
741
|
+
stderr_truncated: false,
|
|
742
|
+
stdout_truncated: false,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
if (name === "start_process") {
|
|
746
|
+
const cwd = String(values.cwd || "/home/user");
|
|
747
|
+
const code = error?.code;
|
|
748
|
+
const logTail = code === "ENOENT"
|
|
749
|
+
? `[invalid_argument] cwd '${cwd}' does not exist`
|
|
750
|
+
: message;
|
|
751
|
+
return {
|
|
752
|
+
status: "shell_error",
|
|
753
|
+
process_id: null,
|
|
754
|
+
pid: null,
|
|
755
|
+
exit_code: null,
|
|
756
|
+
log_tail: logTail,
|
|
757
|
+
listening_ports: [],
|
|
758
|
+
new_ports: [],
|
|
759
|
+
duration_ms: durationMs,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
if (name === "get_process_output") {
|
|
763
|
+
return {
|
|
764
|
+
status: "shell_error",
|
|
765
|
+
log_tail: message,
|
|
766
|
+
exit_code: null,
|
|
767
|
+
listening_ports: [],
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
if (name === "stop_process") {
|
|
771
|
+
return { status: "shell_error", log_tail: message };
|
|
772
|
+
}
|
|
773
|
+
if (name === "fetch_page") {
|
|
774
|
+
return { status: "error", error: fetchPageFailure(message) };
|
|
775
|
+
}
|
|
776
|
+
if (name === "add_voice") {
|
|
777
|
+
return {
|
|
778
|
+
status: "error",
|
|
779
|
+
message: prefixedFailure("Failed to audition voices: ", message),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (name === "generate_image") {
|
|
783
|
+
return {
|
|
784
|
+
status: "error",
|
|
785
|
+
message: prefixedFailure("Failed to generate image: ", message),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (name === "generate_speech") {
|
|
789
|
+
return {
|
|
790
|
+
status: "error",
|
|
791
|
+
message: prefixedFailure("Failed to generate speech: ", message),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (name === "image_search") {
|
|
795
|
+
return {
|
|
796
|
+
status: "error",
|
|
797
|
+
message: prefixedFailure("Image search failed: ", message),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if (name === "present_file") {
|
|
801
|
+
return { status: "error", message: "Error calling present file tool" };
|
|
802
|
+
}
|
|
803
|
+
return { status: "error", message };
|
|
804
|
+
}
|
|
534
805
|
takeVisualMessage(toolCallIds) {
|
|
535
806
|
// 生产契约:同一组 tool call 的全部图片汇入一条 user message,
|
|
536
807
|
// 由一个 arena-system-message 文本 part 领头。
|
|
@@ -557,6 +828,8 @@ class ArenaToolExecutor {
|
|
|
557
828
|
: "/home/user")), Number(values.timeout || 30));
|
|
558
829
|
case "ask_user":
|
|
559
830
|
return this.askUser(values.questions, toolCallId, context);
|
|
831
|
+
case "add_voice":
|
|
832
|
+
return this.addVoice(String(values.text), String(values.language), values.voice_identity, toolCallId, context);
|
|
560
833
|
case "edit_file":
|
|
561
834
|
return this.editFile(String(values.path), String(values.old_text), String(values.new_text));
|
|
562
835
|
case "read_file":
|
|
@@ -573,10 +846,28 @@ class ArenaToolExecutor {
|
|
|
573
846
|
return this.imageSearch(String(values.query), Number(values.count));
|
|
574
847
|
case "present_file":
|
|
575
848
|
return this.presentFile(String(values.path));
|
|
849
|
+
case "stop_process":
|
|
850
|
+
return this.processManager.stop(String(values.process_id));
|
|
851
|
+
case "start_process":
|
|
852
|
+
return this.startProcess(String(values.name || ""), String(values.command), String(values.cwd ||
|
|
853
|
+
(this.workspaceContext
|
|
854
|
+
? path.resolve(this.workspaceContext.cwd)
|
|
855
|
+
: "/home/user")), Number(values.startup_wait || 5));
|
|
576
856
|
case "generate_image":
|
|
577
857
|
return this.generateImage(String(values.file_path), String(values.prompt), values.images);
|
|
578
858
|
case "generate_speech":
|
|
579
|
-
return this.generateSpeech(String(values.file_path), String(values.text), String(values.
|
|
859
|
+
return this.generateSpeech(String(values.file_path), String(values.text), String(values.voice_id), values.language === undefined ? undefined : String(values.language));
|
|
860
|
+
case "get_process_output":
|
|
861
|
+
return this.processManager.get({
|
|
862
|
+
processId: String(values.process_id),
|
|
863
|
+
tailLines: Number(values.tail_lines || 200),
|
|
864
|
+
waitFor: values.wait_for,
|
|
865
|
+
waitPattern: values.wait_pattern === undefined
|
|
866
|
+
? undefined
|
|
867
|
+
: String(values.wait_pattern),
|
|
868
|
+
waitTimeoutSeconds: Number(values.wait_timeout || 60),
|
|
869
|
+
signal: this.signal,
|
|
870
|
+
});
|
|
580
871
|
default:
|
|
581
872
|
throw new Error(`unknown tool: ${name}`);
|
|
582
873
|
}
|
|
@@ -585,12 +876,15 @@ class ArenaToolExecutor {
|
|
|
585
876
|
if (!raw)
|
|
586
877
|
throw new Error("path must not be empty");
|
|
587
878
|
let candidate;
|
|
588
|
-
if (
|
|
879
|
+
if (raw === "/home/user") {
|
|
589
880
|
candidate = this.workspace;
|
|
590
881
|
}
|
|
591
|
-
else if (
|
|
882
|
+
else if (raw.startsWith("/home/user/")) {
|
|
592
883
|
candidate = path.join(this.workspace, raw.slice("/home/user/".length));
|
|
593
884
|
}
|
|
885
|
+
else if (!this.workspaceContext && raw === "~") {
|
|
886
|
+
candidate = this.workspace;
|
|
887
|
+
}
|
|
594
888
|
else if (!this.workspaceContext && raw.startsWith("~/")) {
|
|
595
889
|
candidate = path.join(this.workspace, raw.slice(2));
|
|
596
890
|
}
|
|
@@ -636,6 +930,17 @@ class ArenaToolExecutor {
|
|
|
636
930
|
}
|
|
637
931
|
return virtualized;
|
|
638
932
|
}
|
|
933
|
+
sandboxEnvironment() {
|
|
934
|
+
const env = {
|
|
935
|
+
...process.env,
|
|
936
|
+
ARENA_WORKSPACE: this.workspace,
|
|
937
|
+
LANG: "C.UTF-8",
|
|
938
|
+
LC_ALL: "C.UTF-8",
|
|
939
|
+
};
|
|
940
|
+
delete env.FORCE_COLOR;
|
|
941
|
+
delete env.CLICOLOR_FORCE;
|
|
942
|
+
return env;
|
|
943
|
+
}
|
|
639
944
|
async bash(command, cwd, timeoutSeconds) {
|
|
640
945
|
const workdir = await this.confinedPath(cwd);
|
|
641
946
|
if (!(await stat(workdir)).isDirectory()) {
|
|
@@ -647,18 +952,7 @@ class ArenaToolExecutor {
|
|
|
647
952
|
? command
|
|
648
953
|
: command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
|
|
649
954
|
return new Promise((resolve, reject) => {
|
|
650
|
-
const env =
|
|
651
|
-
...process.env,
|
|
652
|
-
ARENA_WORKSPACE: this.workspace,
|
|
653
|
-
// 生产 sandbox 是 C collation(参考 find|sort 为逐字节序),宿主
|
|
654
|
-
// 常见 en_US.UTF-8 会翻转大小写次序;统一成 C.UTF-8 对齐生产。
|
|
655
|
-
LANG: "C.UTF-8",
|
|
656
|
-
LC_ALL: "C.UTF-8",
|
|
657
|
-
};
|
|
658
|
-
// 交互终端(node --test、npm 等)会向子进程注入强制着色变量,
|
|
659
|
-
// 管道输出被 ANSI 色码污染;生产轨迹无色码,剥离之。
|
|
660
|
-
delete env.FORCE_COLOR;
|
|
661
|
-
delete env.CLICOLOR_FORCE;
|
|
955
|
+
const env = this.sandboxEnvironment();
|
|
662
956
|
// detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
|
|
663
957
|
// 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
|
|
664
958
|
// 必须杀整组,并在宽限期后强制结算兜底。
|
|
@@ -673,7 +967,6 @@ class ArenaToolExecutor {
|
|
|
673
967
|
let stderr = "";
|
|
674
968
|
let timedOut = false;
|
|
675
969
|
let settled = false;
|
|
676
|
-
let killRequested = false;
|
|
677
970
|
let forceSettle;
|
|
678
971
|
const killTree = (signal) => {
|
|
679
972
|
try {
|
|
@@ -692,33 +985,26 @@ class ArenaToolExecutor {
|
|
|
692
985
|
throw error;
|
|
693
986
|
}
|
|
694
987
|
};
|
|
695
|
-
const finish = (code) => {
|
|
988
|
+
const finish = (code, signal) => {
|
|
696
989
|
if (settled)
|
|
697
990
|
return;
|
|
698
991
|
settled = true;
|
|
699
992
|
clearTimeout(timeout);
|
|
700
993
|
if (forceSettle)
|
|
701
994
|
clearTimeout(forceSettle);
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
if (killRequested)
|
|
707
|
-
killTree("SIGKILL");
|
|
995
|
+
// bash is one-shot in v2. Any descendant left in the process group is
|
|
996
|
+
// cleaned up even when the shell itself exited normally; persistent
|
|
997
|
+
// servers belong in start_process.
|
|
998
|
+
killTree("SIGKILL");
|
|
708
999
|
this.signal?.removeEventListener("abort", abort);
|
|
709
1000
|
if (this.signal?.aborted) {
|
|
710
1001
|
reject(this.signal.reason || new Error("operation cancelled"));
|
|
711
1002
|
return;
|
|
712
1003
|
}
|
|
713
|
-
// 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
|
|
714
|
-
// 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
|
|
715
|
-
if (timedOut) {
|
|
716
|
-
stderr += (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
|
|
717
|
-
}
|
|
718
1004
|
const out = capBashOutput(this.virtualizeBashOutput(stdout));
|
|
719
1005
|
const err = capBashOutput(this.virtualizeBashOutput(stderr));
|
|
720
1006
|
resolve({
|
|
721
|
-
status: "completed",
|
|
1007
|
+
status: timedOut ? "timeout" : signal ? "killed" : "completed",
|
|
722
1008
|
stderr: err.text,
|
|
723
1009
|
stdout: out.text,
|
|
724
1010
|
exit_code: timedOut ? null : code,
|
|
@@ -732,17 +1018,15 @@ class ArenaToolExecutor {
|
|
|
732
1018
|
const armForceSettle = () => {
|
|
733
1019
|
forceSettle ??= setTimeout(() => {
|
|
734
1020
|
killTree("SIGKILL");
|
|
735
|
-
finish(null);
|
|
1021
|
+
finish(null, "SIGKILL");
|
|
736
1022
|
}, 2000);
|
|
737
1023
|
};
|
|
738
1024
|
const timeout = setTimeout(() => {
|
|
739
1025
|
timedOut = true;
|
|
740
|
-
killRequested = true;
|
|
741
1026
|
killTree("SIGKILL");
|
|
742
1027
|
armForceSettle();
|
|
743
1028
|
}, timeoutSeconds * 1000);
|
|
744
1029
|
const abort = () => {
|
|
745
|
-
killRequested = true;
|
|
746
1030
|
killTree("SIGTERM");
|
|
747
1031
|
armForceSettle();
|
|
748
1032
|
};
|
|
@@ -763,7 +1047,24 @@ class ArenaToolExecutor {
|
|
|
763
1047
|
this.signal?.removeEventListener("abort", abort);
|
|
764
1048
|
reject(error);
|
|
765
1049
|
});
|
|
766
|
-
child.once("close", (code) => finish(code));
|
|
1050
|
+
child.once("close", (code, signal) => finish(code, signal));
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
async startProcess(name, command, cwd, startupWaitSeconds) {
|
|
1054
|
+
const workdir = await this.confinedPath(cwd);
|
|
1055
|
+
if (!(await stat(workdir)).isDirectory()) {
|
|
1056
|
+
throw new Error(`cwd is not a directory: ${cwd}`);
|
|
1057
|
+
}
|
|
1058
|
+
const translatedCommand = this.workspaceContext
|
|
1059
|
+
? command
|
|
1060
|
+
: command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
|
|
1061
|
+
return this.processManager.start({
|
|
1062
|
+
name,
|
|
1063
|
+
command: translatedCommand,
|
|
1064
|
+
cwd: workdir,
|
|
1065
|
+
startupWaitSeconds,
|
|
1066
|
+
env: this.sandboxEnvironment(),
|
|
1067
|
+
signal: this.signal,
|
|
767
1068
|
});
|
|
768
1069
|
}
|
|
769
1070
|
async askUser(questions, toolCallId, context) {
|
|
@@ -783,9 +1084,77 @@ class ArenaToolExecutor {
|
|
|
783
1084
|
"Proceed with reasonable defaults and state any assumptions you make.",
|
|
784
1085
|
};
|
|
785
1086
|
}
|
|
1087
|
+
async addVoice(text, language, voiceIdentity = {}, toolCallId, context) {
|
|
1088
|
+
if (!this.askUserHandler) {
|
|
1089
|
+
const selection = this.voiceRegistry.registerVirtual(language, voiceIdentity);
|
|
1090
|
+
return {
|
|
1091
|
+
status: "success",
|
|
1092
|
+
message: `Battle "${selection.battleId}": no interactive voice picker was ` +
|
|
1093
|
+
`available, so a default voice was selected. Pass voice_id ` +
|
|
1094
|
+
`"${selection.voiceId}" to generate_speech to speak with it.`,
|
|
1095
|
+
voice_id: selection.voiceId,
|
|
1096
|
+
battle_id: selection.battleId,
|
|
1097
|
+
virtual: true,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
const battle = await this.voiceRegistry.audition(text, language, voiceIdentity, this.signal);
|
|
1101
|
+
const response = await this.askUserHandler(toolCallId, [
|
|
1102
|
+
{
|
|
1103
|
+
id: `voice-battle-${battle.battleId}`,
|
|
1104
|
+
question: "Which voice should be used for this speaker?",
|
|
1105
|
+
options: battle.candidates.map((candidate) => ({
|
|
1106
|
+
id: candidate.id,
|
|
1107
|
+
label: candidate.label,
|
|
1108
|
+
description: "Play the audition, then select this voice.",
|
|
1109
|
+
audioDataUrl: candidate.audioDataUrl,
|
|
1110
|
+
})),
|
|
1111
|
+
allowCustomResponse: false,
|
|
1112
|
+
},
|
|
1113
|
+
], context
|
|
1114
|
+
? {
|
|
1115
|
+
...context,
|
|
1116
|
+
signal: this.signal || context.signal,
|
|
1117
|
+
}
|
|
1118
|
+
: undefined);
|
|
1119
|
+
if (response.status === "needs_input")
|
|
1120
|
+
return response;
|
|
1121
|
+
if (response.skipped === true) {
|
|
1122
|
+
return { status: "error", message: "The user skipped voice selection." };
|
|
1123
|
+
}
|
|
1124
|
+
const answers = Array.isArray(response.answers) ? response.answers : [];
|
|
1125
|
+
const selectedOptionId = record(answers[0]).selectedOptionId;
|
|
1126
|
+
if (typeof selectedOptionId !== "string") {
|
|
1127
|
+
throw new Error("voice selection did not choose a candidate");
|
|
1128
|
+
}
|
|
1129
|
+
const voiceId = this.voiceRegistry.register(battle, selectedOptionId);
|
|
1130
|
+
return {
|
|
1131
|
+
status: "success",
|
|
1132
|
+
message: `Battle "${battle.battleId}": the user selected a voice. ` +
|
|
1133
|
+
`Pass voice_id "${voiceId}" to generate_speech to speak with it. ` +
|
|
1134
|
+
"Continue the task now — if you need another voice, call add_voice again immediately.",
|
|
1135
|
+
voice_id: voiceId,
|
|
1136
|
+
battle_id: battle.battleId,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
786
1139
|
async editFile(rawPath, oldText, newText) {
|
|
787
|
-
|
|
788
|
-
|
|
1140
|
+
let target;
|
|
1141
|
+
let source;
|
|
1142
|
+
try {
|
|
1143
|
+
target = await this.confinedPath(rawPath);
|
|
1144
|
+
if (!(await stat(target)).isFile()) {
|
|
1145
|
+
return {
|
|
1146
|
+
status: "error",
|
|
1147
|
+
message: `File not found: ${rawPath}`,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
source = await readFile(target, "utf8");
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
return {
|
|
1154
|
+
status: "error",
|
|
1155
|
+
message: `Failed to read workspace file: ${rawPath}`,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
789
1158
|
// 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
|
|
790
1159
|
// 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
|
|
791
1160
|
// 会静默改错文件;全部参考成功样本也都能走精确路径复现。
|
|
@@ -798,7 +1167,15 @@ class ArenaToolExecutor {
|
|
|
798
1167
|
message: "Context not found. Read the file to verify the text exists.",
|
|
799
1168
|
};
|
|
800
1169
|
}
|
|
801
|
-
|
|
1170
|
+
try {
|
|
1171
|
+
await writeFile(target, source.replace(oldText, newText), "utf8");
|
|
1172
|
+
}
|
|
1173
|
+
catch {
|
|
1174
|
+
return {
|
|
1175
|
+
status: "error",
|
|
1176
|
+
message: `Failed to write workspace file: ${rawPath}`,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
802
1179
|
return { status: "success", message: `Edited ${rawPath}.` };
|
|
803
1180
|
}
|
|
804
1181
|
async readWorkspaceFile(rawPath, toolCallId) {
|
|
@@ -842,6 +1219,15 @@ class ArenaToolExecutor {
|
|
|
842
1219
|
if (kind === "image" && fileSize > 20 * 1024 * 1024) {
|
|
843
1220
|
throw new Error(`image exceeds 20 MB: ${rawPath}`);
|
|
844
1221
|
}
|
|
1222
|
+
// Current Arena v2 captures consistently expose local PDFs as binary
|
|
1223
|
+
// metadata. PDF text extraction belongs to fetch_page, not read_file.
|
|
1224
|
+
if (extension === ".pdf") {
|
|
1225
|
+
return {
|
|
1226
|
+
kind: "unsupported",
|
|
1227
|
+
mediaType: "application/pdf",
|
|
1228
|
+
size: fileSize,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
845
1231
|
const mainModelSupportsImages = kind === "image" ? supportsImageInput(this.config) : false;
|
|
846
1232
|
const fallbackModel = mainModelSupportsImages
|
|
847
1233
|
? undefined
|
|
@@ -939,9 +1325,18 @@ class ArenaToolExecutor {
|
|
|
939
1325
|
};
|
|
940
1326
|
}
|
|
941
1327
|
async writeWorkspaceFile(rawPath, content) {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1328
|
+
let target;
|
|
1329
|
+
try {
|
|
1330
|
+
target = await this.confinedPath(rawPath);
|
|
1331
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
1332
|
+
await writeFile(target, content, "utf8");
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
return {
|
|
1336
|
+
status: "error",
|
|
1337
|
+
message: `Failed to write workspace file: ${rawPath}`,
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
945
1340
|
return { status: "success" };
|
|
946
1341
|
}
|
|
947
1342
|
async webSearch(query, depth) {
|
|
@@ -1002,23 +1397,35 @@ class ArenaToolExecutor {
|
|
|
1002
1397
|
}
|
|
1003
1398
|
}
|
|
1004
1399
|
}
|
|
1400
|
+
if (!saved.length) {
|
|
1401
|
+
return {
|
|
1402
|
+
status: "error",
|
|
1403
|
+
message: "Image search failed: Strict safe search filtered all results for this query. " +
|
|
1404
|
+
"Try a different or more specific query.",
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1005
1407
|
return { status: "success", results: saved };
|
|
1006
1408
|
}
|
|
1007
1409
|
async presentFile(rawPath) {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1410
|
+
try {
|
|
1411
|
+
const target = await this.confinedPath(rawPath);
|
|
1412
|
+
if (!(await stat(target)).isFile())
|
|
1413
|
+
throw new Error(rawPath);
|
|
1414
|
+
return { path: rawPath, status: "success" };
|
|
1415
|
+
}
|
|
1416
|
+
catch {
|
|
1417
|
+
return { status: "error", message: "Error calling present file tool" };
|
|
1418
|
+
}
|
|
1012
1419
|
}
|
|
1013
1420
|
async generateImage(rawPath, prompt, images) {
|
|
1014
1421
|
const target = await this.confinedPath(rawPath);
|
|
1015
|
-
|
|
1422
|
+
const extension = path.extname(target).toLowerCase();
|
|
1423
|
+
if (![".jpg", ".jpeg", ".png"].includes(extension)) {
|
|
1016
1424
|
throw new Error("file_path must end in .jpg, .jpeg, or .png");
|
|
1017
1425
|
}
|
|
1018
1426
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
1019
|
-
const model = process.env.ARENA_IMAGE_MODEL || "
|
|
1020
|
-
const
|
|
1021
|
-
const content = [];
|
|
1427
|
+
const model = process.env.ARENA_IMAGE_MODEL || "gemini-2.5-flash-image";
|
|
1428
|
+
const references = [];
|
|
1022
1429
|
for (const imagePath of images || []) {
|
|
1023
1430
|
const source = await this.confinedPath(imagePath);
|
|
1024
1431
|
const extension = path.extname(source).toLowerCase();
|
|
@@ -1026,173 +1433,66 @@ class ArenaToolExecutor {
|
|
|
1026
1433
|
if (!mimeType?.startsWith("image/")) {
|
|
1027
1434
|
throw new Error(`unsupported reference image format: ${imagePath}`);
|
|
1028
1435
|
}
|
|
1029
|
-
|
|
1436
|
+
let bytes;
|
|
1437
|
+
try {
|
|
1438
|
+
bytes = await readFile(source);
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
throw new Error(`Failed to read workspace file: ${imagePath}`);
|
|
1442
|
+
}
|
|
1030
1443
|
if (bytes.length > 20 * 1024 * 1024) {
|
|
1031
1444
|
throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
|
|
1032
1445
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1446
|
+
references.push({
|
|
1447
|
+
bytes,
|
|
1448
|
+
fileName: path.basename(source),
|
|
1449
|
+
mimeType,
|
|
1035
1450
|
});
|
|
1036
1451
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
watermark: false,
|
|
1042
|
-
};
|
|
1043
|
-
if (isQwenImage2) {
|
|
1044
|
-
parameters.prompt_extend = true;
|
|
1045
|
-
}
|
|
1046
|
-
else {
|
|
1047
|
-
parameters.n = 1;
|
|
1048
|
-
if (!images?.length)
|
|
1049
|
-
parameters.thinking_mode = true;
|
|
1050
|
-
}
|
|
1051
|
-
const headers = {
|
|
1052
|
-
"content-type": "application/json",
|
|
1053
|
-
};
|
|
1054
|
-
if (!isQwenImage2)
|
|
1055
|
-
headers["x-dashscope-async"] = "enable";
|
|
1056
|
-
const submitted = await dashScopeJson("api", isQwenImage2
|
|
1057
|
-
? "/services/aigc/multimodal-generation/generation"
|
|
1058
|
-
: "/services/aigc/image-generation/generation", {
|
|
1059
|
-
method: "POST",
|
|
1060
|
-
headers,
|
|
1061
|
-
body: JSON.stringify({
|
|
1062
|
-
model,
|
|
1063
|
-
input: {
|
|
1064
|
-
messages: [{ role: "user", content }],
|
|
1065
|
-
},
|
|
1066
|
-
parameters,
|
|
1067
|
-
}),
|
|
1452
|
+
const generated = await generateImageWithModelRouter({
|
|
1453
|
+
images: references,
|
|
1454
|
+
model,
|
|
1455
|
+
prompt,
|
|
1068
1456
|
signal: this.signal,
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
: undefined;
|
|
1073
|
-
if (!isQwenImage2) {
|
|
1074
|
-
const taskId = stringValue(record(submitted.output).task_id);
|
|
1075
|
-
if (!taskId) {
|
|
1076
|
-
throw new Error(`generate_image returned no task_id: ${responseMessage(submitted)}`);
|
|
1077
|
-
}
|
|
1078
|
-
const timeoutMs = Number(process.env.DASHSCOPE_TASK_TIMEOUT_MS || 5 * 60 * 1000);
|
|
1079
|
-
const pollIntervalMs = Number(process.env.DASHSCOPE_TASK_POLL_INTERVAL_MS || 1000);
|
|
1080
|
-
const deadline = Date.now() + timeoutMs;
|
|
1081
|
-
while (Date.now() <= deadline) {
|
|
1082
|
-
const task = await dashScopeJson("api", `/tasks/${encodeURIComponent(taskId)}`, { method: "GET", signal: this.signal }, "generate_image status");
|
|
1083
|
-
const output = record(task.output);
|
|
1084
|
-
const taskStatus = stringValue(output.task_status).toUpperCase();
|
|
1085
|
-
if (taskStatus === "SUCCEEDED") {
|
|
1086
|
-
completed = task;
|
|
1087
|
-
break;
|
|
1088
|
-
}
|
|
1089
|
-
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
|
1090
|
-
throw new Error(`generate_image task ${taskStatus}: ${responseMessage(task)}`);
|
|
1091
|
-
}
|
|
1092
|
-
if (Date.now() + pollIntervalMs > deadline)
|
|
1093
|
-
break;
|
|
1094
|
-
await delay(pollIntervalMs, undefined, { signal: this.signal });
|
|
1095
|
-
}
|
|
1096
|
-
if (!completed) {
|
|
1097
|
-
throw new Error(`generate_image timed out waiting for task ${taskId}`);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
if (!completed) {
|
|
1101
|
-
throw new Error("generate_image returned no response");
|
|
1102
|
-
}
|
|
1103
|
-
const output = record(completed.output);
|
|
1104
|
-
const choices = Array.isArray(output.choices) ? output.choices : [];
|
|
1105
|
-
const message = record(record(choices[0]).message);
|
|
1106
|
-
const generatedContent = Array.isArray(message.content)
|
|
1107
|
-
? message.content
|
|
1108
|
-
: [];
|
|
1109
|
-
const generated = generatedContent
|
|
1110
|
-
.map(record)
|
|
1111
|
-
.find((item) => typeof item.image === "string");
|
|
1112
|
-
const legacyResults = Array.isArray(output.results) ? output.results : [];
|
|
1113
|
-
const imageUrl = stringValue(generated?.image) ||
|
|
1114
|
-
stringValue(record(legacyResults[0]).url);
|
|
1115
|
-
if (!imageUrl) {
|
|
1116
|
-
throw new Error("generate_image response did not include an image URL");
|
|
1117
|
-
}
|
|
1118
|
-
const imageResponse = await fetch(imageUrl, { signal: this.signal });
|
|
1119
|
-
if (!imageResponse.ok) {
|
|
1120
|
-
throw new Error(`image download failed (${imageResponse.status})`);
|
|
1121
|
-
}
|
|
1122
|
-
await writeFile(target, await responseBytes(imageResponse, 25 * 1024 * 1024));
|
|
1457
|
+
size: process.env.ARENA_IMAGE_SIZE || "1024*1024",
|
|
1458
|
+
});
|
|
1459
|
+
await writeFile(target, await generatedImageBytesForExtension(generated.bytes, generated.mimeType, extension));
|
|
1123
1460
|
return {
|
|
1124
1461
|
status: "success",
|
|
1125
1462
|
file_path: this.relativePath(target),
|
|
1126
1463
|
};
|
|
1127
1464
|
}
|
|
1128
|
-
async generateSpeech(rawPath, text,
|
|
1465
|
+
async generateSpeech(rawPath, text, voiceId, language) {
|
|
1129
1466
|
const target = await this.confinedPath(rawPath);
|
|
1130
1467
|
const extension = path.extname(target).toLowerCase();
|
|
1131
1468
|
const format = {
|
|
1132
1469
|
".mp3": "mp3",
|
|
1133
1470
|
".wav": "wav",
|
|
1471
|
+
".ogg": "opus",
|
|
1134
1472
|
".opus": "opus",
|
|
1135
1473
|
}[extension];
|
|
1136
1474
|
if (!format) {
|
|
1137
|
-
throw new Error("
|
|
1475
|
+
throw new Error("ModelRouter Doubao TTS output supports .mp3, .wav, .ogg, or .opus");
|
|
1138
1476
|
}
|
|
1139
1477
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
const voices = primaryLanguage === "en"
|
|
1144
|
-
? feminine
|
|
1145
|
-
? ["loongeva_v3.6", "longanhuan_v3.6"]
|
|
1146
|
-
: ["loongjohn", "longjielidou_v3.6"]
|
|
1147
|
-
: feminine
|
|
1148
|
-
? ["longanhuan_v3.6"]
|
|
1149
|
-
: ["longjielidou_v3.6"];
|
|
1150
|
-
const requestedIndex = Math.max(0, Math.trunc(Number(voiceIdentity.index || 0)));
|
|
1151
|
-
const voice = process.env.DASHSCOPE_SPEECH_VOICE ||
|
|
1152
|
-
voices[requestedIndex % voices.length] ||
|
|
1153
|
-
voices[0];
|
|
1154
|
-
const supportedHints = new Set([
|
|
1155
|
-
"ar",
|
|
1156
|
-
"de",
|
|
1157
|
-
"en",
|
|
1158
|
-
"es",
|
|
1159
|
-
"fil",
|
|
1160
|
-
"fr",
|
|
1161
|
-
"id",
|
|
1162
|
-
"it",
|
|
1163
|
-
"ja",
|
|
1164
|
-
"ko",
|
|
1165
|
-
"ms",
|
|
1166
|
-
"pt",
|
|
1167
|
-
"ru",
|
|
1168
|
-
"th",
|
|
1169
|
-
"vi",
|
|
1170
|
-
"zh",
|
|
1171
|
-
]);
|
|
1172
|
-
const input = {
|
|
1173
|
-
text,
|
|
1174
|
-
voice,
|
|
1175
|
-
format,
|
|
1176
|
-
sample_rate: 24000,
|
|
1177
|
-
};
|
|
1178
|
-
if (supportedHints.has(primaryLanguage)) {
|
|
1179
|
-
input.language_hints = [primaryLanguage];
|
|
1180
|
-
}
|
|
1181
|
-
const payload = await dashScopeJson("api", "/services/audio/tts/SpeechSynthesizer", {
|
|
1182
|
-
method: "POST",
|
|
1183
|
-
headers: { "content-type": "application/json" },
|
|
1184
|
-
body: JSON.stringify({ model, input }),
|
|
1185
|
-
signal: this.signal,
|
|
1186
|
-
}, "generate_speech");
|
|
1187
|
-
const audioUrl = stringValue(record(record(payload.output).audio).url);
|
|
1188
|
-
if (!audioUrl) {
|
|
1189
|
-
throw new Error(`generate_speech returned no audio URL: ${responseMessage(payload)}`);
|
|
1478
|
+
const registered = this.voiceRegistry.resolve(voiceId);
|
|
1479
|
+
if (!registered) {
|
|
1480
|
+
throw new Error(`unknown voice_id: ${voiceId}. Call add_voice and use the returned voice_id.`);
|
|
1190
1481
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1482
|
+
if (language &&
|
|
1483
|
+
language.toLowerCase().split(/[-_]/)[0] !==
|
|
1484
|
+
registered.language.toLowerCase().split(/[-_]/)[0]) {
|
|
1485
|
+
throw new Error(`voice ${voiceId} was registered for ${registered.language}; ` +
|
|
1486
|
+
`register a voice for ${language} with add_voice`);
|
|
1194
1487
|
}
|
|
1195
|
-
|
|
1488
|
+
const voice = process.env.ARENA_SPEECH_VOICE || registered.providerVoice;
|
|
1489
|
+
await writeFile(target, await generateSpeechWithModelRouter({
|
|
1490
|
+
format,
|
|
1491
|
+
model: process.env.ARENA_SPEECH_MODEL || "doubao-tts",
|
|
1492
|
+
signal: this.signal,
|
|
1493
|
+
text,
|
|
1494
|
+
voice,
|
|
1495
|
+
}));
|
|
1196
1496
|
return {
|
|
1197
1497
|
status: "success",
|
|
1198
1498
|
file_path: this.relativePath(target),
|