@gt-fe/eap-runtime 0.0.30 → 0.0.32

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 (41) hide show
  1. package/dist/api/handlers/invoke.handler.js +9 -5
  2. package/dist/api/handlers/stream.handler.js +15 -4
  3. package/dist/common/index.d.ts +1 -0
  4. package/dist/common/index.js +1 -0
  5. package/dist/common/user-root.d.ts +8 -0
  6. package/dist/common/user-root.js +19 -0
  7. package/dist/graph/adapters/langgraph.adapter.js +57 -10
  8. package/dist/graph/adapters/skill-entry-span.d.ts +4 -2
  9. package/dist/graph/adapters/skill-entry-span.js +10 -3
  10. package/dist/graph/adapters/skill-read.d.ts +18 -2
  11. package/dist/graph/adapters/skill-read.js +66 -31
  12. package/dist/graph/contracts/executable-graph.d.ts +7 -1
  13. package/dist/graph/nodes/tool-node.d.ts +6 -1
  14. package/dist/graph/nodes/tool-node.js +18 -3
  15. package/dist/registry/deployment-snapshot.d.ts +1 -1
  16. package/dist/registry/deployment-snapshot.js +6 -3
  17. package/dist/registry/skill-tree.d.ts +1 -1
  18. package/dist/registry/skill-tree.js +11 -6
  19. package/dist/runtime/agent-runtime.js +36 -5
  20. package/dist/session/persist-turn-messages.d.ts +13 -6
  21. package/dist/session/persist-turn-messages.js +4 -0
  22. package/dist/sse/events/tool-events.d.ts +29 -0
  23. package/dist/tool/harness-command.d.ts +1 -1
  24. package/dist/tool/harness-command.js +8 -7
  25. package/dist/tool/harness-primitives.d.ts +4 -10
  26. package/dist/tool/harness-primitives.js +189 -130
  27. package/dist/tool/index.d.ts +2 -0
  28. package/dist/tool/index.js +2 -0
  29. package/dist/tool/inject-public-artifact-links.d.ts +32 -0
  30. package/dist/tool/inject-public-artifact-links.js +142 -0
  31. package/dist/tool/skill-relative-read.d.ts +14 -0
  32. package/dist/tool/skill-relative-read.js +98 -0
  33. package/dist/tool/static-artifact-url.d.ts +66 -0
  34. package/dist/tool/static-artifact-url.js +561 -0
  35. package/dist/tool/system-prompt-builder.js +8 -4
  36. package/dist/tool/tool-event-emitter.d.ts +2 -1
  37. package/dist/tool/tool-event-emitter.js +30 -18
  38. package/dist/tool/tool-executor.interface.d.ts +13 -1
  39. package/dist/tool/tool-result-injector.d.ts +3 -3
  40. package/dist/tool/tool-result-injector.js +7 -3
  41. package/package.json +2 -2
@@ -2,20 +2,26 @@
2
2
  * 平台默认 Harness 原语:read_file / write_file / execute_command。
3
3
  *
4
4
  * 不写进 agent.manifest.toolsets。同名业务工具优先(问数 file-io 等)。
5
- * 写:`users/<userId>/`(含用户自己的 skills)。
5
+ * 写:`dirname(agentDir)/users/<userId>/`(含用户自己的 skills)。产物默认 `files/`,公开副本 `public/`。
6
6
  * 读:写根 + 已装配 `preinstall/tools`、`preinstall/skills`。
7
- * 命令:cwd 仅写根。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
7
+ * 命令:cwd 默认 `files/`。技能包只读,EAP_SKILL_DIR 为整包绝对根(不限 scripts/)。不把预装拷进用户装配槽。
8
8
  * 开发态 overlay:`EAP_LOCAL_SKILLS_DIR` / `EAP_LOCAL_TOOLS_DIR`(只读)。在 `createDevRuntime` /
9
9
  * `createToolExecutor` 时钉进执行器 extraRoots;SDK `createAgent` 构造完会恢复 process.env,
10
10
  * 请求时不再读这两项。相对 `read_file` 会把 overlay 根下直接子目录里带 SKILL.md 的包纳入唯一命中。
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
- import { constants as fsConstants, existsSync, readdirSync, realpathSync } from 'node:fs';
14
- import { mkdir, open, readFile } from 'node:fs/promises';
13
+ import { constants as fsConstants, realpathSync } from 'node:fs';
14
+ import { chmod, mkdir, open, readFile, stat } from 'node:fs/promises';
15
15
  import path from 'node:path';
