@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
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { randomUUID } from 'node:crypto';
18
18
  import { buildHitlWaitingTaskOutput, loadModelFacingHistory, messageRecordsToGraphMessages, persistGraphMessageBaseline, turnGraphMessagesAfterCompression, persistHitlWaitingPlaceholder, persistTurnTranscript, persistUserTurnMessage, resolveCheckpointThreadId, sessionVisibleGraphMessages, } from '../../session/persist-turn-messages.js';
19
+ import { applyInjectedAssistant, collectPublicArtifactLinks, injectPublicArtifactLinks, } from '../../tool/inject-public-artifact-links.js';
19
20
  import { SessionBudgetExhaustedError } from '../../token/session-budget-checker.js';
20
21
  import { TokenBudgetExhaustedError } from '../../token/token-budget-guard.js';
21
22
  import { recordTaskTokenUsages, tokenUsagesFromLlmChannel, } from '../../token/record-task-token-usage.js';
@@ -380,21 +381,24 @@ export class InvokeOrchestrator {
380
381
  },
381
382
  ]);
382
383
  }
383
- // ─── Step 7: 完成 Task (running → success) ───
384
- await this.taskService.updateStatus(task.id, 'success', graphResult.output);
385
384
  // ─── Step 7b: 持久化本轮 transcript(统一顺序:assistant tool_calls → tool → assistant 文本)───
386
385
  // 切点是入图 messages 基线(压缩 `__set` 后 Graph 短于 Session 全文)。user 已在 4b 写入。
387
386
  const allMessages = Array.isArray(graphResult.finalState?.messages)
388
387
  ? graphResult.finalState.messages
389
388
  : [];
390
389
  const newMessages = sessionVisibleGraphMessages(allMessages.slice(persistBaseline), finishPoints);
390
+ const links = collectPublicArtifactLinks(allMessages);
391
+ const { injected } = injectPublicArtifactLinks(graphResult.output ?? '', links);
392
+ const persisted = applyInjectedAssistant(newMessages, injected, links);
393
+ // ─── Step 7: 完成 Task (running → success) ───
394
+ await this.taskService.updateStatus(task.id, 'success', injected);
391
395
  const duration = Date.now() - startTime;
392
396
  await persistTurnTranscript(this.messageService, {
393
397
  sessionId,
394
398
  taskId: task.id,
395
399
  correlationId,
396
- messages: newMessages,
397
- fallbackAssistantContent: graphResult.output ?? '',
400
+ messages: persisted,
401
+ fallbackAssistantContent: injected,
398
402
  assistantTokenCount: tokenUsage.output,
399
403
  assistantDurationMs: duration,
400
404
  });
