@qwen-code/arena-agent 0.0.5 → 0.0.7-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.env.example +12 -19
  2. package/README.md +26 -133
  3. package/dist/agent.d.ts +3 -9
  4. package/dist/agent.js +108 -246
  5. package/dist/agent.js.map +1 -1
  6. package/dist/cli.js +12 -41
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +1 -6
  9. package/dist/config.js +2 -28
  10. package/dist/config.js.map +1 -1
  11. package/dist/fetch-page.js +7 -18
  12. package/dist/fetch-page.js.map +1 -1
  13. package/dist/history.d.ts +3 -3
  14. package/dist/history.js +4 -4
  15. package/dist/history.js.map +1 -1
  16. package/dist/mock.js +9 -25
  17. package/dist/mock.js.map +1 -1
  18. package/dist/prompts.d.ts +4 -0
  19. package/dist/prompts.js +57 -0
  20. package/dist/prompts.js.map +1 -0
  21. package/dist/protocol.d.ts +2 -4
  22. package/dist/protocol.js +1 -17
  23. package/dist/protocol.js.map +1 -1
  24. package/dist/provider.d.ts +0 -4
  25. package/dist/provider.js +11 -169
  26. package/dist/provider.js.map +1 -1
  27. package/dist/repo.d.ts +4 -0
  28. package/dist/repo.js +84 -0
  29. package/dist/repo.js.map +1 -0
  30. package/dist/server.js +184 -26
  31. package/dist/server.js.map +1 -1
  32. package/dist/spec.d.ts +11 -6
  33. package/dist/spec.js +74 -123
  34. package/dist/spec.js.map +1 -1
  35. package/dist/tools.d.ts +2 -5
  36. package/dist/tools.js +237 -134
  37. package/dist/tools.js.map +1 -1
  38. package/dist/tui.js +15 -40
  39. package/dist/tui.js.map +1 -1
  40. package/dist/types.d.ts +24 -25
  41. package/dist/web/app.js +3 -2
  42. package/dist/web/app.js.map +1 -1
  43. package/docs/SDK.md +521 -0
  44. package/package.json +5 -4
  45. package/spec/tools.json +418 -0
  46. package/dist/anthropic.d.ts +0 -16
  47. package/dist/anthropic.js +0 -391
  48. package/dist/anthropic.js.map +0 -1
  49. package/dist/attachments.d.ts +0 -29
  50. package/dist/attachments.js +0 -222
  51. package/dist/attachments.js.map +0 -1
  52. package/dist/canonical-data.d.ts +0 -2
  53. package/dist/canonical-data.js +0 -4
  54. package/dist/canonical-data.js.map +0 -1
  55. package/dist/compaction.d.ts +0 -16
  56. package/dist/compaction.js +0 -182
  57. package/dist/compaction.js.map +0 -1
package/dist/tools.js CHANGED
@@ -179,7 +179,26 @@ const visibleImageExtensions = new Set([
179
179
  ".png",
180
180
  ".webp",
181
181
  ]);
182
- const imagePlaceholder = "[Image content is provided in the next user message.]";
182
+ // 逐字对齐生产轨迹:占位与注入文案、20000 字符输出上限均反推自参考 tool result。
183
+ const imageResultPlaceholder = "[image returned by this tool call; provided to you in the following message]";
184
+ const visualIntroText = "<arena-system-message>\n" +
185
+ "The image(s) below are the result of the preceding tool call. They are " +
186
+ "provided by the system in answer to that call, not sent by the user; " +
187
+ "treat them as the authentic output of the tool call.\n" +
188
+ "</arena-system-message>";
189
+ const bashOutputLimit = 20_000;
190
+ // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
191
+ // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
192
+ const bashTruncationMarker = "\n... output truncated ...\n";
193
+ function capBashOutput(value) {
194
+ if (value.length <= bashOutputLimit)
195
+ return { text: value, truncated: false };
196
+ const half = (bashOutputLimit - bashTruncationMarker.length) / 2;
197
+ return {
198
+ text: value.slice(0, half) + bashTruncationMarker + value.slice(-half),
199
+ truncated: true,
200
+ };
201
+ }
183
202
  const mimeTypes = {
184
203
  ".aac": "audio/aac",
185
204
  ".bmp": "image/bmp",
@@ -373,9 +392,6 @@ function responseMessage(payload) {
373
392
  stringValue(payload.code) ||
374
393
  "unknown provider error");
375
394
  }