16
+ import { MISSING_USER_DIR, userRoot } from '../common/user-root.js';
17
+ import { locateSkillRelativeRead, userReadBase } from './skill-relative-read.js';
18
+ export { locateSkillRelativeRead } from './skill-relative-read.js';
19
+ import { MAX_SNAPSHOT_ENUM, MAX_SNAPSHOT_URLS, PUBLIC_FILE_MODE, ensureUserSlots, maxWriteBytesFromEnv, publishFromFiles, resolveUserProductPath, snapshotCandidates, walkFilesTree, } from './static-artifact-url.js';
16
20
  import { BUILTIN_TOOL_SOURCE, DEFAULT_BUILTIN_TIMEOUT_MS, MAX_BUILTIN_TIMEOUT_MS, clampBuiltinTimeout, isBuiltinToolExecution, resolveBuiltinToolsConfig, } from '@gt-fe/eap-contracts';
17
21
  import { ensureInterpreterShims, formatSpawnError, killProcessTree, resolveShellSpawn, sandboxEnv, spawnOptionsFor, substituteSkillDir, } from './harness-command.js';
18
22
  import { expandSkillScriptRefs, isSkillEntryFile } from './skill-script-refs.js';
23
+ /** 超时杀进程或 spawn 失败后等 `close`;未到则最多再等此时长再快照。 */
24
+ const SPAWN_CLOSE_GRACE_MS = 1000;
19
25
  function builtinConfig() {
20
26
  return resolveBuiltinToolsConfig(process.env);
21
27
  }
