@gt-fe/eap-runtime 0.0.34 → 0.0.35

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.
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { buildHitlWaitingTaskOutput, buildTurnTranscriptFromStreamEvents, loadModelFacingHistory, turnGraphMessagesAfterCompression, applyStreamReasoningEvent, applyStreamTextDelta, createStreamAssistantBuckets, createStreamReasoningState, resolvedStreamReasoning, visibleStreamAssistantText, streamAssistantTexts, messageRecordsToGraphMessages, persistHitlWaitingPlaceholder, persistTurnTranscript, persistUserTurnMessage, appendTerminalToolResult, } from '../../session/persist-turn-messages.js';
23
23
  import { collectPublicArtifactLinks, collectPublicArtifactLinksFromEvent, injectPublicArtifactLinks, mergeArtifactLinks, } from '../../tool/inject-public-artifact-links.js';
24
+ import { correctEmployeeVisibleReply, employeeStreamClose, takeEmployeeVisibleEvents, } from '../../tool/mcp-tool-exposure.js';
24
25
  import { SessionBudgetExhaustedError } from '../../token/session-budget-checker.js';
25
26
  import { TokenBudgetExhaustedError } from '../../token/token-budget-guard.js';
26
27
  import { recordTaskTokenUsages } from '../../token/record-task-token-usage.js';
@@ -332,8 +333,10 @@ export class StreamOrchestrator {
332
333
  collectPublicArtifactLinksFromEvent(runtimeEvent.type, runtimeEvent.payload, artifactLinks);
333
334
  applyStreamReasoningEvent(reasoningState, runtimeEvent.type, runtimeEvent.payload);
334
335
  applyStreamTextDelta(assistantBuckets, runtimeEvent.type, runtimeEvent.payload);
335
- if (shouldEmitTokenSse(tokenDetail, runtimeEvent.type)) {
336
- emitter.emit(this.runtimeEventToSSE(runtimeEvent, correlationId, taskId));
336
+ for (const visibleEvent of takeEmployeeVisibleEvents(runtimeEvent.type, runtimeEvent.payload)) {
337
+ if (shouldEmitTokenSse(tokenDetail, visibleEvent.type)) {
338
+ emitter.emit(this.runtimeEventToSSE({ ...runtimeEvent, type: visibleEvent.type, payload: visibleEvent.payload }, correlationId, taskId));
339
+ }
337
340
  }
338
341
  }
339
342
  }
@@ -398,12 +401,14 @@ export class StreamOrchestrator {
398
401
  }
399
402
  // ─── Step 7: 发送结束事件序列 ───
400
403
  const tokenStats = this.tokenStatsService.getTaskStats(taskId);
401
- const visible = visibleStreamAssistantText(assistantBuckets);
404
+ const rawVisible = visibleStreamAssistantText(assistantBuckets);
405
+ const visible = correctEmployeeVisibleReply(rawVisible, toolResults);
402
406
  const links = mergeArtifactLinks(artifactLinks, collectPublicArtifactLinks(toolResults));
403
407
  const { injected, suffix } = injectPublicArtifactLinks(visible, links);