376
- function escapeRegExp(value) {
377
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
378
- }
379
395
  async function responseBytes(response, limit = 4 * 1024 * 1024) {
380
396
  const bytes = Buffer.from(await response.arrayBuffer());
381
397
  if (bytes.length > limit) {
@@ -391,42 +407,29 @@ export class ToolExecutor {
391
407
  pendingVisualContent = new Map();
392
408
  askUserHandler;
393
409
  signal;
394
- workspaceContext;
395
410
  pageReader;
396
411
  ajv = new Ajv({ allErrors: true, strict: false });
397
- // ask_user needs an interactive front-end (TUI/WebUI) to collect answers.
398
- // Headless `-p` supplies no handler, so the tool is withheld from the model
399
- // entirely rather than offered and then dead-ending on needs_input.
400
- askUserEnabled;
401
412
  constructor(config, options = {}) {
402
413
  this.config = config;
403
414
  this.workspace = path.resolve(config.workspace);
404
415
  mkdirSync(this.workspace, { recursive: true });
405
416
  this.workspaceReal = realpathSync(this.workspace);
406
417
  this.askUserHandler = options.askUserHandler;
407
- this.askUserEnabled = Boolean(options.askUserHandler);
408
418
  this.signal = options.signal;
409
- this.workspaceContext = options.workspaceContext;
410
419
  this.pageReader = new FetchPageReader({ signal: options.signal });
411
420
  addFormats(this.ajv);
412
- for (const definition of toolDefinitions(this.workspaceContext)) {
421
+ for (const definition of toolDefinitions()) {
413
422
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
414
423
  }
415
424
  }
416
- // The tool schemas offered to the model for this session. Identical to the
417
- // canonical trace set, adjusted for the selected workspace runtime and minus
418
- // ask_user when no interactive handler is wired.
419
- availableTools() {
420
- const definitions = toolDefinitions(this.workspaceContext);
421
- if (this.askUserEnabled)
422
- return definitions;
423
- return definitions.filter((definition) => definition.function.name !== "ask_user");
424
- }
425
425
  async execute(call) {
426
426
  const callId = String(call.id || "");
427
427
  const name = String(call.function?.name || "");
428
428
  this.pendingVisualContent.delete(callId);
429
429
  try {
430
+ // 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
431
+ // 之前短路,否则命令会照常 spawn 直到自然结束。
432
+ this.signal?.throwIfAborted();
430
433
  const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
431
434
  ? call.function.arguments
432
435
  : "{}");
@@ -452,29 +455,32 @@ export class ToolExecutor {
452
455
  tool_call_id: callId,
453
456
  content: json({
454
457
  status: "error",
455
- tool: name,
456
- error: errorMessage(error),
458
+ message: errorMessage(error),
457
459
  }),
458
460
  };
459
461
  }
460
462
  }
461
463
  takeVisualMessage(toolCallIds) {
462
- const content = [];
464
+ // 生产契约:同一组 tool call 的全部图片汇入一条 user message,
465
+ // 由一个 arena-system-message 文本 part 领头。
466
+ const images = [];
463
467
  for (const toolCallId of toolCallIds) {
464
468
  const parts = this.pendingVisualContent.get(toolCallId);
465
469
  this.pendingVisualContent.delete(toolCallId);
466
470
  if (parts)
467
- content.push(...parts);
471
+ images.push(...parts);
468
472
  }
469
- return content.length ? { role: "user", content } : undefined;
473
+ if (!images.length)
474
+ return undefined;
475
+ return {
476
+ role: "user",
477
+ content: [{ type: "text", text: visualIntroText }, ...images],
478
+ };
470
479
  }
471
480
  async dispatch(name, values, toolCallId) {
472
481
  switch (name) {
473
482
  case "bash":
474
- return this.bash(String(values.command), String(values.cwd ||
475
- (this.workspaceContext
476
- ? path.resolve(this.workspaceContext.cwd)
477
- : "/home/user")), Number(values.timeout || 30));
483
+ return this.bash(String(values.command), String(values.cwd || "/home/user"), Number(values.timeout || 30));
478
484
  case "ask_user":
479
485
  return this.askUser(values.questions, toolCallId);
480
486
  case "edit_file":
@@ -488,7 +494,7 @@ export class ToolExecutor {
488
494
  case "write_file":
489
495
  return this.writeWorkspaceFile(String(values.path), String(values.content));
490
496
  case "image_search":
491
- return this.imageSearch(String(values.query), Number(values.count), toolCallId);
497
+ return this.imageSearch(String(values.query), Number(values.count));
492
498
  case "present_file":
493
499
  return this.presentFile(String(values.path));
494
500
  case "generate_image":
@@ -503,20 +509,20 @@ export class ToolExecutor {
503
509
  if (!raw)
504
510
  throw new Error("path must not be empty");
505
511
  let candidate;
506
- if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
512
+ if (raw === "/home/user" || raw === "~") {
507
513
  candidate = this.workspace;
508
514
  }
509
- else if (!this.workspaceContext && raw.startsWith("/home/user/")) {
515
+ else if (raw.startsWith("/home/user/")) {
510
516
  candidate = path.join(this.workspace, raw.slice("/home/user/".length));
511
517
  }
512
- else if (!this.workspaceContext && raw.startsWith("~/")) {
518
+ else if (raw.startsWith("~/")) {
513
519
  candidate = path.join(this.workspace, raw.slice(2));
514
520
  }
515
521
  else {
516
- const defaultCwd = this.workspaceContext
517
- ? path.resolve(this.workspaceContext.cwd)
518
- : this.workspace;
519
- candidate = path.isAbsolute(raw) ? raw : path.join(defaultCwd, raw);
522
+ // 相对路径解析到 /home/user,repo 模式暂同。未经裁决:tools.json 与
523
+ // repo 模式 system prompt 对相对路径根的说法矛盾,生产轨迹无法区分;
524
+ // 复刻脚本会把受影响的调用标记为 comparability hazard。
525
+ candidate = path.isAbsolute(raw) ? raw : path.join(this.workspace, raw);
520
526
  }
521
527
  const absolute = path.resolve(candidate);
522
528
  const lexicalRelative = path.relative(this.workspace, absolute);
@@ -542,24 +548,126 @@ export class ToolExecutor {
542
548
  relativePath(target) {
543
549
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
544
550
  }
551
+ virtualizeBashOutput(value) {
552
+ let virtualized = value;
553
+ const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
554
+ .filter((root) => root !== path.parse(root).root)
555
+ .sort((left, right) => right.length - left.length);
556
+ for (const root of workspaceRoots) {
557
+ virtualized = virtualized.replaceAll(root, "/home/user");
558
+ }
559
+ return virtualized;
560
+ }
545
561
  async bash(command, cwd, timeoutSeconds) {
546
562
  const workdir = await this.confinedPath(cwd);
547
563
  if (!(await stat(workdir)).isDirectory()) {
548
564
  throw new Error(`cwd is not a directory: ${cwd}`);
549
565
  }
566
+ // Arena 对模型暴露的工作区固定为 /home/user;本地 harness 可能运行在任意
567
+ // 绝对路径。用环境变量替换虚拟根,并同步 HOME,使 bash 与文件工具看到
568
+ // 相同的文件系统布局。
569
+ const translatedCommand = command.replaceAll("/home/user", "\"$ARENA_WORKSPACE\"");
550
570
  return new Promise((resolve, reject) => {
551
- const child = spawn("/bin/bash", ["-lc", command], {
571
+ const env = {
572
+ ...process.env,
573
+ HOME: this.workspace,
574
+ ARENA_WORKSPACE: this.workspace,
575
+ // 生产 sandbox 是 C collation(参考 find|sort 为逐字节序),宿主
576
+ // 常见 en_US.UTF-8 会翻转大小写次序;统一成 C.UTF-8 对齐生产。
577
+ LANG: "C.UTF-8",
578
+ LC_ALL: "C.UTF-8",
579
+ };
580
+ // 交互终端(node --test、npm 等)会向子进程注入强制着色变量,
581
+ // 管道输出被 ANSI 色码污染;生产轨迹无色码,剥离之。
582
+ delete env.FORCE_COLOR;
583
+ delete env.CLICOLOR_FORCE;
584
+ // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
585
+ // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
586
+ // 必须杀整组,并在宽限期后强制结算兜底。
587
+ const child = spawn("/bin/bash", ["-lc", translatedCommand], {
552
588
  cwd: workdir,
589
+ env,
553
590
  stdio: ["ignore", "pipe", "pipe"],
591
+ detached: process.platform !== "win32",
554
592
  });
593
+ const startedAt = Date.now();
555
594
  let stdout = "";
556
595
  let stderr = "";
557
596
  let timedOut = false;
597
+ let settled = false;
598
+ let killRequested = false;
599
+ let forceSettle;
600
+ const killTree = (signal) => {
601
+ try {
602
+ if (process.platform !== "win32" && child.pid) {
603
+ process.kill(-child.pid, signal);
604
+ }
605
+ else {
606
+ child.kill(signal);
607
+ }
608
+ }
609
+ catch (error) {
610
+ // ESRCH:组已消失。EPERM:Darwin 在组正被回收时对重复 kill 的
611
+ // 返回值,同样视作已处理。
612
+ const code = error.code;
613
+ if (code !== "ESRCH" && code !== "EPERM")
614
+ throw error;
615
+ }
616
+ };
617
+ const finish = (code) => {
618
+ if (settled)
619
+ return;
620
+ settled = true;
621
+ clearTimeout(timeout);
622
+ if (forceSettle)
623
+ clearTimeout(forceSettle);
624
+ // 组长退出会立刻触发 close 并取消宽限定时器;取消/超时一旦请求过
625
+ // 击杀,就必须在结算时补一发组级 SIGKILL,否则忽略 TERM 且关闭了
626
+ // 继承管道的后台子进程会活过取消。正常退出不清场——模型可能有意
627
+ // 留下后台服务供后续调用使用。
628
+ if (killRequested)
629
+ killTree("SIGKILL");
630
+ this.signal?.removeEventListener("abort", abort);
631
+ if (this.signal?.aborted) {
632
+ reject(this.signal.reason || new Error("operation cancelled"));
633
+ return;
634
+ }
635
+ // 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
636
+ // 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
637
+ if (timedOut) {
638
+ stderr += (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
639
+ }
640
+ const out = capBashOutput(this.virtualizeBashOutput(stdout));
641
+ const err = capBashOutput(this.virtualizeBashOutput(stderr));
642
+ resolve({
643
+ status: "completed",
644
+ stderr: err.text,
645
+ stdout: out.text,
646
+ exit_code: timedOut ? null : code,
647
+ duration_ms: Date.now() - startedAt,
648
+ stderr_truncated: err.truncated,
649
+ stdout_truncated: out.truncated,
650
+ });
651
+ };
652
+ // 宽限期结束时升级为组级 SIGKILL 再结算:abort 只发 SIGTERM,
653
+ // trap TERM 的进程会活过 Promise 拒绝并继续改写工作区。
654
+ const armForceSettle = () => {
655
+ forceSettle ??= setTimeout(() => {
656
+ killTree("SIGKILL");
657
+ finish(null);
658
+ }, 2000);
659
+ };
558
660
  const timeout = setTimeout(() => {
559
661
  timedOut = true;
560
- child.kill("SIGKILL");
662
+ killRequested = true;
663
+ killTree("SIGKILL");
664
+ armForceSettle();
561
665
  }, timeoutSeconds * 1000);
562
- const abort = () => child.kill("SIGTERM");
666
+ const abort = () => {
667
+ killRequested = true;
668
+ killTree("SIGTERM");
669
+ armForceSettle();
670
+ };
563
671
  this.signal?.addEventListener("abort", abort, { once: true });
564
672
  child.stdout.on("data", (chunk) => {
565
673
  stdout += chunk.toString("utf8");
@@ -567,78 +675,68 @@ export class ToolExecutor {
567
675
  child.stderr.on("data", (chunk) => {
568
676
  stderr += chunk.toString("utf8");
569
677
  });
570
- child.once("error", reject);
571
- child.once("close", (code) => {
678
+ child.once("error", (error) => {
679
+ if (settled)
680
+ return;
681
+ settled = true;
572
682
  clearTimeout(timeout);
683
+ if (forceSettle)
684
+ clearTimeout(forceSettle);
573
685
  this.signal?.removeEventListener("abort", abort);
574
- if (this.signal?.aborted) {
575
- reject(this.signal.reason || new Error("operation cancelled"));
576
- return;
577
- }
578
- if (timedOut) {
579
- resolve({
580
- status: "timeout",
581
- exit_code: null,
582
- stdout: "",
583
- stderr: `command exceeded ${timeoutSeconds}s`,
584
- });
585
- return;
586
- }
587
- resolve({
588
- status: code === 0 ? "success" : "error",
589
- exit_code: code,
590
- stdout,
591
- stderr,
592
- });
686
+ reject(error);
593
687
  });
688
+ child.once("close", (code) => finish(code));
594
689
  });
595
690
  }
596
691
  async askUser(questions, toolCallId) {
597
692
  if (this.askUserHandler) {
598
693
  return this.askUserHandler(toolCallId, questions);
599
694
  }
600
- // ask_user is not offered in non-interactive sessions; if the model still
601
- // emits it (e.g. resumed history or a hallucinated call), report an error
602
- // so the turn continues instead of stalling on needs_input.
603
695
  return {
604
- status: "error",
605
- tool: "ask_user",
606
- error: "ask_user is unavailable in this non-interactive session. " +
607
- "Proceed with reasonable defaults and state any assumptions you make.",
696
+ status: "needs_input",
697
+ questions,
698
+ message: "The current headless request cannot collect interactive input.",
608
699
  };
609
700
  }
610
701
  async editFile(rawPath, oldText, newText) {
611
702
  const target = await this.confinedPath(rawPath);
612
703
  const source = await readFile(target, "utf8");
613
- let updated;
614
- if (source.includes(oldText)) {
615
- updated = source.replace(oldText, newText);
616
- }
617
- else {
618
- const tokens = oldText.split(/\s+/).filter(Boolean);
619
- const pattern = new RegExp(tokens.map(escapeRegExp).join("\\s+"));
620
- if (!pattern.test(source)) {
621
- return {
622
- status: "error",
623
- message: "Context not found. Read the file to verify the text exists.",
624
- };
625
- }
626
- updated = source.replace(pattern, newText);
704
+ // 仅精确匹配:曾有空白容错回退,但 T1(b) 回放证实生产没有——case-003
705
+ // 四个 old_text 精确缺失的 edit 生产全部返回 Context not found,而回退
706
+ // 会静默改错文件;全部参考成功样本也都能走精确路径复现。
707
+ // 注意:spec/tools.json 的 edit_file description(与生产逐字一致,受
708
+ // T1(c) 门禁保护)声称支持 fuzzy matching——那是上游自身的描述与行为
709
+ // 不一致,按实测行为对齐,描述不改。见 TRAJECTORY_FIDELITY.md §5。
710
+ if (!source.includes(oldText)) {
711
+ return {
712
+ status: "error",
713
+ message: "Context not found. Read the file to verify the text exists.",
714
+ };
627
715
  }
628
- await writeFile(target, updated, "utf8");
716
+ await writeFile(target, source.replace(oldText, newText), "utf8");
629
717
  return { status: "success", message: `Edited ${rawPath}.` };
630
718
  }
631
719
  async readWorkspaceFile(rawPath, toolCallId) {
632
720
  const target = await this.confinedPath(rawPath);
633
- const data = await readFile(target);
721
+ let data;
722
+ try {
723
+ data = await readFile(target);
724
+ }
725
+ catch {
726
+ // 逐字对齐生产错误文案,路径按调用方原样回显。
727
+ return {
728
+ status: "error",
729
+ message: `Failed to read workspace file: ${rawPath}`,
730
+ };
731
+ }
634
732
  const extension = path.extname(target).toLowerCase();
635
733
  const kind = workspaceFileKind(target, data.subarray(0, 4096));
636
- const relativePath = this.relativePath(target);
637
- const result = {
638
- status: "success",
639
- path: relativePath,
640
- };
641
734
  if (isStructuredDocument(target)) {
735
+ // 文档提取无参考样本(known-unknown),保留本地扩展键。
736
+ const result = {
737
+ status: "success",
738
+ path: this.relativePath(target),
739
+ };
642
740
  const extracted = await extractStructuredDocument(target, data, this.signal);
643
741
  result.content = extracted.content;
644
742
  result.mime_type =
@@ -655,34 +753,41 @@ export class ToolExecutor {
655
753
  result.truncated = true;
656
754
  if (extracted.warning)
657
755
  result.warning = extracted.warning;
756
+ return result;
658
757
  }
659
- else if (kind === "text") {
660
- result.content = decodeText(data);
758
+ if (kind === "text") {
759
+ const content = decodeText(data);
760
+ return {
761
+ kind: "text",
762
+ lines: content.split("\n").length,
763
+ size: Buffer.byteLength(content, "utf8"),
764
+ content,
765
+ };
661
766
  }
662
- else {
663
- const mimeType = mime.getType(target) ||
664
- mimeTypes[extension] ||
665
- "application/octet-stream";
666
- result.mime_type = mimeType;
667
- result.size = data.length;
668
- if (kind === "image") {
669
- if (data.length > 20 * 1024 * 1024) {
670
- throw new Error(`image exceeds 20 MB: ${rawPath}`);
671
- }
672
- result.content = imagePlaceholder;
673
- this.pendingVisualContent.set(toolCallId, [
674
- {
675
- text: "<arena-system-message>" +
676
- `The image below is the genuine content returned by read_file for ${JSON.stringify(relativePath)}.` +
677
- "</arena-system-message>",
678
- },
679
- {
680
- image: `data:${mimeType};base64,${data.toString("base64")}`,
681
- },
682
- ]);
767
+ const mimeType = mime.getType(target) ||
768
+ mimeTypes[extension] ||
769
+ "application/octet-stream";
770
+ if (kind === "image") {
771
+ if (data.length > 20 * 1024 * 1024) {
772
+ throw new Error(`image exceeds 20 MB: ${rawPath}`);
683
773
  }
774
+ this.pendingVisualContent.set(toolCallId, [
775
+ {
776
+ type: "image_url",
777
+ image_url: {
778
+ url: `data:${mimeType};base64,${data.toString("base64")}`,
779
+ detail: "auto",
780
+ },
781
+ },
782
+ ]);
783
+ return [{ type: "text", text: imageResultPlaceholder }];
684
784
  }
685
- return result;
785
+ return {
786
+ status: "success",
787
+ path: this.relativePath(target),
788
+ mime_type: mimeType,
789
+ size: data.length,
790
+ };
686
791
  }
687
792
  async writeWorkspaceFile(rawPath, content) {
688
793
  const target = await this.confinedPath(rawPath);
@@ -720,28 +825,25 @@ export class ToolExecutor {
720
825
  const searchResults = Array.isArray(searchInfo.search_results)
721
826
  ? searchInfo.search_results
722
827
  : [];
723
- const choices = Array.isArray(output.choices) ? output.choices : [];
724
- const answer = stringValue(record(record(choices[0]).message).content);
725
828
  const limit = { "1": 3, "2": 5, "3": 8 }[depth] || 5;
829
+ // 参考轨迹条目:{id, url, title, description[, pageAge]};
830
+ // DashScope 无 pageAge,按可选键省略。
726
831
  return {
727
832
  status: "success",
728
- query,
729
- answer,
730
833
  results: searchResults.slice(0, limit).map((value, index) => {
731
834
  const item = record(value);
732
835
  return {
733
836
  id: Number(item.index) || index + 1,
734
- title: stringValue(item.title),
735
837
  url: stringValue(item.url),
736
- content: stringValue(item.content) ||
838
+ title: stringValue(item.title),
839
+ description: stringValue(item.content) ||
737
840
  stringValue(item.snippet) ||
738
841
  stringValue(item.summary),
739
- site_name: stringValue(item.site_name),
740
842
  };
741
843
  }),
742
844
  };
743
845
  }
744
- async imageSearch(query, count, toolCallId) {
846
+ async imageSearch(query, count) {
745
847
  const model = process.env.ARENA_IMAGE_SEARCH_MODEL || "qwen3.7-plus";
746
848
  const payload = await dashScopeJson("compatible", "/responses", {
747
849
  method: "POST",
@@ -770,13 +872,14 @@ export class ToolExecutor {
770
872
  throw new Error("image_search returned malformed image results");
771
873
  }
772
874
  }
773
- const images = [];
774
- const searchId = toolCallId
775
- .replace(/[^A-Za-z0-9_-]+/g, "-")
776
- .replace(/^-+|-+$/g, "")
777
- .slice(0, 96) || "search";
875
+ const saved = [];
876
+ // 参考轨迹命名:image-search/<query slug 前 40 字符>-<序号>.<扩展名>。
877
+ const slug = query
878
+ .toLowerCase()
879
+ .replace(/[^a-z0-9]+/g, "-")
880
+ .slice(0, 40);
778
881
  for (const value of results) {
779
- if (images.length >= count)
882
+ if (saved.length >= count)
780
883
  break;
781
884
  const item = record(value);
782
885
  const sourceUrl = stringValue(item.url) ||
@@ -794,13 +897,14 @@ export class ToolExecutor {
794
897
  continue;
795
898
  const normalizedType = contentType.split(";")[0]?.trim().toLowerCase();
796
899
  const extension = Object.entries(mimeTypes).find(([, mime]) => mime === normalizedType)?.[0] || ".jpg";
797
- const target = await this.confinedPath(`assets/image-search/${searchId}/result-${images.length + 1}${extension}`);
900
+ const target = await this.confinedPath(`image-search/${slug}-${saved.length + 1}${extension}`);
798
901
  mkdirSync(path.dirname(target), { recursive: true });
799
902
  await writeFile(target, await responseBytes(imageResponse));
800
- images.push({
801
- path: this.relativePath(target),
802
- source_url: sourceUrl,
903
+ saved.push({
803
904
  title: stringValue(item.title),
905
+ file_path: this.relativePath(target),
906
+ source_url: sourceUrl,
907
+ thumbnail_url: stringValue(item.thumbnail_url) || sourceUrl,
804
908
  });
805
909
  }
806
910
  catch (error) {
@@ -809,7 +913,7 @@ export class ToolExecutor {
809
913
  // A single inaccessible search result should not fail the whole search.
810
914
  }
811
915
  }
812
- return { status: "success", query, images };
916
+ return { status: "success", results: saved };
813
917
  }
814
918
  async presentFile(rawPath) {
815
919
  const target = await this.confinedPath(rawPath);
@@ -912,7 +1016,6 @@ export class ToolExecutor {
912
1016
  return {
913
1017
  status: "success",
914
1018
  file_path: this.relativePath(target),
915
- task_id: taskId,
916
1019
  };
917
1020
  }
918
1021
  async generateSpeech(rawPath, text, language, voiceIdentity = {}) {