@@ -66,7 +72,7 @@ function harnessDefinitions() {
66
72
  manifest: {
67
73
  id: 'builtin:write_file',
68
74
  name: 'write_file',
69
- description: '把文本整份写入当前用户目录(含用户自己的 skills)。没有该文件则创建;已有文件会整份换成 content,不是改某一行。只要在末尾加内容请用 append_to_file。不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"相对用户目录的路径","bytesWritten":字节数}。路径越权或禁写文件名会失败。',
75
+ description: '把 UTF-8 文本整份写入当前用户的 files/(装配槽用 skills/ 前缀)。没有该文件则创建;已有文件会整份换成 content,不是改某一行。只要在末尾加内容请用 append_to_file。不能写 public/ 或 work/,不能写预装 tools/skills,不能写 .assembly.json 等平台文件。png/xlsx/docx/zip/pdf 等二进制请用 execute_command 写。成功时工具结果是 JSON:{"ok":true,"path":"工作稿原名","bytesWritten":字节数,"url":"公开副本链接,未配基址则无此字段"}。路径越权或禁写文件名会失败。',
70
76
  ...common,
71
77
  },
72
78
  inputSchema: {
@@ -77,11 +83,11 @@ function harnessDefinitions() {
77
83
  path: {
78
84
  type: 'string',
79
85
  minLength: 1,
80
- description: '相对当前用户目录的路径,例如 notes/todo.md。不能写预装 tools/skills。',
86
+ description: '相对 files/ 的路径,例如 notes/todo.md 或 page/index.html。装配槽用 skills/ 前缀。不能写预装 tools/skills、public/、work/。',
81
87
  },
82
88
  content: {
83
89
  type: 'string',
84
- description: '文件的完整新内容。会覆盖原文件全部内容。',
90
+ description: '文件的完整新内容(UTF-8 文本)。会覆盖原文件全部内容。',
85
91
  },
86
92
  },
87
93
  },
@@ -92,7 +98,7 @@ function harnessDefinitions() {
92
98
  manifest: {
93
99
  id: 'builtin:append_to_file',
94
100
  name: 'append_to_file',
95
- description: '在当前用户目录的文件末尾追加文本,不覆盖已有内容。没有该文件则创建。不要用它整份改写文件,那是 write_file。不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"相对用户目录的路径","bytesWritten":本次追加字节数}。路径越权或禁写文件名会失败。',
101
+ description: '在当前用户 files/ 的文件末尾追加 UTF-8 文本,不覆盖已有内容。没有该文件则创建。不要用它整份改写文件,那是 write_file。不能写 public/ 或 work/,不能写预装 tools/skills,不能写 .assembly.json 等平台文件。成功时工具结果是 JSON:{"ok":true,"path":"工作稿原名","bytesWritten":本次追加字节数,"url":"公开副本链接,未配基址则无此字段"}。路径越权或禁写文件名会失败。',
96
102
  ...common,
97
103
  },
98
104
  inputSchema: {
@@ -103,11 +109,11 @@ function harnessDefinitions() {
103
109
  path: {
104
110
  type: 'string',
105
111
  minLength: 1,
106
- description: '相对当前用户目录的路径,例如 notes/todo.md。不能写预装 tools/skills。',
112
+ description: '相对 files/ 的路径,例如 notes/todo.md。装配槽用 skills/ 前缀。不能写预装 tools/skills、public/、work/。',
107
113
  },
108
114
  content: {
109
115
  type: 'string',
110
- description: '接到文件末尾的文本。不会删除或替换原有内容。',
116
+ description: '接到文件末尾的 UTF-8 文本。不会删除或替换原有内容。',
111
117
  },
112
118
  },
113
119
  },
@@ -118,7 +124,7 @@ function harnessDefinitions() {
118
124
  manifest: {
119
125
  id: 'builtin:execute_command',
120
126
  name: 'execute_command',
121
- description: `用本机 shell 在用户目录执行命令(Windows cmd.exe,其它 /bin/sh)。技能包只读,用 EAP_SKILL_DIR 加包内相对路径跑文件(不限 scripts/)。python/python3、node/nodejs、sh/bash 经 shim。默认超时 ${DEFAULT_BUILTIN_TIMEOUT_MS}ms,可用 timeoutMs 延长,上限 ${MAX_BUILTIN_TIMEOUT_MS}ms。不继承密钥。`,
127
+ description: `用本机 shell 在用户 files/ 执行命令(Windows cmd.exe,其它 /bin/sh)。技能包只读,用 EAP_SKILL_DIR 加包内相对路径跑文件(不限 scripts/)。python/python3、node/nodejs、sh/bash 经 shim。默认超时 ${DEFAULT_BUILTIN_TIMEOUT_MS}ms,可用 timeoutMs 延长,上限 ${MAX_BUILTIN_TIMEOUT_MS}ms。不继承密钥。HOME/TMPDIR 在 work/。子进程 close 后(含超时杀进程)把 files/ 里新的或有改动的公开类型文件做成 public/ 时间戳副本。`,
122
128
  ...common,
123
129
  },
124
130
  inputSchema: {
@@ -126,7 +132,7 @@ function harnessDefinitions() {
126
132
  required: ['command'],
127
133
  properties: {
128
134
  command: { type: 'string' },
129
- cwd: { type: 'string', description: '用户目录内的子目录,可选;省略则用户目录。不能是预装技能/工具包' },
135
+ cwd: { type: 'string', description: 'files/ 内的子目录,可选;省略则 files/。不能是 work/、public/ 或预装技能/工具包' },
130
136
  timeoutMs: {
131
137
  type: 'integer',
132
138
  description: `超时毫秒。省略则 ${DEFAULT_BUILTIN_TIMEOUT_MS}。上限 ${MAX_BUILTIN_TIMEOUT_MS}。`,
@@ -151,19 +157,10 @@ export function mergeHarnessPrimitives(loadedTools) {
151
157
  export function assembleLoadedTools(loadedTools) {
152
158
  return mergeHarnessPrimitives(loadedTools);
153
159
  }
154
- const MISSING_USER_DIR = '缺少用户目录,拒绝该操作';
155
160
  const PROTECTED_WRITE_NAMES = new Set(['.assembly.json', 'assembly.json', '.ready']);
156
- /** `{agentDir}/users/<userId>`(写根;含用户 skills) */
161
+ /** `dirname(agentDir)/users/<userId>`(写根;含用户 skills) */
157
162
  export function resolveUserDir(agentDir, userId) {
158
- const dir = agentDir.trim();
159
- const uid = userId.trim();
160
- if (!dir || !uid) {
161
- throw new Error(MISSING_USER_DIR);
162
- }
163
- if (uid === '.' || uid === '..' || /[\\/]/.test(uid)) {
164
- throw new Error('非法用户标识');
165
- }
166
- return path.resolve(dir, 'users', uid);
163
+ return userRoot(agentDir, userId);
167
164
  }
168
165
  export function collectToolBundlePaths(loadedTools) {
169
166
  const roots = [];
@@ -270,6 +267,17 @@ async function writeFileNoFollow(target, content, append = false) {
270
267
  await handle.close();
271
268
  }
272
269
  }
270
+ function maxWriteBytes(config) {
271
+ const n = config.maxWriteBytes;
272
+ if (typeof n === 'number' && Number.isFinite(n) && n > 0)
273
+ return Math.floor(n);
274
+ return maxWriteBytesFromEnv();
275
+ }
276
+ function toolFileFromPublished(p) {
277
+ if (!p.url)
278
+ return undefined;
279
+ return { fileName: p.fileName, fileType: p.fileType, size: p.size, url: p.url };
280
+ }
273
281
  function isInsideRoot(candidate, root) {
274
282
  const rel = path.relative(root, candidate);
275
283
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
@@ -310,74 +318,6 @@ function resolveViaExistingAncestor(abs) {
310
318
  cur = parent;
311
319
  }
312
320
  }
313
- function userReadBase(workdir) {
314
- return workdir?.trim()
315
- ? (tryRealpath(path.resolve(workdir)) ?? path.resolve(workdir))
316
- : undefined;
317
- }
318
- function isSkillPackageDir(dir) {
319
- return existsSync(path.join(dir, 'SKILL.md')) || existsSync(path.join(dir, 'skill.md'));
320
- }
321
- function skillPackageRoots(roots) {
322
- const out = [];
323
- const seen = new Set();
324
- const add = (dir) => {
325
- if (seen.has(dir))
326
- return;
327
- seen.add(dir);
328
- out.push(dir);
329
- };
330
- for (const root of roots) {
331
- const resolved = path.resolve(root);
332
- if (isSkillPackageDir(resolved)) {
333
- add(resolved);
334
- continue;
335
- }
336
- let entries;
337
- try {
338
- entries = readdirSync(resolved, { withFileTypes: true });
339
- }
340
- catch {
341
- continue;
342
- }
343
- for (const ent of entries) {
344
- if (!ent.isDirectory())
345
- continue;
346
- const child = path.join(resolved, ent.name);
347
- if (isSkillPackageDir(child))
348
- add(child);
349
- }
350
- }
351
- return out;
352
- }
353
- /**
354
- * 相对 read_file:`技能名/文件`、全库唯一命中、显式 skillDir,否则用户目录。
355
- */
356
- export function locateSkillRelativeRead(filePath, workdir, skillDir, extraRoots) {
357
- const trimmed = filePath.trim();
358
- const userBase = userReadBase(workdir);
359
- if (!trimmed || path.isAbsolute(trimmed))
360
- return { path: trimmed, relativeBase: userBase };
361
- const packages = skillPackageRoots([
362
- ...(skillDir?.trim() ? [skillDir] : []),
363
- ...(extraRoots ?? []),
364
- ]);
365
- const slash = trimmed.replace(/\\/g, '/');
366
- const segs = slash.split('/').filter(Boolean);
367
- if (segs.length >= 2) {
368
- const hit = packages.find((pkg) => path.basename(pkg) === segs[0]);
369
- if (hit)
370
- return { path: segs.slice(1).join('/'), relativeBase: hit };
371
- }
372
- const matches = packages.filter((pkg) => existsSync(path.resolve(pkg, trimmed)));
373
- if (matches.length === 1)
374
- return { path: trimmed, relativeBase: matches[0] };
375
- const skillBase = skillDir?.trim() ? path.resolve(skillDir) : undefined;
376
- if (skillBase && existsSync(path.resolve(skillBase, trimmed))) {
377
- return { path: trimmed, relativeBase: skillBase };
378
- }
379
- return { path: trimmed, relativeBase: userBase };
380
- }
381
321
  export function resolveJailedPath(requested, roots, opts) {
382
322
  const trimmed = requested.trim();
383
323
  if (!trimmed || trimmed.includes('\0')) {
@@ -473,10 +413,27 @@ export class HarnessPrimitiveExecutor {
473
413
  const filePath = String(request.arguments.path ?? '');
474
414
  const workdir = this.workdirFor(request);
475
415
  const located = locateSkillRelativeRead(filePath, workdir, request.skillDir, this.extraRootsFor(request));
476
- const target = resolveJailedPath(located.path, this.rootsFor(request), {
477
- mustExist: true,
478
- relativeBase: located.relativeBase,
479
- });
416
+ const userBase = userReadBase(workdir);
417
+ let target;
418
+ if (path.isAbsolute(filePath.trim())) {
419
+ target = resolveJailedPath(filePath, this.rootsFor(request), { mustExist: true });
420
+ }
421
+ else if (located.relativeBase && located.relativeBase !== userBase) {
422
+ target = resolveJailedPath(located.path, this.rootsFor(request), {
423
+ mustExist: true,
424
+ relativeBase: located.relativeBase,
425
+ });
426
+ }
427
+ else if (workdir) {
428
+ const product = resolveUserProductPath(filePath, workdir, 'read');
429
+ target = resolveJailedPath(product.abs, this.rootsFor(request), { mustExist: true });
430
+ }
431
+ else {
432
+ target = resolveJailedPath(located.path, this.rootsFor(request), {
433
+ mustExist: true,
434
+ relativeBase: located.relativeBase,
435
+ });
436
+ }
480
437
  const buf = await readFile(target);
481
438
  const config = builtinConfig();
482
439
  const lines = buf.toString('utf8').split('\n');
@@ -507,44 +464,81 @@ export class HarnessPrimitiveExecutor {
507
464
  if (isSkillEntryFile(target)) {
508
465
  text = expandSkillScriptRefs(text, path.dirname(target));
509
466
  }
510
- return ok(request, text);
467
+ return { ...ok(request, text), resolvedPath: target };
511
468
  }
512
469
  async writeFile(request, append) {
513
470
  const filePath = String(request.arguments.path ?? '');
514
471
  const content = String(request.arguments.content ?? '');
515
472
  const workdir = this.requireWorkdir(request);
516
- const writeRoots = [workdir];
517
- const jailed = { relativeBase: workdir };
518
- const target = resolveJailedPath(filePath, writeRoots, jailed);
519
- if (PROTECTED_WRITE_NAMES.has(path.basename(target))) {
520
- throw new Error(`禁止写入平台文件:${path.basename(target)}`);
473
+ await ensureUserSlots(workdir);
474
+ const resolved = resolveUserProductPath(filePath, workdir, 'write');
475
+ if (PROTECTED_WRITE_NAMES.has(path.basename(resolved.abs))) {
476
+ throw new Error(`禁止写入平台文件:${path.basename(resolved.abs)}`);
521
477
  }
522
- await mkdir(path.dirname(target), { recursive: true });
523
- const recast = resolveJailedPath(filePath, writeRoots, jailed);
524
- if (PROTECTED_WRITE_NAMES.has(path.basename(recast))) {
525
- throw new Error(`禁止写入平台文件:${path.basename(recast)}`);
478
+ const incomingBytes = Buffer.byteLength(content, 'utf8');
479
+ const limit = maxWriteBytes(builtinConfig());
480
+ if (append) {
481
+ const existing = await stat(resolved.abs)
482
+ .then((s) => s.size)
483
+ .catch(() => 0);
484
+ if (existing + incomingBytes > limit) {
485
+ throw new Error(`文件超过 ${limit} 字节上限`);
486
+ }
526
487
  }
527
- const bytesWritten = await writeFileNoFollow(recast, content, append);
528
- return ok(request, JSON.stringify({ ok: true, path: path.relative(workdir, recast), bytesWritten }));
488
+ else if (incomingBytes > limit) {
489
+ throw new Error(`文件超过 ${limit} 字节上限`);
490
+ }
491
+ await mkdir(path.dirname(resolved.abs), { recursive: true });
492
+ const recast = resolveUserProductPath(filePath, workdir, 'write');
493
+ if (PROTECTED_WRITE_NAMES.has(path.basename(recast.abs))) {
494
+ throw new Error(`禁止写入平台文件:${path.basename(recast.abs)}`);
495
+ }
496
+ const bytesWritten = await writeFileNoFollow(recast.abs, content, append);
497
+ await chmod(recast.abs, PUBLIC_FILE_MODE).catch(() => undefined);
498
+ const payload = {
499
+ ok: true,
500
+ path: recast.jsonPath,
501
+ bytesWritten,
502
+ };
503
+ let file;
504
+ if (recast.slot === 'files' && recast.relFromFiles) {
505
+ const published = await publishFromFiles(workdir, [recast.relFromFiles]);
506
+ const first = published[0];
507
+ if (first?.url) {
508
+ payload.url = first.url;
509
+ file = toolFileFromPublished(first);
510
+ }
511
+ }
512
+ return { ...ok(request, JSON.stringify(payload)), ...(file ? { file } : {}) };
529
513
  }
530
514
  async executeCommand(request) {
531
515
  const command = substituteSkillDir(String(request.arguments.command ?? '').trim(), request.skillDir);
532
516
  if (!command)
533
517
  return fail(request, 'command 不能为空');
534
518
  const workdir = this.requireWorkdir(request);
535
- await mkdir(workdir, { recursive: true });
536
- const cwdArg = request.arguments.cwd != null ? String(request.arguments.cwd) : workdir;
537
- const cwd = resolveJailedPath(cwdArg, [workdir], {
538
- mustExist: true,
539
- relativeBase: workdir,
540
- });
519
+ const slots = await ensureUserSlots(workdir);
520
+ let cwd;
521
+ if (request.arguments.cwd != null && String(request.arguments.cwd).trim() !== '') {
522
+ const resolvedCwd = resolveUserProductPath(String(request.arguments.cwd), workdir, 'cwd');
523
+ if (resolvedCwd.slot !== 'files') {
524
+ throw new Error(`路径超出允许目录:${String(request.arguments.cwd)}`);
525
+ }
526
+ cwd = resolvedCwd.abs;
527
+ }
528
+ else {
529
+ cwd = slots.filesDir;
530
+ }
531
+ await mkdir(cwd, { recursive: true });
541
532
  const config = builtinConfig();
542
533
  const timeoutMs = clampBuiltinTimeout(request.arguments.timeoutMs, config);
543
534
  const maxOutput = config.maxOutputBytes;
535
+ const writeLimit = maxWriteBytes(config);
536
+ const before = await walkFilesTree(slots.filesDir);
544
537
  const shell = resolveShellSpawn(command);
545
- const shimDir = ensureInterpreterShims(workdir);
546
- const env = sandboxEnv(workdir, cwd, config.envAllowlist, shimDir, request.skillDir);
547
- const output = await new Promise((resolve, reject) => {
538
+ const shimDir = ensureInterpreterShims(slots.workDir);
539
+ const env = sandboxEnv(workdir, cwd, config.envAllowlist, shimDir, request.skillDir, slots.workDir);
540
+ // 子进程 close 后再快照 files/(超时先杀进程再等 close;close 未到则最多再等 SPAWN_CLOSE_GRACE_MS)。
541
+ const output = await new Promise((resolve) => {
548
542
  const child = spawn(shell.file, shell.args, {
549
543
  cwd,
550
544
  env,
@@ -555,9 +549,36 @@ export class HarnessPrimitiveExecutor {
555
549
  let stderr = Buffer.alloc(0);
556
550
  let stdoutTruncated = false;
557
551
  let stderrTruncated = false;
552
+ let settled = false;
553
+ let timedOut = false;
554
+ let spawnError;
555
+ let closeFallback;
556
+ const finish = (value) => {
557
+ if (settled)
558
+ return;
559
+ settled = true;
560
+ clearTimeout(timer);
561
+ if (closeFallback !== undefined)
562
+ clearTimeout(closeFallback);
563
+ resolve(value);
564
+ };
565
+ const snapshotStdio = () => ({
566
+ stdout: stdout.toString('utf8'),
567
+ stderr: stderr.toString('utf8'),
568
+ stdoutTruncated,
569
+ stderrTruncated,
570
+ });
571
+ const waitForCloseThenFinish = () => {
572
+ if (closeFallback !== undefined)
573
+ clearTimeout(closeFallback);
574
+ closeFallback = setTimeout(() => {
575
+ finish({ ...snapshotStdio(), code: null, timedOut, spawnError });
576
+ }, SPAWN_CLOSE_GRACE_MS);
577
+ };
558
578
  const timer = setTimeout(() => {
579
+ timedOut = true;
559
580
  killProcessTree(child);
560
- reject(new Error(`命令超时(${timeoutMs}ms)`));
581
+ waitForCloseThenFinish();
561
582
  }, timeoutMs);
562
583
  child.stdout?.on('data', (chunk) => {
563
584
  if (stdout.length >= maxOutput) {
@@ -578,33 +599,71 @@ export class HarnessPrimitiveExecutor {
578
599
  stderrTruncated = true;
579
600
  });
580
601
  child.on('error', (err) => {
602
+ spawnError = formatSpawnError(err, shell.file);
581
603
  clearTimeout(timer);
582
- reject(formatSpawnError(err, shell.file));
604
+ waitForCloseThenFinish();
583
605
  });
584
606
  child.on('close', (code) => {
585
- clearTimeout(timer);
586
- resolve({
587
- stdout: stdout.toString('utf8'),
588
- stderr: stderr.toString('utf8'),
589
- code,
590
- stdoutTruncated,
591
- stderrTruncated,
592
- });
607
+ finish({ ...snapshotStdio(), code, timedOut, spawnError });
593
608
  });
594
609
  });
595
- const body = [
610
+ const after = await walkFilesTree(slots.filesDir);
611
+ const { rels, skippedOversize } = snapshotCandidates(before.entries, after.entries, writeLimit);
612
+ const limited = rels.slice(0, MAX_SNAPSHOT_URLS);
613
+ const published = limited.length > 0 ? await publishFromFiles(workdir, limited) : [];
614
+ const publicFiles = published.map((p) => ({
615
+ path: p.sourceRel ?? p.fileName,
616
+ fileName: p.fileName,
617
+ size: p.size,
618
+ ...(p.url ? { url: p.url } : {}),
619
+ }));
620
+ const notes = [];
621
+ if (before.truncated || after.truncated) {
622
+ notes.push(`快照枚举达到 ${MAX_SNAPSHOT_ENUM} 上限`);
623
+ }
624
+ if (skippedOversize > 0) {
625
+ notes.push(`跳过 ${skippedOversize} 个超过大小上限的文件`);
626
+ }
627
+ if (rels.length > MAX_SNAPSHOT_URLS) {
628
+ notes.push(`快照 url 最多 ${MAX_SNAPSHOT_URLS} 个`);
629
+ }
630
+ const withUrl = published
631
+ .map(toolFileFromPublished)
632
+ .filter((f) => Boolean(f));
633
+ const file = withUrl[0];
634
+ const files = withUrl.slice(1);
635
+ const ended = output.timedOut
636
+ ? `命令超时(${timeoutMs}ms)`
637
+ : output.spawnError
638
+ ? output.spawnError.message
639
+ : `exit ${output.code ?? -1}`;
640
+ const commandBody = [
596
641
  output.stdout.trimEnd(),
597
642
  output.stdoutTruncated ? `[stdout truncated at ${maxOutput} bytes]` : '',
598
643
  output.stderr.trim() ? `stderr:\n${output.stderr.trimEnd()}` : '',
599
644
  output.stderrTruncated ? `[stderr truncated at ${maxOutput} bytes]` : '',
600
- `exit ${output.code ?? -1}`,
645
+ ended,
646
+ ...notes,
601
647
  ]
602
648
  .filter(Boolean)
603
649
  .join('\n');
650
+ const body = publicFiles.length > 0
651
+ ? `${JSON.stringify({ publicFiles })}\n${commandBody}`
652
+ : commandBody;
653
+ const extra = {
654
+ ...(file ? { file } : {}),
655
+ ...(files.length > 0 ? { files } : {}),
656
+ };
657
+ if (output.spawnError) {
658
+ return { ...fail(request, output.spawnError.message), content: body, ...extra };
659
+ }
660
+ if (output.timedOut) {
661
+ return { ...fail(request, `命令超时(${timeoutMs}ms)`), content: body, ...extra };
662
+ }
604
663
  if (output.code !== 0) {
605
- return { ...fail(request, `命令退出码 ${output.code}`), content: body };
664
+ return { ...fail(request, `命令退出码 ${output.code}`), content: body, ...extra };
606
665
  }
607
- return ok(request, body);
666
+ return { ...ok(request, body), ...extra };
608
667
  }
609
668
  }
610
669
  export function withHarnessPrimitives(inner, options) {
@@ -15,6 +15,8 @@ export { formatMcpOutput, toModelVisibleToolContent, MAX_MODEL_RESULT_ROWS, MAX_
15
15
  export { ToolExecutionRouter, type DeploymentContext, type ToolExecutionRouterOptions, } from './tool-execution-router.js';
16
16
  export { createToolExecutor, EmptyInstallStore, type CreateToolExecutorOptions, } from './create-tool-executor.js';
17
17
  export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, captureOverlayJailOptions, collectToolBundlePaths, type HarnessJailOptions as BuiltinJailOptions, } from './harness-primitives.js';
18
+ export { injectPublicArtifactLinks, collectPublicArtifactLinks, applyInjectedAssistant, } from './inject-public-artifact-links.js';
19
+ export { publishFromFiles, ensureUserSlots, publicArtifactUrl, PUBLIC_EXTENSIONS, } from './static-artifact-url.js';
18
20
  export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
19
21
  export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
20
22
  export { ToolResultInjector, type ToolMessage, } from './tool-result-injector.js';
@@ -14,6 +14,8 @@ export { formatMcpOutput, toModelVisibleToolContent, MAX_MODEL_RESULT_ROWS, MAX_
14
14
  export { ToolExecutionRouter, } from './tool-execution-router.js';
15
15
  export { createToolExecutor, EmptyInstallStore, } from './create-tool-executor.js';
16
16
  export { assembleLoadedTools, mergeHarnessPrimitives as mergeBuiltinTools, withHarnessPrimitives as withBuiltinTools, HarnessPrimitiveExecutor as BuiltinToolExecutor, resolveUserDir, resolveJailFromState, captureOverlayJailOptions, collectToolBundlePaths, } from './harness-primitives.js';
17
+ export { injectPublicArtifactLinks, collectPublicArtifactLinks, applyInjectedAssistant, } from './inject-public-artifact-links.js';
18
+ export { publishFromFiles, ensureUserSlots, publicArtifactUrl, PUBLIC_EXTENSIONS, } from './static-artifact-url.js';
17
19
  export { resolveShellSpawn, shellNameFor, commandDialectLines, findPythonInterpreter, findNodeInterpreter, ensureInterpreterShims, substituteSkillDir, } from './harness-command.js';
18
20
  export { expandSkillScriptRefs, isSkillEntryFile, EAP_SKILL_DIR_ENV, } from './skill-script-refs.js';
19
21
  export { ToolResultInjector, } from './tool-result-injector.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 本轮带 url 的公开副本 → 助手正文末尾 GFM 链接。
3
+ * stream / streamResumeHitl / stream.handler / invoke.handler 共用。
4
+ */
5
+ export type ArtifactLink = {
6
+ fileName: string;
7
+ url: string;
8
+ };
9
+ export type ToolResultLike = {
10
+ content?: unknown;
11
+ file?: {
12
+ fileName?: string;
13
+ url?: string;
14
+ };
15
+ files?: Array<{
16
+ fileName?: string;
17
+ url?: string;
18
+ }>;
19
+ };
20
+ export declare function mergeArtifactLinks(...groups: ArtifactLink[][]): ArtifactLink[];
21
+ export declare function collectPublicArtifactLinks(results: ToolResultLike[]): ArtifactLink[];
22
+ export declare function collectPublicArtifactLinksFromEvent(eventType: string, payload: unknown, bucket: ArtifactLink[]): void;
23
+ export declare function toolResultHasArtifacts(result: ToolResultLike): boolean;
24
+ export declare function applyInjectedAssistant<T extends {
25
+ role?: string;
26
+ content: string;
27
+ toolCalls?: unknown[];
28
+ }>(messages: T[], injected: string, links: ArtifactLink[]): T[];
29
+ export declare function injectPublicArtifactLinks(visibleText: string, artifacts: ArtifactLink[]): {
30
+ injected: string;
31
+ suffix: string;
32
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 本轮带 url 的公开副本 → 助手正文末尾 GFM 链接。
3
+ * stream / streamResumeHitl / stream.handler / invoke.handler 共用。
4
+ */
5
+ function escapeMdLinkText(text) {
6
+ return text.replace(/\\/g, '\\\\').replace(/([\[\]()])/g, '\\$1');
7
+ }
8
+ function addLink(out, seen, fileName, url) {
9
+ const href = url.trim();
10
+ if (!href || seen.has(href))
11
+ return;
12
+ seen.add(href);
13
+ out.push({ fileName: fileName.trim() || href, url: href });
14
+ }
15
+ function parseContentLinks(content, out, seen) {
16
+ if (typeof content !== 'string' || !content.trim())
17
+ return;
18
+ const first = content.split('\n', 1)[0] ?? '';
19
+ try {
20
+ const parsed = JSON.parse(first);
21
+ if (Array.isArray(parsed.publicFiles)) {
22
+ for (const item of parsed.publicFiles) {
23
+ if (!item || typeof item !== 'object')
24
+ continue;
25
+ const row = item;
26
+ if (typeof row.url === 'string') {
27
+ const name = (typeof row.fileName === 'string' && row.fileName) ||
28
+ (typeof row.path === 'string' && row.path) ||
29
+ 'file';
30
+ addLink(out, seen, name, row.url);
31
+ }
32
+ }
33
+ return;
34
+ }
35
+ if (parsed.ok === true && typeof parsed.url === 'string') {
36
+ const name = typeof parsed.path === 'string' ? parsed.path : 'file';
37
+ addLink(out, seen, name, parsed.url);
38
+ }
39
+ }
40
+ catch {
41
+ // 非 JSON 行忽略
42
+ }
43
+ }
44
+ export function mergeArtifactLinks(...groups) {
45
+ const out = [];
46
+ const seen = new Set();
47
+ for (const group of groups) {
48
+ for (const item of group)
49
+ addLink(out, seen, item.fileName, item.url);
50
+ }
51
+ return out;
52
+ }
53
+ export function collectPublicArtifactLinks(results) {
54
+ const out = [];
55
+ const seen = new Set();
56
+ for (const result of results) {
57
+ if (result.file?.url) {
58
+ addLink(out, seen, result.file.fileName ?? result.file.url, result.file.url);
59
+ }
60
+ for (const f of result.files ?? []) {
61
+ if (f?.url)
62
+ addLink(out, seen, f.fileName ?? f.url, f.url);
63
+ }
64
+ parseContentLinks(result.content, out, seen);
65
+ }
66
+ return out;
67
+ }
68
+ export function collectPublicArtifactLinksFromEvent(eventType, payload, bucket) {
69
+ if (!payload || typeof payload !== 'object')
70
+ return;
71
+ const p = payload;
72
+ const seen = new Set(bucket.map((l) => l.url));
73
+ if (eventType === 'file.created' && typeof p.url === 'string') {
74
+ addLink(bucket, seen, typeof p.fileName === 'string' ? p.fileName : p.url, p.url);
75
+ return;
76
+ }
77
+ if (eventType === 'tool.result' || eventType === 'tool.error') {
78
+ const extra = collectPublicArtifactLinks([
79
+ { content: p.content, file: p.file, files: p.files },
80
+ ]);
81
+ for (const link of extra)
82
+ addLink(bucket, seen, link.fileName, link.url);
83
+ }
84
+ }
85
+ export function toolResultHasArtifacts(result) {
86
+ if (result.file?.url)
87
+ return true;
88
+ if ((result.files ?? []).some((f) => Boolean(f?.url)))
89
+ return true;
90
+ if (typeof result.content !== 'string' || !result.content.trim())
91
+ return false;
92
+ const first = result.content.split('\n', 1)[0] ?? '';
93
+ try {
94
+ const parsed = JSON.parse(first);
95
+ if (Array.isArray(parsed.publicFiles) && parsed.publicFiles.length > 0)
96
+ return true;
97
+ if (parsed.ok === true && typeof parsed.url === 'string' && parsed.url)
98
+ return true;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ return false;
104
+ }
105
+ export function applyInjectedAssistant(messages, injected, links) {
106
+ if (!injected || links.length === 0)
107
+ return messages;
108
+ let idx = -1;
109
+ for (let i = messages.length - 1; i >= 0; i--) {
110
+ const m = messages[i];
111
+ if (m.role !== 'assistant')
112
+ continue;
113
+ if (!m.content.trim())
114
+ continue;
115
+ idx = i;
116
+ break;
117
+ }
118
+ if (idx >= 0) {
119
+ const copy = messages.slice();
120
+ copy[idx] = { ...copy[idx], content: injected };
121
+ return copy;
122
+ }
123
+ return [...messages, { role: 'assistant', content: injected }];
124
+ }
125
+ export function injectPublicArtifactLinks(visibleText, artifacts) {
126
+ const seen = new Set();
127
+ const lines = [];
128
+ for (const item of artifacts) {
129
+ const url = item.url.trim();
130
+ if (!url || seen.has(url))
131
+ continue;
132
+ seen.add(url);
133
+ if (visibleText.includes(url))
134
+ continue;
135
+ lines.push(`[${escapeMdLinkText(item.fileName)}](${url})`);
136
+ }
137
+ if (lines.length === 0)
138
+ return { injected: visibleText, suffix: '' };
139
+ const block = lines.join('\n');
140
+ const suffix = visibleText ? `\n\n${block}` : block;
141
+ return { injected: `${visibleText}${suffix}`, suffix };
142
+ }