404
- if (suffix) {
408
+ const close = employeeStreamClose({ raw: rawVisible, tools: toolResults, suffix });
409
+ for (const content of close.chunks) {
405
410
  emitter.emit(this.buildSSEEvent('response.chunk', correlationId, taskId, {
406
- content: suffix,
411
+ content,
407
412
  format: 'text',
408
413
  }));
409
414
  }
@@ -442,7 +447,7 @@ export class StreamOrchestrator {
442
447
  messages: buildTurnTranscriptFromStreamEvents({
443
448
  pendingToolCalls,
444
449
  toolResults,
445
- assistantTexts: suffix ? undefined : streamAssistantTexts(assistantBuckets),
450
+ assistantTexts: close.persistCorrected ? undefined : streamAssistantTexts(assistantBuckets),
446
451
  assistantText: injected,
447
452
  finishPoints: graph.finishPoints,
448
453
  toolCallReasoningContent,
@@ -23,7 +23,8 @@ import { ToolResultInjector } from '../../tool/tool-result-injector.js';
23
23
  import { fileArtifactFromToolOutput } from '../../tool/file-artifact.js';
24
24
  import { EchoLLMProvider } from '../../providers/echo-llm-provider.js';
25
25
  import { ConditionEvaluator } from '../condition-evaluator.js';
26
- import { SystemPromptBuilder, buildSkillCatalog, loadedToolsToApiTools, resolveLlmPromptInputs, spokenName } from '../../tool/system-prompt-builder.js';
26
+ import { SystemPromptBuilder, buildSkillCatalog, resolveLlmPromptInputs, spokenName } from '../../tool/system-prompt-builder.js';
27
+ import { correctEmployeeVisibleReply, lastAssistantTextSinceLastUser, exposureScopeId, exposedNamesForScope, forcedToolName, holdEmployeeTextForUnnamedMcp, resolveModelToolSurface, shouldDropUnsettledEmployeeText, toolFactsSinceLastUser, } from '../../tool/mcp-tool-exposure.js';
27
28
  import { resolveJailFromState, resolveJailRoots } from '../../tool/harness-primitives.js';
28
29
  import os from 'node:os';
29
30
  import { bindSelectedSkill } from '../nodes/skill-bind-node.js';
@@ -417,7 +418,7 @@ export class LangGraphAdapter {
417
418
  });
418
419
  }
419
420
  }
420
- if (progress.status === 'started') {
421
+ if (progress.status === 'started' && (!progress.quiet || progress.modelOnly)) {
421
422
  stream.sink({
422
423
  type: 'tool.called',
423
424
  taskId,
@@ -429,19 +430,22 @@ export class LangGraphAdapter {
429
430
  ...(progress.toolset ? { toolset: progress.toolset } : {}),
430
431
  ...(progress.toolVersion ? { toolVersion: progress.toolVersion } : {}),
431
432
  arguments: progress.arguments ?? {},
433
+ ...(progress.modelOnly ? { modelOnly: true } : {}),
432
434
  ...loc,
433
435
  },
434
436
  });
435
437
  }
436
438
  // resolvedPath / skillDirs 只给上面 classifySkillFile;inSkillSubgraph 只给 skill.started 去重。不进对话流。
437
- const { resolvedPath: _resolvedPath, skillDirs: _skillDirs, inSkillSubgraph: _inSkillSubgraph, ...progressView } = progress;
438
- stream.sink({
439
- type: 'tool.progress',
440
- taskId,
441
- correlationId,
442
- timestamp,
443
- payload: progressView,
444
- });
439
+ const { resolvedPath: _resolvedPath, skillDirs: _skillDirs, inSkillSubgraph: _inSkillSubgraph, modelOnly: _modelOnly, ...progressView } = progress;
440
+ if (!progress.quiet) {
441
+ stream.sink({
442
+ type: 'tool.progress',
443
+ taskId,
444
+ correlationId,
445
+ timestamp,
446
+ payload: progressView,
447
+ });
448
+ }
445
449
  if (progress.status === 'completed') {
446
450
  stream.sink({
447
451
  type: 'tool.result',
@@ -456,6 +460,7 @@ export class LangGraphAdapter {
456
460
  success: true,
457
461
  ...(progress.file ? { file: progress.file } : {}),
458
462
  ...(progress.files?.length ? { files: progress.files } : {}),
463
+ ...(progress.modelOnly ? { modelOnly: true } : {}),
459
464
  ...loc,
460
465
  },
461
466
  });
@@ -472,6 +477,7 @@ export class LangGraphAdapter {
472
477
  ...(progress.toolset ? { toolset: progress.toolset } : {}),
473
478
  error: progress.error ?? 'Unknown error',
474
479
  willRetry: progress.willRetry === true,
480
+ ...(progress.modelOnly ? { modelOnly: true } : {}),
475
481
  ...(progress.content ? { content: progress.content } : {}),
476
482
  ...(progress.file ? { file: progress.file } : {}),
477
483
  ...(progress.files?.length ? { files: progress.files } : {}),
@@ -545,6 +551,9 @@ export class LangGraphAdapter {
545
551
  checkpointNs: ephemeral(undefined),
546
552
  skillIsolateContext: ephemeral(undefined),
547
553
  loadedTools: ephemeral(undefined),
554
+ exposedMcpTools: ephemeral(undefined),
555
+ exposedMcpScopeId: ephemeral(undefined),
556
+ sentMcpToolNames: ephemeral(undefined),
548
557
  loadedSkills: ephemeral(undefined),
549
558
  skillDefinitions: ephemeral(undefined),
550
559
  skillInput: ephemeral(undefined),
@@ -770,10 +779,8 @@ export class LangGraphAdapter {
770
779
  }
771
780
  const duration = Date.now() - startTime;
772
781
  const messages = Array.isArray(result['messages']) ? result['messages'] : [];
773
- const lastMessage = messages[messages.length - 1];
774
- const output = typeof lastMessage === 'object' && lastMessage !== null && 'content' in lastMessage
775
- ? String(lastMessage.content)
776
- : '';
782
+ const rawOutput = lastAssistantTextSinceLastUser(messages);
783
+ const output = correctEmployeeVisibleReply(rawOutput, toolFactsSinceLastUser(messages));
777
784
  // ── Token 累计:从 __llmTokenUsage 数组累加器读取每轮 LLM 明细 + 汇总
778
785
  // 数组由 channel reducer 自动累加(每次 LLM 节点追加一条)。
779
786
  const llmUsageArray = Array.isArray(result['__llmTokenUsage'])
@@ -798,6 +805,12 @@ export class LangGraphAdapter {
798
805
  : state.messages,
799
806
  userId: state.userId,
800
807
  loadedTools: state.loadedTools ?? result.loadedTools,
808
+ exposedMcpTools: Array.isArray(result['exposedMcpTools'])
809
+ ? result['exposedMcpTools']
810
+ : state.exposedMcpTools,
811
+ exposedMcpScopeId: typeof result['exposedMcpScopeId'] === 'string'
812
+ ? result['exposedMcpScopeId']
813
+ : state.exposedMcpScopeId,
801
814
  forceToolServer: state.forceToolServer ?? result.forceToolServer,
802
815
  executionContext: state.executionContext ?? result.executionContext,
803
816
  },
@@ -1202,10 +1215,18 @@ export class LangGraphAdapter {
1202
1215
  agentSystemPrompt: state['agentSystemPrompt'],
1203
1216
  nodePrompt: resolvedPrompt,
1204
1217
  });
1218
+ const surface = resolveModelToolSurface({
1219
+ loadedTools,
1220
+ disableTools,
1221
+ exposedNames: exposedNamesForScope(state['exposedMcpTools'], state['exposedMcpScopeId'], exposureScopeId({ sessionId: state['sessionId'], taskId: state['taskId'] })),
1222
+ forcedName: forcedToolName(baseConfig.toolChoice),
1223
+ });
1224
+ const sentMcpToolNames = surface.apiTools.map((tool) => tool.function.name);
1205
1225
  const assembled = this.systemPromptBuilder.build({
1206
1226
  agentPrompt: promptInputs.agentPrompt,
1207
1227
  nodePrompt: promptInputs.nodePrompt,
1208
1228
  skillCatalog: catalog,
1229
+ mcpToolIndex: surface.mcpToolIndex,
1209
1230
  agentName,
1210
1231
  agentCode,
1211
1232
  userId,
@@ -1214,7 +1235,10 @@ export class LangGraphAdapter {
1214
1235
  jailRoots,
1215
1236
  skillDirs,
1216
1237
  });
1217
- const apiTools = disableTools ? [] : loadedToolsToApiTools(loadedTools);
1238
+ const apiTools = surface.apiTools;
1239
+ const sentTools = disableTools
1240
+ ? undefined
1241
+ : (apiTools.length > 0 ? apiTools : baseConfig.tools);
1218
1242
  const finalConfig = {
1219
1243
  ...baseConfig,
1220
1244
  systemPrompt: assembled.text,
@@ -1226,7 +1250,7 @@ export class LangGraphAdapter {
1226
1250
  agentCode,
1227
1251
  correlationId: typeof state['correlationId'] === 'string' ? state['correlationId'] : undefined,
1228
1252
  },
1229
- tools: disableTools ? undefined : (apiTools.length > 0 ? apiTools : baseConfig.tools),
1253
+ tools: sentTools,
1230
1254
  toolChoice: disableTools ? 'none' : baseConfig.toolChoice,
1231
1255
  };
1232
1256
  // 流式旁路:有本次 stream 袋时启用 LLM token 推送;silent 节点不推给用户
@@ -1237,22 +1261,28 @@ export class LangGraphAdapter {
1237
1261
  stream.streamedTextThisTurn = false;
1238
1262
  stream.reasoningChunkIndex = 0;
1239
1263
  }
1264
+ const holdEmployeeText = holdEmployeeTextForUnnamedMcp(loadedTools, sentMcpToolNames);
1265
+ const heldDeltas = [];
1240
1266
  const llmNode = new LLMNode(node.id, finalConfig, this.llmProvider, enableStream, enableStream
1241
1267
  ? (delta) => {
1242
1268
  const live = getExecutionStream();
1243
1269
  if (!live)
1244
1270
  return;
1271
+ if (holdEmployeeText) {
1272
+ heldDeltas.push(delta);
1273
+ return;
1274
+ }
1245
1275
  emitLlmStreamDelta(live, node.id, finalConfig.model, delta);
1246
1276
  }
1247
1277
  : undefined, this.interceptors);
1248
- const toolsForSpan = disableTools || !loadedTools
1249
- ? undefined
1250
- : Object.values(loadedTools).map((t) => ({
1251
- name: t.name,
1252
- ...(t.manifest.description?.trim()
1253
- ? { description: t.manifest.description.trim() }
1278
+ const toolsForSpan = sentTools && sentTools.length > 0
1279
+ ? sentTools.map((tool) => ({
1280
+ name: tool.function.name,
1281
+ ...(tool.function.description?.trim()
1282
+ ? { description: tool.function.description.trim() }
1254
1283
  : {}),
1255
- }));
1284
+ }))
1285
+ : undefined;
1256
1286
  // 组装后再 createNode:input 必须是发给模型的 system/tools/messages;completeNode 不能补 input。
1257
1287
  return withGraphSpan(state, {
1258
1288
  name: node.id,
@@ -1266,12 +1296,31 @@ export class LangGraphAdapter {
1266
1296
  },
1267
1297
  }, async () => {
1268
1298
  const next = await llmNode.execute(state);
1269
- if (enableStream) {
1299
+ let dropEmployeeText = false;
1300
+ if (enableStream && holdEmployeeText) {
1301
+ const added = Array.isArray(next.messages) ? next.messages[0] : undefined;
1302
+ const calls = added && typeof added === 'object' && Array.isArray(added.toolCalls)
1303
+ ? added.toolCalls
1304
+ : [];
1305
+ dropEmployeeText = shouldDropUnsettledEmployeeText(calls, loadedTools, sentMcpToolNames);
1306
+ const live = getExecutionStream();
1307
+ if (dropEmployeeText) {
1308
+ // 挡住 updates 再把这段正文补发成 response.chunk。
1309
+ if (live)
1310
+ live.streamedTextThisTurn = true;
1311
+ }
1312
+ else if (live) {
1313
+ for (const delta of heldDeltas)
1314
+ emitLlmStreamDelta(live, node.id, finalConfig.model, delta);
1315
+ }
1316
+ }
1317
+ if (enableStream && !dropEmployeeText) {
1270
1318
  emitReasoningCompletedIfAny(getExecutionStream(), node.id, next);
1271
1319
  }
1272
1320
  const hydratedUpdate = loadedTools && loadedTools !== loadedToolsBase ? { loadedTools } : {};
1321
+ const sentUpdate = { sentMcpToolNames };
1273
1322
  if (!silent)
1274
- return { ...next, ...hydratedUpdate };
1323
+ return { ...next, ...hydratedUpdate, ...sentUpdate };
1275
1324
  const added = Array.isArray(next.messages) ? next.messages[0] : undefined;
1276
1325
  const raw = added && typeof added === 'object' && typeof added.content === 'string'
1277
1326
  ? added.content
@@ -1281,6 +1330,7 @@ export class LangGraphAdapter {
1281
1330
  messages: [],
1282
1331
  silentOutput: raw,
1283
1332
  ...hydratedUpdate,
1333
+ ...sentUpdate,
1284
1334
  };
1285
1335
  }, { outputFrom: llmSpanOutputFromResult });
1286
1336
  };
@@ -15,6 +15,7 @@ import { resolveExecutionTrace, warnTraceSpanFailure, } from '../execution-trace
15
15
  import { skillSpanFields, skillTraceIdentity } from './skill-entry-span.js';
16
16
  import { filterLoadedToolsForSkill, spokenName, } from '../../tool/system-prompt-builder.js';
17
17
  import { unwrapHitlPaused } from '../hitl/hitl-handler.js';
18
+ import { exposureScopeId, exposedNamesForScope, mergeExposed } from '../../tool/mcp-tool-exposure.js';
18
19
  import { pickContextFields } from '../../token/context-window.js';
19
20
  import { assertSkillIoIfEnabled, coerceSkillInputFromMessages, } from '../../registry/skill-io-guard.js';
20
21
  import { isolatedSkillMessages, isolatedSkillOutput, } from './skill-isolate.js';
@@ -163,6 +164,15 @@ export class SubGraphCompiler {
163
164
  skillIsolateContext: isolateContext || undefined,
164
165
  executionContext: state['executionContext'],
165
166
  loadedTools: scopedTools,
167
+ exposedMcpTools: Array.isArray(state['exposedMcpTools'])
168
+ ? state['exposedMcpTools']
169
+ : undefined,
170
+ exposedMcpScopeId: typeof state['exposedMcpScopeId'] === 'string'
171
+ ? state['exposedMcpScopeId']
172
+ : undefined,
173
+ sentMcpToolNames: Array.isArray(state['sentMcpToolNames'])
174
+ ? state['sentMcpToolNames']
175
+ : undefined,
166
176
  loadedSkills: state['loadedSkills'],
167
177
  skillDefinitions: defs,
168
178
  agentSystemPrompt: typeof state['agentSystemPrompt'] === 'string'
@@ -234,10 +244,25 @@ export class SubGraphCompiler {
234
244
  undefined;
235
245
  const parentNodeId = options?.parentNodeId
236
246
  || (dsl.name ? `subgraph:${dsl.name}` : 'subgraph');
247
+ const parentScope = exposureScopeId({
248
+ sessionId: state['sessionId'],
249
+ taskId: state['taskId'],
250
+ });
251
+ const childScope = result.finalState?.exposedMcpScopeId;
252
+ const parentNames = exposedNamesForScope(state['exposedMcpTools'], state['exposedMcpScopeId'], parentScope);
253
+ const childNames = childScope === parentScope && Array.isArray(result.finalState?.exposedMcpTools)
254
+ ? result.finalState.exposedMcpTools
255
+ : [];
237
256
  return {
238
257
  messages: outputMessages,
239
258
  currentNode: parentNodeId,
240
259
  historyPath: [parentNodeId],
260
+ ...(parentScope
261
+ ? {
262
+ exposedMcpTools: mergeExposed(parentNames, childNames),
263
+ exposedMcpScopeId: parentScope,
264
+ }
265
+ : {}),
241
266
  ...(nestedLastSpanId ? { lastSpanId: nestedLastSpanId } : {}),
242
267
  ...(Array.isArray(perNode) && perNode.length > 0
243
268
  ? {
@@ -197,6 +197,15 @@ export interface GraphState {
197
197
  ragSources?: string[];
198
198
  /** GraphLoader 注入:toolName → 运行期 manifest 引用 */
199
199
  loadedTools?: import('../../tool/loaded-tool-definition.js').LoadedToolMap;
200
+ /**
201
+ * 本会话已点名、下一次模型调用要带完整参数的 MCP 工具名。
202
+ * 与 exposedMcpScopeId 一起用。有 sessionId 时按会话,否则按 taskId。新会话不沿用。
203
+ */
204
+ exposedMcpTools?: string[];
205
+ /** 与 exposedMcpTools 对应的 sessionId,没有 sessionId 时是 taskId。 */
206
+ exposedMcpScopeId?: string;
207
+ /** 上一轮实际发给模型的工具名。工具节点用它判断没点名的 MCP 调用。 */
208
+ sentMcpToolNames?: string[];
200
209
  /** GraphLoader 注入:已装配 Skill 列表 */
201
210
  loadedSkills?: import('../../tool/system-prompt-builder.js').LoadedSkillInfo[];
202
211
  /** GraphLoader 注入:skillId → 完整定义(子图解析 / prompt) */
@@ -14,6 +14,7 @@ import { buildContextUsage, turnSectionChars } from '../../token/context-window.
14
14
  import { isAbortError } from '../../tool/timeout.js';
15
15
  import { emitRuntimeLog } from '../../common/logger.js';
16
16
  import { decideBeforeModel, } from '../../execution/interceptors.js';
17
+ import { projectHistoryForModelTools } from '../../tool/mcp-tool-exposure.js';
17
18
  // ─────────────────────────────────────────────
18
19
  // Default retry config
19
20
  // ─────────────────────────────────────────────
@@ -84,7 +85,8 @@ export class LLMNode {
84
85
  if (gate.action === 'deny') {
85
86
  throw new Error(gate.reason);
86
87
  }
87
- const messagesForModel = gate.messages ?? messagesWithSystem;
88
+ const toolNames = new Set((this.config.tools ?? []).map((tool) => tool.function.name));
89
+ const messagesForModel = projectHistoryForModelTools(gate.messages ?? messagesWithSystem, toolNames);
88
90
  const startedAt = Date.now();
89
91
  // 带重试的 LLM 调用
90
92
  const response = await this.invokeWithRetry(messagesForModel);
@@ -58,6 +58,10 @@ export interface ToolNodeProgress {
58
58
  toolVersion?: string;
59
59
  /** 本失败后是否还会再试。缺省 false */
60
60
  willRetry?: boolean;
61
+ /** 不发 tool.progress。tool_search 仍发 tool.result,供最终回复判断。 */
62
+ quiet?: boolean;
63
+ /** 只给模型。不进员工 SSE。未点名的调用用这个。 */
64
+ modelOnly?: boolean;
61
65
  }
62
66
  export declare class ToolNode {
63
67
  private readonly orchestrator;
@@ -17,6 +17,7 @@ import { isHitlDecision, unwrapHitlPaused } from '../hitl/hitl-handler.js';
17
17
  import { ToolConfirmationInterceptor } from '../../tool/tool-confirmation.js';
18
18
  import { resolveCheckpointThreadId } from '../../session/persist-turn-messages.js';
19
19
  import { mcpToolsetCode, toolTraceIdentity } from '../../tool/mcp-list.js';
20
+ import { exposureScopeId, exposedNamesForScope, isUnloadedDeferredCall, mcpNamesToExpose, mergeExposed, partitionToolSearchCalls, runToolSearch, unloadedCallToolContent, } from '../../tool/mcp-tool-exposure.js';
20
21
  import { resolveExecutionTrace, warnTraceSpanFailure, } from '../execution-trace-context.js';
21
22
  import { emitRuntimeLog } from '../../common/logger.js';
22
23
  import { toolResultHasArtifacts } from '../../tool/inject-public-artifact-links.js';
@@ -36,6 +37,28 @@ function skillDirsFromState(state) {
36
37
  .filter((skill) => Boolean(skill.bundlePath))
37
38
  .map((skill) => ({ skillId: skill.skillId, bundlePath: skill.bundlePath }));
38
39
  }
40
+ function exposureUpdate(state, searchCalls, businessNames, loadedTools, alreadyInTools) {
41
+ const scopeId = exposureScopeId({ sessionId: state['sessionId'], taskId: state['taskId'] });
42
+ const existing = exposedNamesForScope(state['exposedMcpTools'], state['exposedMcpScopeId'], scopeId);
43
+ const messagesById = new Map();
44
+ const searchExpose = [];
45
+ for (const call of searchCalls) {
46
+ const outcome = runToolSearch(call.arguments, loadedTools, alreadyInTools);
47
+ searchExpose.push(...outcome.expose);
48
+ messagesById.set(call.id, {
49
+ role: 'tool',
50
+ content: outcome.content,
51
+ toolCallId: call.id,
52
+ toolName: call.name,
53
+ success: true,
54
+ });
55
+ }
56
+ return {
57
+ messagesById,
58
+ exposedMcpTools: mergeExposed(existing, mcpNamesToExpose(loadedTools, searchExpose, businessNames)),
59
+ exposedMcpScopeId: scopeId,
60
+ };
61
+ }
39
62
  export class ToolNode {
40
63
  orchestrator;
41
64
  injector;
@@ -83,6 +106,15 @@ export class ToolNode {
83
106
  return state;
84
107
  }
85
108
  const loadedTools = state['loadedTools'];
109
+ const partitioned = partitionToolSearchCalls(lastAssistantMessage.toolCalls, loadedTools);
110
+ const sentMcpToolNames = Array.isArray(state['sentMcpToolNames'])
111
+ ? state['sentMcpToolNames']
112
+ : undefined;
113
+ const blockedCalls = partitioned.deferActive
114
+ ? partitioned.business.filter((call) => isUnloadedDeferredCall(loadedTools?.[call.name], sentMcpToolNames))
115
+ : [];
116
+ const blockedIds = new Set(blockedCalls.map((call) => call.id));
117
+ const executableCalls = partitioned.business.filter((call) => !blockedIds.has(call.id));
86
118
  const skillDirs = skillDirsFromState(state);
87
119
  const jail = resolveJailFromState({
88
120
  agentDir: state['agentDir'],
@@ -92,13 +124,13 @@ export class ToolNode {
92
124
  skillDefinitions: state['skillDefinitions'],
93
125
  });
94
126
  const jailExtraRoots = jail.extraRoots ?? [];
95
- const batchCalls = lastAssistantMessage.toolCalls.map((tc) => ({
127
+ const batchCalls = executableCalls.map((tc) => ({
96
128
  name: tc.name,
97
129
  arguments: tc.arguments,
98
130
  }));
99
131
  const boundSkill = typeof state['activatedSkill'] === 'string' ? state['activatedSkill'] : undefined;
100
132
  // 4. 转换为 ToolCallRequest[](附带 manifest 路由字段)
101
- const requests = lastAssistantMessage.toolCalls.map((tc) => {
133
+ const requests = executableCalls.map((tc) => {
102
134
  const loaded = loadedTools?.[tc.name];
103
135
  const toolsetCode = mcpToolsetCode(loaded);
104
136
  const correlationId = String(state['correlationId'] ?? tc.id);
@@ -170,6 +202,84 @@ export class ToolNode {
170
202
  ? state['hitlToolCallId']
171
203
  : undefined;
172
204
  const confirmationCount = requests.filter((req) => confirmationTools.has(req.toolName)).length;
205
+ // 点名先记进本轮返回值。HITL 抛出时这次返回作废,恢复后本节点会再跑一遍并带上这些名字。
206
+ const exposure = partitioned.deferActive
207
+ ? exposureUpdate(state, partitioned.search, partitioned.business.map((call) => call.name), loadedTools, sentMcpToolNames)
208
+ : undefined;
209
+ const blockedMessages = new Map();
210
+ const sideSpanIds = [];
211
+ const recordSideSpan = async (name, callId, input, output, notExecuted) => {
212
+ if (!traceCollector || !traceId)
213
+ return;
214
+ try {
215
+ const parentNodeId = typeof state['skillSpanId'] === 'string' && state['skillSpanId']
216
+ ? state['skillSpanId']
217
+ : undefined;
218
+ const spanId = await traceCollector.createNode({
219
+ traceId,
220
+ name,
221
+ type: 'tool',
222
+ parentNodeId,
223
+ agentCode: agentCode || undefined,
224
+ input,
225
+ metadata: {
226
+ toolCallId: callId,
227
+ ...(notExecuted ? { notExecuted: true } : {}),
228
+ },
229
+ });
230
+ sideSpanIds.push(spanId);
231
+ await traceCollector.completeNode(spanId, {
232
+ status: notExecuted ? 'failed' : 'completed',
233
+ output,
234
+ ...(notExecuted ? { error: output } : {}),
235
+ });
236
+ }
237
+ catch (err) {
238
+ warnTraceSpanFailure('createNode', { type: 'tool', name, traceId, toolCallId: callId }, err);
239
+ }
240
+ };
241
+ for (const call of partitioned.search) {
242
+ const content = exposure?.messagesById.get(call.id)?.content ?? '';
243
+ emitProgress({
244
+ toolCallId: call.id,
245
+ toolName: call.name,
246
+ status: 'completed',
247
+ arguments: call.arguments,
248
+ content,
249
+ quiet: true,
250
+ modelOnly: true,
251
+ });
252
+ await recordSideSpan(call.name, call.id, call.arguments, content, false);
253
+ }
254
+ for (const call of blockedCalls) {
255
+ const content = unloadedCallToolContent(call.name);
256
+ blockedMessages.set(call.id, {
257
+ role: 'tool',
258
+ content,
259
+ toolCallId: call.id,
260
+ toolName: call.name,
261
+ success: false,
262
+ });
263
+ emitProgress({
264
+ toolCallId: call.id,
265
+ toolName: call.name,
266
+ status: 'started',
267
+ arguments: call.arguments,
268
+ quiet: true,
269
+ modelOnly: true,
270
+ });
271
+ emitProgress({
272
+ toolCallId: call.id,
273
+ toolName: call.name,
274
+ status: 'error',
275
+ arguments: call.arguments,
276
+ error: content,
277
+ content,
278
+ quiet: true,
279
+ modelOnly: true,
280
+ });
281
+ await recordSideSpan(call.name, call.id, call.arguments, content, true);
282
+ }
173
283
  // 5. 并行执行所有 Tool 调用(附 Trace span)
174
284
  const spanIds = await Promise.all(requests.map(async (req) => {
175
285
  const reusePausedSpan = Boolean(resumeToolSpanId) &&
@@ -443,11 +553,29 @@ export class ToolNode {
443
553
  ...('file' in result && result.file ? { file: result.file } : {}),
444
554
  ...('files' in result && result.files?.length ? { files: result.files } : {}),
445
555
  }));
446
- const lastSpanId = [...spanIds].reverse().find((id) => Boolean(id));
556
+ const lastSpanId = [...spanIds, ...sideSpanIds].reverse().find((id) => Boolean(id));
557
+ const orderedMessages = (exposure && partitioned.intercept) || blockedMessages.size > 0
558
+ ? lastAssistantMessage.toolCalls.map((call) => {
559
+ const searched = exposure?.messagesById.get(call.id);
560
+ if (searched)
561
+ return searched;
562
+ const blocked = blockedMessages.get(call.id);
563
+ if (blocked)
564
+ return blocked;
565
+ const business = toolMessages.find((message) => message.toolCallId === call.id);
566
+ return business;
567
+ })
568
+ : toolMessages;
447
569
  return {
448
- messages: toolMessages,
570
+ messages: orderedMessages,
449
571
  currentNode: nodeId,
450
572
  ...(lastSpanId ? { lastSpanId } : {}),
573
+ ...(exposure
574
+ ? {
575
+ exposedMcpTools: exposure.exposedMcpTools,
576
+ exposedMcpScopeId: exposure.exposedMcpScopeId,
577
+ }
578
+ : {}),
451
579
  };
452
580
  }
453
581
  // ─────────────────────────────────────────────
@@ -270,6 +270,15 @@ function resolveMcpServer(manifest) {
270
270
  }
271
271
  return undefined;
272
272
  }
273
+ /** 门户把同一次注册的「用户确认」写在每个 tools[] 上。MCP 不展开这份清单,占位记录读这个值。 */
274
+ function mcpToolsRequireConfirmation(manifest) {
275
+ const tools = manifest.tools;
276
+ if (!Array.isArray(tools))
277
+ return false;
278
+ return tools.some((tool) => Boolean(tool &&
279
+ typeof tool === 'object' &&
280
+ tool.requireConfirmation));
281
+ }
273
282
  function mcpDiscoverStub(pre, options) {
274
283
  const m = pre.manifest;
275
284
  const server = resolveMcpServer(m);
@@ -306,7 +315,7 @@ function mcpDiscoverStub(pre, options) {
306
315
  dataScope: normalizeDataScope(m.permissions && typeof m.permissions === 'object' && !Array.isArray(m.permissions)
307
316
  ? m.permissions.dataScope
308
317
  : m.dataScope),
309
- requireConfirmation: false,
318
+ requireConfirmation: mcpToolsRequireConfirmation(m),
310
319
  execution,
311
320
  },
312
321
  };