@qwen-code/arena-agent 0.0.10-dev.0 → 0.0.10-dev.2

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/dist/tools.js CHANGED
@@ -18,10 +18,22 @@ import { imageTextFromFallbackModel } from "./image-input-fallback.js";
18
18
  import { isCodeArenaMode } from "./mode.js";
19
19
  import { generateImageWithModelRouter, generateSpeechWithModelRouter, } from "./model-router-media.js";
20
20
  import { processManagerForWorkspace, } from "./process-manager.js";
21
- import { HttpModelClient } from "./provider.js";
21
+ import { HttpModelClient, } from "./provider.js";
22
22
  import { RoutifyFetchPageReader, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
23
23
  import { toolDefinitions } from "./spec.js";
24
24
  import { voiceRegistryForWorkspace, } from "./voice-registry.js";
25
+ export function imageInputFallbackConnection(config) {
26
+ if (!config.imageInputFallbackBaseUrl &&
27
+ !config.imageInputFallbackApiKey &&
28
+ !config.imageInputFallbackProtocol) {
29
+ return undefined;
30
+ }
31
+ return {
32
+ baseUrl: config.imageInputFallbackBaseUrl,
33
+ apiKey: config.imageInputFallbackApiKey,
34
+ protocol: config.imageInputFallbackProtocol,
35
+ };
36
+ }
25
37
  const knownTextApplicationMimeTypes = new Set([
26
38
  "application/ecmascript",
27
39
  "application/javascript",
@@ -349,6 +361,180 @@ function json(value) {
349
361
  function errorMessage(error) {
350
362
  return error instanceof Error ? error.message : String(error);
351
363
  }
364
+ function jsonPointerPath(instancePath) {
365
+ if (!instancePath)
366
+ return [];
367
+ return instancePath
368
+ .slice(1)
369
+ .split("/")
370
+ .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
371
+ .map((part) => (/^(0|[1-9]\d*)$/.test(part) ? Number(part) : part));
372
+ }
373
+ function valueAtPath(value, pathParts) {
374
+ let current = value;
375
+ for (const part of pathParts) {
376
+ if (current === null || typeof current !== "object")
377
+ return undefined;
378
+ current = current[part];
379
+ }
380
+ return current;
381
+ }
382
+ function zodTypeName(type) {
383
+ return type === "integer" ? "number" : type;
384
+ }
385
+ function receivedType(value) {
386
+ if (value === null)
387
+ return "null";
388
+ if (Array.isArray(value))
389
+ return "array";
390
+ return typeof value;
391
+ }
392
+ function arenaValidationIssue(error, value) {
393
+ const pathParts = jsonPointerPath(error.instancePath);
394
+ if (error.keyword === "required") {
395
+ const missingProperty = String(error.params.missingProperty);
396
+ const parentSchema = error.parentSchema;
397
+ const expected = zodTypeName(parentSchema?.properties?.[missingProperty]?.type || "unknown");
398
+ return {
399
+ expected,
400
+ code: "invalid_type",
401
+ path: [...pathParts, missingProperty],
402
+ message: `Invalid input: expected ${expected}, received undefined`,
403
+ };
404
+ }
405
+ if (error.keyword === "type") {
406
+ const expected = zodTypeName(String(error.params.type));
407
+ return {
408
+ expected,
409
+ code: "invalid_type",
410
+ path: pathParts,
411
+ message: `Invalid input: expected ${expected}, received ` +
412
+ receivedType(valueAtPath(value, pathParts)),
413
+ };
414
+ }
415
+ if (error.keyword === "maxLength" || error.keyword === "minLength") {
416
+ const tooBig = error.keyword === "maxLength";
417
+ const limitKey = tooBig ? "maximum" : "minimum";
418
+ const limit = Number(error.params.limit);
419
+ return {
420
+ origin: "string",
421
+ code: tooBig ? "too_big" : "too_small",
422
+ [limitKey]: limit,
423
+ inclusive: true,
424
+ path: pathParts,
425
+ message: `${tooBig ? "Too big" : "Too small"}: expected string to have ` +
426
+ `${tooBig ? "<=" : ">="}${limit} characters`,
427
+ };
428
+ }
429
+ if (error.keyword === "maximum" || error.keyword === "minimum") {
430
+ const tooBig = error.keyword === "maximum";
431
+ const limitKey = tooBig ? "maximum" : "minimum";
432
+ const limit = Number(error.params.limit);
433
+ return {
434
+ origin: "number",
435
+ code: tooBig ? "too_big" : "too_small",
436
+ [limitKey]: limit,
437
+ inclusive: true,
438
+ path: pathParts,
439
+ message: `${tooBig ? "Too big" : "Too small"}: expected number to be ` +
440
+ `${tooBig ? "<=" : ">="}${limit}`,
441
+ };
442
+ }
443
+ if (error.keyword === "maxItems" || error.keyword === "minItems") {
444
+ const tooBig = error.keyword === "maxItems";
445
+ const limitKey = tooBig ? "maximum" : "minimum";
446
+ const limit = Number(error.params.limit);
447
+ return {
448
+ origin: "array",
449
+ code: tooBig ? "too_big" : "too_small",
450
+ [limitKey]: limit,
451
+ inclusive: true,
452
+ path: pathParts,
453
+ message: `${tooBig ? "Too big" : "Too small"}: expected array to have ` +
454
+ `${tooBig ? "<=" : ">="}${limit} items`,
455
+ };
456
+ }
457
+ if (error.keyword === "enum") {
458
+ const values = error.params.allowedValues;
459
+ return {
460
+ code: "invalid_value",
461
+ values,
462
+ path: pathParts,
463
+ message: `Invalid option: expected one of ${values
464
+ .map((item) => JSON.stringify(item))
465
+ .join("|")}`,
466
+ };
467
+ }
468
+ if (error.keyword === "format") {
469
+ return {
470
+ origin: "string",
471
+ code: "invalid_format",
472
+ format: error.params.format === "uri" ? "url" : error.params.format,
473
+ path: pathParts,
474
+ message: error.params.format === "uri" ? "Invalid URL" : "Invalid string",
475
+ };
476
+ }
477
+ return {
478
+ code: "custom",
479
+ path: pathParts,
480
+ message: error.message || "Invalid input",
481
+ };
482
+ }
483
+ function arenaValidationIssues(error, value) {
484
+ const issues = [arenaValidationIssue(error, value)];
485
+ if (error.keyword !== "type" || error.params.type !== "array")
486
+ return issues;
487
+ const pathParts = jsonPointerPath(error.instancePath);
488
+ const received = valueAtPath(value, pathParts);
489
+ if (typeof received !== "string")
490
+ return issues;
491
+ const parentSchema = error.parentSchema;
492
+ const maximum = parentSchema?.maxItems;
493
+ if (typeof maximum === "number" && received.length > maximum) {
494
+ issues.push({
495
+ origin: "string",
496
+ code: "too_big",
497
+ maximum,
498
+ inclusive: true,
499
+ path: pathParts,
500
+ message: `Too big: expected string to have <=${maximum} characters`,
501
+ });
502
+ }
503
+ const minimum = parentSchema?.minItems;
504
+ if (typeof minimum === "number" && received.length < minimum) {
505
+ issues.push({
506
+ origin: "string",
507
+ code: "too_small",
508
+ minimum,
509
+ inclusive: true,
510
+ path: pathParts,
511
+ message: `Too small: expected string to have >=${minimum} characters`,
512
+ });
513
+ }
514
+ return issues;
515
+ }
516
+ function arenaValidationError(toolName, value, errors) {
517
+ const issues = (errors || []).flatMap((error) => arenaValidationIssues(error, value));
518
+ return (`Invalid input for tool ${toolName}: Type validation failed: Value: ` +
519
+ `${JSON.stringify(value)}.\nError message: ${JSON.stringify(issues, null, 2)}`);
520
+ }
521
+ function arenaJsonParsingError(toolName, text, error) {
522
+ return (`Invalid input for tool ${toolName}: JSON parsing failed: Text: ${text}.\n` +
523
+ `Error message: ${errorMessage(error)}`);
524
+ }
525
+ function unavailableToolError(toolName, availableToolNames) {
526
+ return (`Model tried to call unavailable tool '${toolName}'. Available tools: ` +
527
+ `${[...availableToolNames, "compact"].join(", ")}.`);
528
+ }
529
+ function prefixedFailure(prefix, message) {
530
+ return message.startsWith(prefix) ? message : `${prefix}${message}`;
531
+ }
532
+ function fetchPageFailure(message) {
533
+ const status = message.match(/(?:fetch_page|Firecrawl|Jina Reader) failed \((\d{3})\b/i)?.[1];
534
+ return status
535
+ ? `Failed to fetch page (HTTP ${status})`
536
+ : "Failed to fetch page";
537
+ }
352
538
  function displayByteSize(value) {
353
539
  if (value >= 1024 * 1024) {
354
540
  return `${(value / (1024 * 1024)).toFixed(1)} MB`;
@@ -384,7 +570,14 @@ class ArenaToolExecutor {
384
570
  routifyPageReader;
385
571
  processManager;
386
572
  voiceRegistry;
387
- ajv = new Ajv({ allErrors: true, strict: false });
573
+ ajv = new Ajv({
574
+ allErrors: true,
575
+ strict: false,
576
+ verbose: true,
577
+ // The captured runtime uses Zod object parsing: unknown object keys are
578
+ // stripped before dispatch instead of being emitted as validation issues.
579
+ removeAdditional: "all",
580
+ });
388
581
  // ask_user needs a handler: TUI/WebUI collect real user input, while
389
582
  // headless `-p` may delegate to a configured remote User Agent. add_voice
390
583
  // remains available without one and deterministically registers a fallback.
@@ -399,8 +592,12 @@ class ArenaToolExecutor {
399
592
  this.askUserHandler = options.askUserHandler;
400
593
  this.askUserEnabled = Boolean(options.askUserHandler);
401
594
  if (config.imageInputFallbackModel?.trim()) {
595
+ const connection = imageInputFallbackConnection(config);
402
596
  this.imageInputFallbackClient =
403
- options.modelClient || new HttpModelClient(config);
597
+ options.imageInputFallbackClient ||
598
+ (connection
599
+ ? new HttpModelClient(config, { connection })
600
+ : options.modelClient || new HttpModelClient(config));
404
601
  }
405
602
  this.signal = options.signal;
406
603
  this.workspaceContext = options.workspaceContext;
@@ -409,6 +606,13 @@ class ArenaToolExecutor {
409
606
  signal: options.signal,
410
607
  });
411
608
  addFormats(this.ajv);
609
+ // WHATWG URL parsing matches the captured fetch_page behavior. ajv-formats'
610
+ // RFC URI checker rejects a recorded URL containing `|` in its query even
611
+ // though Arena accepted and fetched it successfully.
612
+ this.ajv.addFormat("uri", {
613
+ type: "string",
614
+ validate: (value) => URL.canParse(value),
615
+ });
412
616
  for (const definition of toolDefinitions(this.workspaceContext)) {
413
617
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
414
618
  }
@@ -431,23 +635,82 @@ class ArenaToolExecutor {
431
635
  try {
432
636
  // 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
433
637
  // 之前短路,否则命令会照常 spawn 直到自然结束。
434
- this.signal?.throwIfAborted();
638
+ if (this.signal?.aborted) {
639
+ return {
640
+ role: "tool",
641
+ tool_call_id: callId,
642
+ content: json({ status: "aborted" }),
643
+ };
644
+ }
435
645
  if (this.config.disallowedTools?.includes(name)) {
436
646
  throw new Error(`tool is disallowed: ${name}`);
437
647
  }
438
- const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
439
- ? call.function.arguments
440
- : "{}");
441
648
  const validate = this.validators.get(name);
442
- if (!validate)
443
- throw new Error(`unknown tool: ${name}`);
444
- if (!validate(argumentsValue)) {
445
- const errors = (validate.errors || [])
446
- .map((error) => `${error.instancePath || "/"} ${error.message}`)
447
- .join("; ");
448
- throw new Error(`invalid arguments: ${errors}`);
649
+ if (!validate) {
650
+ return {
651
+ role: "tool",
652
+ tool_call_id: callId,
653
+ content: unavailableToolError(name, toolDefinitions(this.workspaceContext).map((definition) => definition.function.name)),
654
+ };
655
+ }
656
+ const argumentsText = typeof call.function?.arguments === "string"
657
+ ? call.function.arguments
658
+ : "{}";
659
+ let argumentsValue;
660
+ try {
661
+ argumentsValue = JSON.parse(argumentsText);
662
+ }
663
+ catch (error) {
664
+ return {
665
+ role: "tool",
666
+ tool_call_id: callId,
667
+ content: arenaJsonParsingError(name, argumentsText, error),
668
+ };
669
+ }
670
+ const malformedArguments = argumentsValue !== null &&
671
+ typeof argumentsValue === "object" &&
672
+ !Array.isArray(argumentsValue)
673
+ ? argumentsValue.__malformedToolArguments
674
+ : undefined;
675
+ if (typeof malformedArguments === "string") {
676
+ try {
677
+ JSON.parse(malformedArguments);
678
+ }
679
+ catch (error) {
680
+ return {
681
+ role: "tool",
682
+ tool_call_id: callId,
683
+ content: arenaJsonParsingError(name, malformedArguments, error),
684
+ };
685
+ }
686
+ }
687
+ const validatedArguments = structuredClone(argumentsValue);
688
+ if (!validate(validatedArguments)) {
689
+ return {
690
+ role: "tool",
691
+ tool_call_id: callId,
692
+ content: arenaValidationError(name, argumentsValue, validate.errors),
693
+ };
694
+ }
695
+ const startedAt = Date.now();
696
+ let result;
697
+ try {
698
+ result = await this.dispatch(name, validatedArguments, callId, context);
699
+ }
700
+ catch (error) {
701
+ if (error instanceof AskUserHandlerError) {
702
+ return {
703
+ role: "tool",
704
+ tool_call_id: callId,
705
+ content: json({ status: "error", message: error.message }),
706
+ };
707
+ }
708
+ return {
709
+ role: "tool",
710
+ tool_call_id: callId,
711
+ content: json(this.runtimeFailureResult(name, validatedArguments, error, startedAt)),
712
+ };
449
713
  }
450
- const result = await this.dispatch(name, argumentsValue, callId, context);
451
714
  const content = typeof result === "string" ? result : json(result);
452
715
  if (content.length > toolResultCharacterLimit) {
453
716
  return {
@@ -468,13 +731,6 @@ class ArenaToolExecutor {
468
731
  };
469
732
  }
470
733
  catch (error) {
471
- if (error instanceof AskUserHandlerError) {
472
- return {
473
- role: "tool",
474
- tool_call_id: callId,
475
- content: json({ status: "error", message: error.message }),
476
- };
477
- }
478
734
  return {
479
735
  role: "tool",
480
736
  tool_call_id: callId,
@@ -485,6 +741,83 @@ class ArenaToolExecutor {
485
741
  };
486
742
  }
487
743
  }
744
+ runtimeFailureResult(name, values, error, startedAt) {
745
+ if (this.signal?.aborted)
746
+ return { status: "aborted" };
747
+ const message = errorMessage(error);
748
+ const durationMs = Date.now() - startedAt;
749
+ if (name === "bash") {
750
+ const stderr = this.virtualizeBashOutput(message);
751
+ return {
752
+ status: "shell_error",
753
+ stderr,
754
+ stdout: "",
755
+ exit_code: null,
756
+ duration_ms: durationMs,
757
+ stderr_truncated: false,
758
+ stdout_truncated: false,
759
+ };
760
+ }
761
+ if (name === "start_process") {
762
+ const cwd = String(values.cwd || "/home/user");
763
+ const code = error?.code;
764
+ const logTail = code === "ENOENT"
765
+ ? `[invalid_argument] cwd '${cwd}' does not exist`
766
+ : message;
767
+ return {
768
+ status: "shell_error",
769
+ process_id: null,
770
+ pid: null,
771
+ exit_code: null,
772
+ log_tail: logTail,
773
+ listening_ports: [],
774
+ new_ports: [],
775
+ duration_ms: durationMs,
776
+ };
777
+ }
778
+ if (name === "get_process_output") {
779
+ return {
780
+ status: "shell_error",
781
+ log_tail: message,
782
+ exit_code: null,
783
+ listening_ports: [],
784
+ };
785
+ }
786
+ if (name === "stop_process") {
787
+ return { status: "shell_error", log_tail: message };
788
+ }
789
+ if (name === "fetch_page") {
790
+ return { status: "error", error: fetchPageFailure(message) };
791
+ }
792
+ if (name === "add_voice") {
793
+ return {
794
+ status: "error",
795
+ message: prefixedFailure("Failed to audition voices: ", message),
796
+ };
797
+ }
798
+ if (name === "generate_image") {
799
+ return {
800
+ status: "error",
801
+ message: prefixedFailure("Failed to generate image: ", message),
802
+ };
803
+ }
804
+ if (name === "generate_speech") {
805
+ return {
806
+ status: "error",
807
+ message: prefixedFailure("Failed to generate speech: ", message),
808
+ };
809
+ }
810
+ if (name === "image_search") {
811
+ return {
812
+ status: "error",
813
+ message: prefixedFailure("Image search failed: ", message),
814
+ };
815
+ }
816
+ if (name === "present_file") {
817
+ return { status: "error", message: "Error calling present file tool" };
818
+ }
819
+ return { status: "error", message };
820
+ }
488
821
  takeVisualMessage(toolCallIds) {
489
822
  // 生产契约:同一组 tool call 的全部图片汇入一条 user message,
490
823
  // 由一个 arena-system-message 文本 part 领头。
@@ -820,8 +1153,24 @@ class ArenaToolExecutor {
820
1153
  };
821
1154
  }
822
1155
  async editFile(rawPath, oldText, newText) {
823
- const target = await this.confinedPath(rawPath);
824
- const source = await readFile(target, "utf8");
1156
+ let target;
1157
+ let source;
1158
+ try {
1159
+ target = await this.confinedPath(rawPath);
1160
+ if (!(await stat(target)).isFile()) {
1161
+ return {
1162
+ status: "error",
1163
+ message: `File not found: ${rawPath}`,
1164
+ };
1165
+ }
1166
+ source = await readFile(target, "utf8");
1167
+ }
1168
+ catch {
1169
+ return {
1170
+ status: "error",
1171
+ message: `Failed to read workspace file: ${rawPath}`,
1172
+ };
1173
+ }
825
1174
  // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
826
1175
  // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
827
1176
  // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
@@ -834,7 +1183,15 @@ class ArenaToolExecutor {
834
1183
  message: "Context not found. Read the file to verify the text exists.",
835
1184
  };
836
1185
  }
837
- await writeFile(target, source.replace(oldText, newText), "utf8");
1186
+ try {
1187
+ await writeFile(target, source.replace(oldText, newText), "utf8");
1188
+ }
1189
+ catch {
1190
+ return {
1191
+ status: "error",
1192
+ message: `Failed to write workspace file: ${rawPath}`,
1193
+ };
1194
+ }
838
1195
  return { status: "success", message: `Edited ${rawPath}.` };
839
1196
  }
840
1197
  async readWorkspaceFile(rawPath, toolCallId) {
@@ -878,6 +1235,15 @@ class ArenaToolExecutor {
878
1235
  if (kind === "image" && fileSize > 20 * 1024 * 1024) {
879
1236
  throw new Error(`image exceeds 20 MB: ${rawPath}`);
880
1237
  }
1238
+ // Current Arena v2 captures consistently expose local PDFs as binary
1239
+ // metadata. PDF text extraction belongs to fetch_page, not read_file.
1240
+ if (extension === ".pdf") {
1241
+ return {
1242
+ kind: "unsupported",
1243
+ mediaType: "application/pdf",
1244
+ size: fileSize,
1245
+ };
1246
+ }
881
1247
  const mainModelSupportsImages = kind === "image" ? supportsImageInput(this.config) : false;
882
1248
  const fallbackModel = mainModelSupportsImages
883
1249
  ? undefined
@@ -975,9 +1341,18 @@ class ArenaToolExecutor {
975
1341
  };
976
1342
  }
977
1343
  async writeWorkspaceFile(rawPath, content) {
978
- const target = await this.confinedPath(rawPath);
979
- mkdirSync(path.dirname(target), { recursive: true });
980
- await writeFile(target, content, "utf8");
1344
+ let target;
1345
+ try {
1346
+ target = await this.confinedPath(rawPath);
1347
+ mkdirSync(path.dirname(target), { recursive: true });
1348
+ await writeFile(target, content, "utf8");
1349
+ }
1350
+ catch {
1351
+ return {
1352
+ status: "error",
1353
+ message: `Failed to write workspace file: ${rawPath}`,
1354
+ };
1355
+ }
981
1356
  return { status: "success" };
982
1357
  }
983
1358
  async webSearch(query, depth) {
@@ -1038,13 +1413,25 @@ class ArenaToolExecutor {
1038
1413
  }
1039
1414
  }
1040
1415
  }
1416
+ if (!saved.length) {
1417
+ return {
1418
+ status: "error",
1419
+ message: "Image search failed: Strict safe search filtered all results for this query. " +
1420
+ "Try a different or more specific query.",
1421
+ };
1422
+ }
1041
1423
  return { status: "success", results: saved };
1042
1424
  }
1043
1425
  async presentFile(rawPath) {
1044
- const target = await this.confinedPath(rawPath);
1045
- if (!(await stat(target)).isFile())
1046
- throw new Error(rawPath);
1047
- return { path: rawPath, status: "success" };
1426
+ try {
1427
+ const target = await this.confinedPath(rawPath);
1428
+ if (!(await stat(target)).isFile())
1429
+ throw new Error(rawPath);
1430
+ return { path: rawPath, status: "success" };
1431
+ }
1432
+ catch {
1433
+ return { status: "error", message: "Error calling present file tool" };
1434
+ }
1048
1435
  }
1049
1436
  async generateImage(rawPath, prompt, images) {
1050
1437
  const target = await this.confinedPath(rawPath);
@@ -1062,7 +1449,13 @@ class ArenaToolExecutor {
1062
1449
  if (!mimeType?.startsWith("image/")) {
1063
1450
  throw new Error(`unsupported reference image format: ${imagePath}`);
1064
1451
  }
1065
- const bytes = await readFile(source);
1452
+ let bytes;
1453
+ try {
1454
+ bytes = await readFile(source);
1455
+ }
1456
+ catch {
1457
+ throw new Error(`Failed to read workspace file: ${imagePath}`);
1458
+ }
1066
1459
  if (bytes.length > 20 * 1024 * 1024) {
1067
1460
  throw new Error(`reference image exceeds 20 MB: ${imagePath}`);
1068
1461
  }