@@ -404,7 +408,7 @@ export class InvokeOrchestrator {
404
408
  return this.buildResponse(traceId, {
405
409
  taskId: task.id,
406
410
  sessionId,
407
- output: graphResult.output,
411
+ output: injected,
408
412
  tokenUsage,
409
413
  duration,
410
414
  status: 'success',
@@ -20,6 +20,7 @@
20
20
  * @see requirements.md Requirement 11 AC2
21
21
  */
22
22
  import { buildHitlWaitingTaskOutput, buildTurnTranscriptFromStreamEvents, loadModelFacingHistory, turnGraphMessagesAfterCompression, extractStreamReasoning, applyStreamTextDelta, createStreamAssistantBuckets, visibleStreamAssistantText, streamAssistantTexts, messageRecordsToGraphMessages, persistHitlWaitingPlaceholder, persistTurnTranscript, persistUserTurnMessage, appendTerminalToolResult, } from '../../session/persist-turn-messages.js';
23
+ import { collectPublicArtifactLinks, collectPublicArtifactLinksFromEvent, injectPublicArtifactLinks, mergeArtifactLinks, } from '../../tool/inject-public-artifact-links.js';
23
24
  import { SessionBudgetExhaustedError } from '../../token/session-budget-checker.js';
24
25
  import { TokenBudgetExhaustedError } from '../../token/token-budget-guard.js';
25
26
  import { recordTaskTokenUsages } from '../../token/record-task-token-usage.js';
@@ -268,6 +269,7 @@ export class StreamOrchestrator {
268
269
  let assistantReasoningContent;
269
270
  const pendingToolCalls = [];
270
271
  const toolResults = [];
272
+ const artifactLinks = [];
271
273
  // ─── Step 6: Graph.stream() 流式执行 ───
272
274
  try {
273
275
  const eventStream = runWithExecutionTrace({ abortSignal }, () => this.graphEngine.stream(graph, {
@@ -326,6 +328,7 @@ export class StreamOrchestrator {
326
328
  }
327
329
  }
328
330
  appendTerminalToolResult(runtimeEvent.type, runtimeEvent.payload, toolResults);
331
+ collectPublicArtifactLinksFromEvent(runtimeEvent.type, runtimeEvent.payload, artifactLinks);
329
332
  const streamedReasoning = extractStreamReasoning(runtimeEvent.type, runtimeEvent.payload);
330
333
  if (streamedReasoning)
331
334
  assistantReasoningContent = streamedReasoning;
@@ -387,10 +390,18 @@ export class StreamOrchestrator {
387
390
  this.timeoutManager?.clearTimer(taskId);
388
391
  }
389
392
  // ─── Step 7: 发送结束事件序列 ───
390
- // Emit: response.final(带完整 assistant 文本,供客户端兜底)
391
393
  const tokenStats = this.tokenStatsService.getTaskStats(taskId);
394
+ const visible = visibleStreamAssistantText(assistantBuckets);
395
+ const links = mergeArtifactLinks(artifactLinks, collectPublicArtifactLinks(toolResults));
396
+ const { injected, suffix } = injectPublicArtifactLinks(visible, links);
397
+ if (suffix) {
398
+ emitter.emit(this.buildSSEEvent('response.chunk', correlationId, taskId, {
399
+ content: suffix,
400
+ format: 'text',
401
+ }));
402
+ }
392
403
  emitter.emit(this.buildSSEEvent('response.final', correlationId, taskId, {
393
- content: visibleStreamAssistantText(assistantBuckets),
404
+ content: injected,
394
405
  format: 'text',
395
406
  }));
396
407
  const totalTokens = (tokenStats.total.input ?? 0) + (tokenStats.total.output ?? 0);
@@ -424,8 +435,8 @@ export class StreamOrchestrator {
424
435
  messages: buildTurnTranscriptFromStreamEvents({
425
436
  pendingToolCalls,
426
437
  toolResults,
427
- assistantTexts: streamAssistantTexts(assistantBuckets),
428
- assistantText: visibleStreamAssistantText(assistantBuckets),
438
+ assistantTexts: suffix ? undefined : streamAssistantTexts(assistantBuckets),
439
+ assistantText: injected,
429
440
  finishPoints: graph.finishPoints,
430
441
  toolCallReasoningContent,
431
442
  assistantReasoningContent,
@@ -4,3 +4,4 @@ export * from './health.js';
4
4
  export * from './agent-code.js';
5
5
  export * from './trace-id.js';
6
6
  export { RUNTIME_PACKAGE_VERSION } from './runtime-package-version.js';
7
+ export { userRoot, USERS_DIR_NAME, MISSING_USER_DIR } from './user-root.js';
@@ -4,3 +4,4 @@ export * from './health.js';
4
4
  export * from './agent-code.js';
5
5
  export * from './trace-id.js';
6
6
  export { RUNTIME_PACKAGE_VERSION } from './runtime-package-version.js';
7
+ export { userRoot, USERS_DIR_NAME, MISSING_USER_DIR } from './user-root.js';
@@ -0,0 +1,8 @@
1
+ export declare const MISSING_USER_DIR = "\u7F3A\u5C11\u7528\u6237\u76EE\u5F55\uFF0C\u62D2\u7EDD\u8BE5\u64CD\u4F5C";
2
+ /** `agents/<code>/users/`:与 `<ver>` 平级,不是版本目录。 */
3
+ export declare const USERS_DIR_NAME = "users";
4
+ /**
5
+ * jail 写根:`dirname(agentDir)/users/<userId>`。
6
+ * `agentDir` 仍是版本目录 `agents/<code>/<ver>`。
7
+ */
8
+ export declare function userRoot(agentDir: string, userId: string): string;
@@ -0,0 +1,19 @@
1
+ import path from 'node:path';
2
+ export const MISSING_USER_DIR = '缺少用户目录,拒绝该操作';
3
+ /** `agents/<code>/users/`:与 `<ver>` 平级,不是版本目录。 */
4
+ export const USERS_DIR_NAME = 'users';
5
+ /**
6
+ * jail 写根:`dirname(agentDir)/users/<userId>`。
7
+ * `agentDir` 仍是版本目录 `agents/<code>/<ver>`。
8
+ */
9
+ export function userRoot(agentDir, userId) {
10
+ const dir = agentDir.trim();
11
+ const uid = userId.trim();
12
+ if (!dir || !uid) {
13
+ throw new Error(MISSING_USER_DIR);
14
+ }
15
+ if (uid === '.' || uid === '..' || /[\\/]/.test(uid)) {
16
+ throw new Error('非法用户标识');
17
+ }
18
+ return path.resolve(path.dirname(dir), USERS_DIR_NAME, uid);
19
+ }
@@ -34,8 +34,8 @@ import { preloadSkillNotFound } from '../../registry/preload-errors.js';
34
34
  import { AppError, ErrorCodes } from '@gt-fe/eap-contracts';
35
35
  import { pickContextFields } from '../../token/context-window.js';
36
36
  import { isHitlDecision, lastAssistantHasUnansweredToolCalls } from '../hitl/hitl-handler.js';
37
- import { classifySkillRead } from './skill-read.js';
38
- import { skillEntryInvocationFromTool } from './skill-entry-span.js';
37
+ import { classifySkillFile, isReadTool } from './skill-read.js';
38
+ import { recordSkillEntrySpan, skillEntryInvocationFromTool } from './skill-entry-span.js';
39
39
  import { llmSpanInput, llmSpanOutputFromResult, withGraphSpan } from '../trace-span.js';
40
40
  import { executionTraceFromState, getExecutionStream, getExecutionTrace, runWithExecutionTrace, } from '../execution-trace-context.js';
41
41
  import { isRetryableToolFailure, resolveToolRetryConfig, sleepToolRetry, toolRetryDelayMs, } from '../../tool/tool-retry.js';
@@ -283,8 +283,10 @@ export class LangGraphAdapter {
283
283
  ...(progress.node ? { node: progress.node } : {}),
284
284
  ...(progress.skillId ? { skillId: progress.skillId } : {}),
285
285
  };
286
- const skillRead = classifySkillRead(progress.toolName, progress.arguments, progress.skillDirs);
287
- if (skillRead && progress.status === 'completed') {
286
+ const skillRead = progress.status === 'completed' && progress.resolvedPath && isReadTool(progress.toolName)
287
+ ? classifySkillFile(progress.resolvedPath, progress.skillDirs)
288
+ : null;
289
+ if (skillRead) {
288
290
  if (skillRead.kind === 'loaded') {
289
291
  const skillId = skillRead.skillId || loc.skillId || skillRead.path;
290
292
  stream.sink({
@@ -299,7 +301,7 @@ export class LangGraphAdapter {
299
301
  });
300
302
  const catalogSkill = skillEntryInvocationFromTool({
301
303
  toolName: progress.toolName,
302
- arguments: progress.arguments,
304
+ resolvedPath: progress.resolvedPath,
303
305
  skillDirs: progress.skillDirs,
304
306
  alreadyInSkillSpan: progress.inSkillSubgraph,
305
307
  });
@@ -355,12 +357,14 @@ export class LangGraphAdapter {
355
357
  },
356
358
  });
357
359
  }
360
+ // resolvedPath / skillDirs 只给上面 classifySkillFile;inSkillSubgraph 只给 skill.started 去重。不进对话流。
361
+ const { resolvedPath: _resolvedPath, skillDirs: _skillDirs, inSkillSubgraph: _inSkillSubgraph, ...progressView } = progress;
358
362
  stream.sink({
359
363
  type: 'tool.progress',
360
364
  taskId,
361
365
  correlationId,
362
366
  timestamp,
363
- payload: { ...progress },
367
+ payload: progressView,
364
368
  });
365
369
  if (progress.status === 'completed') {
366
370
  stream.sink({
@@ -374,6 +378,8 @@ export class LangGraphAdapter {
374
378
  ...(progress.toolset ? { toolset: progress.toolset } : {}),
375
379
  content: progress.content,
376
380
  success: true,
381
+ ...(progress.file ? { file: progress.file } : {}),
382
+ ...(progress.files?.length ? { files: progress.files } : {}),
377
383
  ...loc,
378
384
  },
379
385
  });
@@ -390,6 +396,9 @@ export class LangGraphAdapter {
390
396
  ...(progress.toolset ? { toolset: progress.toolset } : {}),
391
397
  error: progress.error ?? 'Unknown error',
392
398
  willRetry: progress.willRetry === true,
399
+ ...(progress.content ? { content: progress.content } : {}),
400
+ ...(progress.file ? { file: progress.file } : {}),
401
+ ...(progress.files?.length ? { files: progress.files } : {}),
393
402
  ...loc,
394
403
  },
395
404
  });
@@ -931,6 +940,13 @@ export class LangGraphAdapter {
931
940
  node: node.id,
932
941
  });
933
942
  const skillDefs = state['skillDefinitions'];
943
+ const jail = resolveJailFromState({
944
+ agentDir: state['agentDir'],
945
+ userId: state['userId'],
946
+ executionContext: state['executionContext'],
947
+ loadedTools: state['loadedTools'],
948
+ skillDefinitions: skillDefs,
949
+ });
934
950
  const jailExtraRoots = Object.values(skillDefs ?? {})
935
951
  .map((skill) => skill.bundlePath)
936
952
  .filter((p) => Boolean(p));
@@ -941,6 +957,7 @@ export class LangGraphAdapter {
941
957
  authContext: { userId, delegationToken, agentCode },
942
958
  correlationId,
943
959
  taskId,
960
+ ...(jail.workdir ? { jailWorkdir: jail.workdir } : {}),
944
961
  ...(jailExtraRoots.length > 0 ? { jailExtraRoots } : {}),
945
962
  };
946
963
  const retry = resolveToolRetryConfig(node.retry);
@@ -976,13 +993,21 @@ export class LangGraphAdapter {
976
993
  executionTime,
977
994
  node: node.id,
978
995
  ...(skillDirs.length > 0 ? { skillDirs } : {}),
996
+ ...(result.resolvedPath ? { resolvedPath: result.resolvedPath } : {}),
979
997
  });
998
+ if (result.success) {
999
+ await recordSkillEntrySpan(state, {
1000
+ toolName: result.toolName,
1001
+ resolvedPath: result.resolvedPath,
1002
+ skillDirs,
1003
+ });
1004
+ }
980
1005
  // 重要:messages channel 的 reducer 是 append 语义,只返回新增消息
981
1006
  return {
982
1007
  messages: [
983
1008
  {
984
1009
  role: 'tool',
985
- content: result.content || JSON.stringify(result),
1010
+ content: result.content ?? '',
986
1011
  toolCallId,
987
1012
  toolName,
988
1013
  success: result.success ?? true,
@@ -1592,10 +1617,20 @@ export class LangGraphAdapter {
1592
1617
  continue;
1593
1618
  }
1594
1619
  if (msg.role === 'tool') {
1595
- // tool.result / tool.error 由 Tool 节点旁路发出,此处只补 file.created
1620
+ // tool.result / tool.error 由 Tool 节点旁路发出(已带 file/files);此处再补 file.created
1596
1621
  const raw = typeof msg.content === 'string' ? msg.content : '';
1597
- const file = msg.file ?? fileArtifactFromToolOutput(raw);
1598
- if (file) {
1622
+ const files = [
1623
+ ...(msg.file ? [msg.file] : []),
1624
+ ...(msg.files ?? []),
1625
+ ];
1626
+ if (files.length === 0) {
1627
+ const parsed = fileArtifactFromToolOutput(raw);
1628
+ if (parsed)
1629
+ files.push(parsed);
1630
+ }
1631
+ for (const file of files) {
1632
+ if (!file?.url)
1633
+ continue;
1599
1634
  yield {
1600
1635
  type: 'file.created',
1601
1636
  taskId: undefined,
@@ -1662,6 +1697,7 @@ export class LangGraphAdapter {
1662
1697
  success: typeof m['success'] === 'boolean' ? m['success'] : undefined,
1663
1698
  reasoningContent: typeof m['reasoningContent'] === 'string' ? m['reasoningContent'] : undefined,
1664
1699
  file: readFileArtifact(m['file']),
1700
+ files: readFileArtifactList(m['files']),
1665
1701
  };
1666
1702
  });
1667
1703
  }
@@ -1679,3 +1715,14 @@ function readFileArtifact(value) {
1679
1715
  url: rec.url,
1680
1716
  };
1681
1717
  }
1718
+ function readFileArtifactList(value) {
1719
+ if (!Array.isArray(value))
1720
+ return undefined;
1721
+ const out = [];
1722
+ for (const item of value) {
1723
+ const file = readFileArtifact(item);
1724
+ if (file)
1725
+ out.push(file);
1726
+ }
1727
+ return out.length > 0 ? out : undefined;
1728
+ }
@@ -2,6 +2,8 @@
2
2
  * 成功读 SKILL.md 时写 Trace `type=skill` Span。
3
3
  *
4
4
  * 假设:Skill 被调用 = 一次成功读取技能包入口(SKILL.md),
5
+ * 入口是执行器返回的实际文件(resolvedPath)对已装配 skillDirs。
6
+ * 没有实际路径不记,不再用 arguments.path 猜。
5
7
  * 与子图激活走同一 collector.createNode({ type: 'skill' })。
6
8
  * 声明版本在 createNode.version;skillSpace 只在 input。
7
9
  * 子图已有 skillSpanId 时不记第二条。write_file / 包内附属读 / 失败读不是调用。
@@ -23,7 +25,7 @@ export declare function skillSpanFields(identity: SkillTraceIdentity, extra?: {
23
25
  };
24
26
  export declare function skillEntryInvocationFromTool(input: {
25
27
  toolName: string;
26
- arguments?: Record<string, unknown>;
28
+ resolvedPath?: string;
27
29
  skillDirs?: SkillDirRef[];
28
30
  alreadyInSkillSpan?: boolean;
29
31
  }): {
@@ -32,6 +34,6 @@ export declare function skillEntryInvocationFromTool(input: {
32
34
  } | null;
33
35
  export declare function recordSkillEntrySpan(state: Record<string, unknown>, input: {
34
36
  toolName: string;
35
- arguments?: Record<string, unknown>;
37
+ resolvedPath?: string;
36
38
  skillDirs?: SkillDirRef[];
37
39
  }): Promise<void>;
@@ -2,11 +2,13 @@
2
2
  * 成功读 SKILL.md 时写 Trace `type=skill` Span。
3
3
  *
4
4
  * 假设:Skill 被调用 = 一次成功读取技能包入口(SKILL.md),
5
+ * 入口是执行器返回的实际文件(resolvedPath)对已装配 skillDirs。
6
+ * 没有实际路径不记,不再用 arguments.path 猜。
5
7
  * 与子图激活走同一 collector.createNode({ type: 'skill' })。
6
8
  * 声明版本在 createNode.version;skillSpace 只在 input。
7
9
  * 子图已有 skillSpanId 时不记第二条。write_file / 包内附属读 / 失败读不是调用。
8
10
  */
9
- import { classifySkillRead, classifySkillSpace } from './skill-read.js';
11
+ import { classifySkillFile, classifySkillSpace, isReadTool, } from './skill-read.js';
10
12
  import { completeGraphSpan, startGraphSpan } from '../trace-span.js';
11
13
  function declaredSkillVersion(skillId, version) {
12
14
  const raw = version?.trim();
@@ -46,7 +48,12 @@ export function skillSpanFields(identity, extra) {
46
48
  export function skillEntryInvocationFromTool(input) {
47
49
  if (input.alreadyInSkillSpan)
48
50
  return null;
49
- const hit = classifySkillRead(input.toolName, input.arguments, input.skillDirs);
51
+ if (!isReadTool(input.toolName))
52
+ return null;
53
+ const resolved = input.resolvedPath?.trim();
54
+ if (!resolved)
55
+ return null;
56
+ const hit = classifySkillFile(resolved, input.skillDirs);
50
57
  if (!hit || hit.kind !== 'loaded' || !hit.skillId)
51
58
  return null;
52
59
  return { skillId: hit.skillId, path: hit.path };
@@ -55,7 +62,7 @@ export async function recordSkillEntrySpan(state, input) {
55
62
  const alreadyInSkillSpan = typeof state['skillSpanId'] === 'string' && Boolean(state['skillSpanId']);
56
63
  const hit = skillEntryInvocationFromTool({
57
64
  toolName: input.toolName,
58
- arguments: input.arguments,
65
+ resolvedPath: input.resolvedPath,
59
66
  skillDirs: input.skillDirs,
60
67
  alreadyInSkillSpan,
61
68
  });
@@ -12,10 +12,26 @@ export declare function classifySkillSpace(bundleOrFilePath: string, opts?: {
12
12
  agentDir?: string;
13
13
  userId?: string;
14
14
  }): SkillSpace | undefined;
15
+ /** 与 Harness `read_file` 相同的相对路径定位(workdir / skillDir / overlay extraRoots)。 */
16
+ export interface SkillReadLocate {
17
+ workdir?: string;
18
+ skillDir?: string;
19
+ extraRoots?: string[];
20
+ }
21
+ export declare function isReadTool(toolName: string): boolean;
22
+ /**
23
+ * 实际读到的文件是否落在已装配技能包内。不解析定位符。
24
+ */
25
+ export declare function classifySkillFile(absPath: string, skillDirs?: SkillDirRef[]): {
26
+ kind: 'loaded' | 'resource';
27
+ path: string;
28
+ skillId: string;
29
+ } | null;
15
30
  /**
16
- * 判断 harness 读操作是否命中技能包,并用 bundlePath 最长前缀解析 skillId。
31
+ * 给 EAP_SKILL_DIR 推断:把 `arguments.path` 按 Harness 规则落到文件再对 skillDirs。
32
+ * Trace / SSE 入口判定走 `classifySkillFile`(实际读到的文件),不走这里。
17
33
  */
18
- export declare function classifySkillRead(toolName: string, args: Record<string, unknown> | undefined, skillDirs?: SkillDirRef[]): {
34
+ export declare function classifySkillRead(toolName: string, args: Record<string, unknown> | undefined, skillDirs?: SkillDirRef[], locate?: SkillReadLocate): {
19
35
  kind: 'loaded' | 'resource';
20
36
  path: string;
21
37
  skillId: string;
@@ -1,5 +1,7 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { userRoot } from '../../common/user-root.js';
4
+ import { resolveSkillReadTarget } from '../../tool/skill-relative-read.js';
3
5
  function tryRealpath(p) {
4
6
  try {
5
7
  return realpathSync(p);
@@ -29,10 +31,6 @@ function longestSkillMatch(filePath, skillDirs) {
29
31
  }
30
32
  return best;
31
33
  }
32
- function skillIdFromSkillsSegment(norm) {
33
- const match = norm.match(/(?:^|\/)(?:preinstall\/)?skills\/([^/]+)(?:\/|$)/i);
34
- return match?.[1] ?? '';
35
- }
36
34
  /**
37
35
  * 按实际 bundle 路径判定空间。有 agentDir 时相对预装根与 users/ 判定;
38
36
  * 否则看路径段 `preinstall/skills` / `users/<id>/skills`。
@@ -49,12 +47,20 @@ export function classifySkillSpace(bundleOrFilePath, opts) {
49
47
  return 'agent';
50
48
  const uid = opts?.userId?.trim();
51
49
  if (uid) {
52
- const user = `${root}/users/${uid}/skills`;
53
- if (target === user || target.startsWith(`${user}/`))
54
- return 'user';
50
+ try {
51
+ const user = normalizeForMatch(path.join(userRoot(agentDir, uid), 'skills'));
52
+ if (target === user || target.startsWith(`${user}/`))
53
+ return 'user';
54
+ }
55
+ catch {
56
+ // 非法 userId 时不按用户槽判定
57
+ }
55
58
  }
56
- if (new RegExp(`^${escapeRegExp(root)}/users/[^/]+/skills(/|$)`).test(target))
59
+ const usersRoot = normalizeForMatch(path.join(path.dirname(agentDir), 'users'));
60
+ if ((target === usersRoot || target.startsWith(`${usersRoot}/`)) &&
61
+ /(^|\/)skills(\/|$)/.test(target.slice(usersRoot.length))) {
57
62
  return 'user';
63
+ }
58
64
  return 'local';
59
65
  }
60
66
  if (/(^|\/)preinstall\/skills(\/|$)/i.test(target))
@@ -63,34 +69,63 @@ export function classifySkillSpace(bundleOrFilePath, opts) {
63
69
  return 'user';
64
70
  return undefined;
65
71
  }
66
- function escapeRegExp(value) {
67
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ function extraRootsForSkillRead(skillDirs, locate) {
73
+ const out = [];
74
+ for (const dir of skillDirs ?? []) {
75
+ if (dir.bundlePath)
76
+ out.push(dir.bundlePath);
77
+ }
78
+ for (const root of locate?.extraRoots ?? []) {
79
+ if (root)
80
+ out.push(root);
81
+ }
82
+ return out;
83
+ }
84
+ export function isReadTool(toolName) {
85
+ const n = toolName.toLowerCase().replace(/-/g, '_');
86
+ return n === 'read_file' || n === 'read_skill_file' || n === 'readfile';
68
87
  }
69
88
  /**
70
- * 判断 harness 读操作是否命中技能包,并用 bundlePath 最长前缀解析 skillId。
89
+ * 实际读到的文件是否落在已装配技能包内。不解析定位符。
71
90
  */
72
- export function classifySkillRead(toolName, args, skillDirs) {
73
- const n = toolName.toLowerCase().replace(/-/g, '_');
74
- if (n !== 'read_file' && n !== 'read_skill_file' && n !== 'readfile')
91
+ export function classifySkillFile(absPath, skillDirs) {
92
+ const trimmed = absPath.trim();
93
+ if (!trimmed)
94
+ return null;
95
+ const matched = longestSkillMatch(trimmed, skillDirs);
96
+ if (!matched)
97
+ return null;
98
+ const resolved = normalizeForMatch(trimmed);
99
+ const isEntry = /(^|\/)(SKILL\.md|skill.md)$/i.test(resolved);
100
+ return {
101
+ kind: isEntry ? 'loaded' : 'resource',
102
+ path: resolved,
103
+ skillId: matched.skillId,
104
+ };
105
+ }
106
+ /**
107
+ * 给 EAP_SKILL_DIR 推断:把 `arguments.path` 按 Harness 规则落到文件再对 skillDirs。
108
+ * Trace / SSE 入口判定走 `classifySkillFile`(实际读到的文件),不走这里。
109
+ */
110
+ export function classifySkillRead(toolName, args, skillDirs, locate) {
111
+ if (!isReadTool(toolName))
75
112
  return null;
76
113
  const filePath = String(args?.path ?? args?.file ?? '');
77
114
  if (!filePath)
78
115
  return null;
79
- const norm = filePath.replace(/\\/g, '/');
80
- const matched = longestSkillMatch(filePath, skillDirs);
81
- const isEntry = /(^|\/)(SKILL\.md|skill\.md)$/i.test(norm);
82
- if (matched) {
83
- return {
84
- kind: isEntry ? 'loaded' : 'resource',
85
- path: filePath,
86
- skillId: matched.skillId,
87
- };
88
- }
89
- if (isEntry) {
90
- return { kind: 'loaded', path: filePath, skillId: skillIdFromSkillsSegment(norm) };
91
- }
92
- if (/(^|\/)(?:preinstall\/)?skills\//i.test(norm)) {
93
- return { kind: 'resource', path: filePath, skillId: skillIdFromSkillsSegment(norm) };
94
- }
95
- return null;
116
+ const target = resolveSkillReadTarget(filePath, {
117
+ workdir: locate?.workdir,
118
+ skillDir: locate?.skillDir,
119
+ extraRoots: extraRootsForSkillRead(skillDirs, locate),
120
+ });
121
+ const matched = longestSkillMatch(target, skillDirs);
122
+ if (!matched)
123
+ return null;
124
+ const slash = target.replace(/\\/g, '/');
125
+ const isEntry = /(^|\/)(SKILL\.md|skill.md)$/i.test(slash);
126
+ return {
127
+ kind: isEntry ? 'loaded' : 'resource',
128
+ path: target,
129
+ skillId: matched.skillId,
130
+ };
96
131
  }
@@ -276,13 +276,19 @@ export interface GraphMessage {
276
276
  * 多轮请求必须原样回传,否则上游会 400。
277
277
  */
278
278
  reasoningContent?: string;
279
- /** 工具产出的文件;仅 Runtime 发 file.created,不进模型正文 */
279
+ /** 工具产出的文件;SSE tool.result / tool.error 与 file.created 共用,不进模型正文 */
280
280
  file?: {
281
281
  fileName: string;
282
282
  fileType: string;
283
283
  size: number;
284
284
  url: string;
285
285
  };
286
+ files?: Array<{
287
+ fileName: string;
288
+ fileType: string;
289
+ size: number;
290
+ url: string;
291
+ }>;
286
292
  }
287
293
  /** Tool 调用信息 */
288
294
  export interface ToolCallInfo {
@@ -10,7 +10,7 @@
10
10
  * @see requirements.md Requirement 8 AC1, AC3, AC5
11
11
  */
12
12
  import type { NodeRetryConfig } from '../contracts/executable-graph.js';
13
- import type { AuthContext } from '../../tool/tool-executor.interface.js';
13
+ import type { AuthContext, ToolCallResult } from '../../tool/tool-executor.interface.js';
14
14
  import type { ToolOrchestrator } from '../../tool/tool-orchestrator.js';
15
15
  import type { ToolResultInjector } from '../../tool/tool-result-injector.js';
16
16
  import type { SkillDirRef } from '../adapters/skill-read.js';
@@ -41,10 +41,15 @@ export interface ToolNodeProgress {
41
41
  arguments?: Record<string, unknown>;
42
42
  content?: string;
43
43
  error?: string;
44
+ /** 与 ToolCallResult.file / files 同形;completed 与失败终态都带 */
45
+ file?: ToolCallResult['file'];
46
+ files?: ToolCallResult['files'];
44
47
  executionTime?: number;
45
48
  node?: string;
46
49
  skillId?: string;
47
50
  skillDirs?: SkillDirRef[];
51
+ /** read_file 实际读到的文件;与 ToolCallResult.resolvedPath 相同 */
52
+ resolvedPath?: string;
48
53
  /** 已在 Skill 子图内(state.skillSpanId);读 SKILL.md 不再发 skill.started */
49
54
  inSkillSubgraph?: boolean;
50
55
  skillSpace?: 'agent' | 'user' | 'local';
@@ -19,9 +19,17 @@ import { resolveCheckpointThreadId } from '../../session/persist-turn-messages.j
19
19
  import { mcpToolsetCode, toolTraceIdentity } from '../../tool/mcp-list.js';
20
20
  import { resolveExecutionTrace, warnTraceSpanFailure, } from '../execution-trace-context.js';
21
21
  import { emitRuntimeLog } from '../../common/logger.js';
22
+ import { toolResultHasArtifacts } from '../../tool/inject-public-artifact-links.js';
22
23
  import { decideBeforeTool, } from '../../execution/interceptors.js';
23
24
  import { isRetryableToolFailure, resolveToolRetryConfig, sleepToolRetry, toolRetryDelayMs, } from '../../tool/tool-retry.js';
24
25
  import { isIsolatedSkillState } from '../adapters/skill-isolate.js';
26
+ function toolArtifactFields(result) {
27
+ return {
28
+ ...(result.content ? { content: result.content } : {}),
29
+ ...(result.file ? { file: result.file } : {}),
30
+ ...(result.files?.length ? { files: result.files } : {}),
31
+ };
32
+ }
25
33
  function skillDirsFromState(state) {
26
34
  const defs = state['skillDefinitions'];
27
35
  return Object.values(defs ?? {})
@@ -287,7 +295,7 @@ export class ToolNode {
287
295
  if (result.success) {
288
296
  const catalog = skillEntryInvocationFromTool({
289
297
  toolName: req.toolName,
290
- arguments: req.arguments,
298
+ resolvedPath: result.resolvedPath,
291
299
  skillDirs,
292
300
  alreadyInSkillSpan: inSkillSubgraph,
293
301
  });
@@ -302,7 +310,9 @@ export class ToolNode {
302
310
  status: 'completed',
303
311
  arguments: req.arguments,
304
312
  content: result.content,
313
+ ...toolArtifactFields(result),
305
314
  executionTime,
315
+ ...(result.resolvedPath ? { resolvedPath: result.resolvedPath } : {}),
306
316
  ...(skillIdentity?.skillSpace ? { skillSpace: skillIdentity.skillSpace } : {}),
307
317
  ...(skillIdentity?.skillVersion ? { skillVersion: skillIdentity.skillVersion } : {}),
308
318
  ...(skillIdentity?.skillName ? { skillName: skillIdentity.skillName } : {}),
@@ -317,6 +327,7 @@ export class ToolNode {
317
327
  status: 'error',
318
328
  arguments: req.arguments,
319
329
  error: result.error ?? 'unknown error',
330
+ ...toolArtifactFields(result),
320
331
  executionTime,
321
332
  });
322
333
  }
@@ -336,7 +347,7 @@ export class ToolNode {
336
347
  if (result.success) {
337
348
  await recordSkillEntrySpan(state, {
338
349
  toolName: req.toolName,
339
- arguments: req.arguments,
350
+ resolvedPath: result.resolvedPath,
340
351
  skillDirs,
341
352
  });
342
353
  }
@@ -421,12 +432,15 @@ export class ToolNode {
421
432
  role: 'tool',
422
433
  content: result.success
423
434
  ? result.content
424
- : `[Tool Error] ${result.error ?? 'unknown error'}`,
435
+ : toolResultHasArtifacts(result) && result.content
436
+ ? `${result.content}\n[Tool Error] ${result.error ?? 'unknown error'}`
437
+ : `[Tool Error] ${result.error ?? 'unknown error'}`,
425
438
  // 以下字段符合 LLM function-calling 协议(ToolMessage 接口)
426
439
  toolCallId: result.toolCallId,
427
440
  toolName: result.toolName,
428
441
  success: result.success,
429
442
  ...('file' in result && result.file ? { file: result.file } : {}),
443
+ ...('files' in result && result.files?.length ? { files: result.files } : {}),
430
444
  }));
431
445
  const lastSpanId = [...spanIds].reverse().find((id) => Boolean(id));
432
446
  return {
@@ -477,6 +491,7 @@ export class ToolNode {
477
491
  status: 'error',
478
492
  arguments: req.arguments,
479
493
  error: last.error ?? 'unknown error',
494
+ ...toolArtifactFields(last),
480
495
  willRetry: true,
481
496
  });
482
497
  await sleepToolRetry(toolRetryDelayMs(attempt, retry.backoff));