@gt-fe/eap-runtime 0.0.20 → 0.0.22

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 (133) hide show
  1. package/README.md +4 -3
  2. package/dist/api/handlers/invoke.handler.d.ts +7 -0
  3. package/dist/api/handlers/invoke.handler.js +52 -10
  4. package/dist/api/handlers/stream.handler.d.ts +6 -0
  5. package/dist/api/handlers/stream.handler.js +60 -39
  6. package/dist/api/routes/agent.routes.d.ts +8 -1
  7. package/dist/checkpoint/file-checkpoint-saver.d.ts +4 -0
  8. package/dist/checkpoint/file-checkpoint-saver.js +51 -22
  9. package/dist/checkpoint/index.d.ts +1 -1
  10. package/dist/checkpoint/index.js +1 -1
  11. package/dist/common/types.d.ts +2 -2
  12. package/dist/common/types.js +1 -1
  13. package/dist/execution/index.d.ts +2 -0
  14. package/dist/execution/index.js +2 -0
  15. package/dist/execution/interceptors.d.ts +40 -0
  16. package/dist/execution/interceptors.js +23 -0
  17. package/dist/execution/ports.d.ts +18 -0
  18. package/dist/execution/ports.js +13 -0
  19. package/dist/governance/audit-reporter.d.ts +14 -0
  20. package/dist/governance/audit-reporter.js +18 -0
  21. package/dist/governance/index.d.ts +2 -0
  22. package/dist/governance/index.js +1 -0
  23. package/dist/governance/permission-guard.d.ts +6 -2
  24. package/dist/governance/permission-guard.js +38 -5
  25. package/dist/graph/adapters/graph-nesting.d.ts +12 -0
  26. package/dist/graph/adapters/graph-nesting.js +29 -0
  27. package/dist/graph/adapters/index.d.ts +2 -0
  28. package/dist/graph/adapters/index.js +2 -0
  29. package/dist/graph/adapters/langgraph-checkpointer.d.ts +11 -0
  30. package/dist/graph/adapters/langgraph-checkpointer.js +48 -9
  31. package/dist/graph/adapters/langgraph.adapter.d.ts +33 -11
  32. package/dist/graph/adapters/langgraph.adapter.js +200 -98
  33. package/dist/graph/adapters/skill-isolate.d.ts +13 -0
  34. package/dist/graph/adapters/skill-isolate.js +39 -0
  35. package/dist/graph/adapters/subgraph-compiler.d.ts +7 -2
  36. package/dist/graph/adapters/subgraph-compiler.js +63 -30
  37. package/dist/graph/contracts/executable-graph.d.ts +33 -5
  38. package/dist/graph/hitl/hitl-handler.d.ts +13 -4
  39. package/dist/graph/hitl/hitl-handler.js +80 -7
  40. package/dist/graph/nodes/index.d.ts +1 -0
  41. package/dist/graph/nodes/index.js +1 -0
  42. package/dist/graph/nodes/llm-node.d.ts +3 -1
  43. package/dist/graph/nodes/llm-node.js +26 -2
  44. package/dist/graph/nodes/rag/dedup.d.ts +20 -0
  45. package/dist/graph/nodes/rag/dedup.js +77 -0
  46. package/dist/graph/nodes/rag/denoise.d.ts +58 -0
  47. package/dist/graph/nodes/rag/denoise.js +220 -0
  48. package/dist/graph/nodes/rag/fusion/fuse.d.ts +11 -0
  49. package/dist/graph/nodes/rag/fusion/fuse.js +25 -0
  50. package/dist/graph/nodes/rag/fusion/normalize.d.ts +9 -0
  51. package/dist/graph/nodes/rag/fusion/normalize.js +135 -0
  52. package/dist/graph/nodes/rag/index.d.ts +18 -0
  53. package/dist/graph/nodes/rag/index.js +17 -0
  54. package/dist/graph/nodes/rag/prompts.d.ts +12 -0
  55. package/dist/graph/nodes/rag/prompts.js +40 -0
  56. package/dist/graph/nodes/rag/rag-node.d.ts +21 -0
  57. package/dist/graph/nodes/rag/rag-node.js +135 -0
  58. package/dist/graph/nodes/rag/rag-search-tool.d.ts +48 -0
  59. package/dist/graph/nodes/rag/rag-search-tool.js +230 -0
  60. package/dist/graph/nodes/rag/react-loop.d.ts +57 -0
  61. package/dist/graph/nodes/rag/react-loop.js +467 -0
  62. package/dist/graph/nodes/rag/retrievers/http-embedder.d.ts +29 -0
  63. package/dist/graph/nodes/rag/retrievers/http-embedder.js +66 -0
  64. package/dist/graph/nodes/rag/retrievers/in-memory-retriever.d.ts +17 -0
  65. package/dist/graph/nodes/rag/retrievers/in-memory-retriever.js +18 -0
  66. package/dist/graph/nodes/rag/retrievers/local-sqlite-retriever.d.ts +46 -0
  67. package/dist/graph/nodes/rag/retrievers/local-sqlite-retriever.js +85 -0
  68. package/dist/graph/nodes/rag/tools.d.ts +90 -0
  69. package/dist/graph/nodes/rag/tools.js +260 -0
  70. package/dist/graph/nodes/rag/types.d.ts +226 -0
  71. package/dist/graph/nodes/rag/types.js +9 -0
  72. package/dist/graph/nodes/tool-node.d.ts +19 -1
  73. package/dist/graph/nodes/tool-node.js +158 -33
  74. package/dist/index.d.ts +2 -0
  75. package/dist/index.js +1 -0
  76. package/dist/memory/semantic-retrieval.service.d.ts +1 -1
  77. package/dist/memory/semantic-retrieval.service.js +13 -23
  78. package/dist/providers/index.d.ts +2 -2
  79. package/dist/providers/index.js +2 -2
  80. package/dist/providers/local-llm-provider.d.ts +6 -0
  81. package/dist/providers/local-llm-provider.js +29 -5
  82. package/dist/providers/routing-llm-provider.d.ts +7 -2
  83. package/dist/providers/routing-llm-provider.js +71 -16
  84. package/dist/registry/deployment-snapshot.js +18 -4
  85. package/dist/registry/graph-loader.d.ts +2 -0
  86. package/dist/registry/graph-loader.js +39 -2
  87. package/dist/registry/preload-flags.d.ts +1 -1
  88. package/dist/registry/preload-flags.js +1 -1
  89. package/dist/runtime/agent-runtime.d.ts +5 -0
  90. package/dist/runtime/agent-runtime.js +107 -70
  91. package/dist/runtime/dev-rag-deps.d.ts +21 -0
  92. package/dist/runtime/dev-rag-deps.js +186 -0
  93. package/dist/runtime/dev-runtime.d.ts +18 -2
  94. package/dist/runtime/dev-runtime.js +68 -8
  95. package/dist/runtime/index.d.ts +1 -1
  96. package/dist/runtime/types.d.ts +10 -9
  97. package/dist/session/apply-session-compression.d.ts +85 -0
  98. package/dist/session/apply-session-compression.js +281 -0
  99. package/dist/session/apply-session-memory.d.ts +21 -0
  100. package/dist/session/apply-session-memory.js +48 -0
  101. package/dist/session/index.d.ts +2 -0
  102. package/dist/session/index.js +2 -0
  103. package/dist/session/persist-turn-messages.d.ts +57 -1
  104. package/dist/session/persist-turn-messages.js +160 -5
  105. package/dist/sse/events/index.d.ts +13 -4
  106. package/dist/sse/events/index.js +9 -1
  107. package/dist/sse/events/rag-events.d.ts +63 -0
  108. package/dist/sse/events/rag-events.js +9 -0
  109. package/dist/sse/events/session-events.d.ts +38 -0
  110. package/dist/sse/events/session-events.js +4 -0
  111. package/dist/token/index.d.ts +2 -1
  112. package/dist/token/index.js +2 -1
  113. package/dist/token/record-task-token-usage.d.ts +25 -0
  114. package/dist/token/record-task-token-usage.js +44 -0
  115. package/dist/token/token-detail-mode.d.ts +4 -4
  116. package/dist/token/token-detail-mode.js +4 -4
  117. package/dist/token/token-detail.d.ts +8 -0
  118. package/dist/token/token-detail.js +22 -0
  119. package/dist/token/token-stats.service.d.ts +2 -2
  120. package/dist/token/token-stats.service.js +1 -1
  121. package/dist/tool/create-tool-executor.d.ts +6 -0
  122. package/dist/tool/create-tool-executor.js +4 -3
  123. package/dist/tool/harness-primitives.d.ts +9 -1
  124. package/dist/tool/harness-primitives.js +43 -12
  125. package/dist/tool/index.d.ts +2 -1
  126. package/dist/tool/index.js +2 -1
  127. package/dist/tool/tool-confirmation.js +8 -5
  128. package/dist/tool/tool-executor.interface.d.ts +27 -0
  129. package/dist/tool/tool-orchestrator.d.ts +3 -1
  130. package/dist/tool/tool-orchestrator.js +15 -6
  131. package/dist/tool/tool-retry.d.ts +15 -0
  132. package/dist/tool/tool-retry.js +69 -0
  133. package/package.json +2 -2
@@ -0,0 +1,467 @@
1
+ /**
2
+ * react-loop — RagNode 内部 ReAct 循环(纯逻辑,不依赖 LangGraph)
3
+ *
4
+ * 输入输出均为普通对象,可脱离图引擎单独单测。
5
+ * 循环:LLM 决策(原生 function-calling)→ 三路派发 → 累积 perSource →
6
+ * 达终止条件后 fuse + denoise(近重去重 + LLM 判定 + 句子抽取)→ 输出;
7
+ * 判定存活集为空且未重写过 → QUERY_REWRITE 重写 query 清空重查(仅一次,§5.4)。
8
+ * 空转换源反馈:上一轮零产出 / 产出全被滤除的工具,下一轮决策上下文注入换源提示
9
+ * (只进决策输入,不落消息历史与最终输出),配合 RAG_REACT 行为规则防同源反复重查。
10
+ * LLM 永远只"点名"(name + arguments),执行全在代码侧:
11
+ * 路 A local_search → 进程内直连 IRetriever(结构化 RetrievalDoc[],零转换)
12
+ * 路 B 注册工具 → ToolOrchestrator(权限 → 路由 → 执行器),字符串结果归一化
13
+ * 路 C 未知工具 → 错误 tool 消息回给 LLM(不中断循环)
14
+ *
15
+ * @see design-v2 §5.4 / §6
16
+ */
17
+ import { NodeRetryExecutor, DEFAULT_LLM_RETRY_CONFIG } from '../../retry/index.js';
18
+ import { RAG_REACT } from './prompts.js';
19
+ import { KEYWORD_SEARCH_TOOL_NAME, LOCAL_SEARCH_TOOL_NAME, RAG_FINISH_TOOL_NAME, briefList, } from './tools.js';
20
+ import { normalizeToolResult } from './fusion/normalize.js';
21
+ import { fuse } from './fusion/fuse.js';
22
+ import { dedupExact, sameContent } from './dedup.js';
23
+ import { denoise, rewriteQuery } from './denoise.js';
24
+ const DEFAULT_MAX_ITERATIONS = 5;
25
+ export async function runReactLoop(input) {
26
+ const maxIterations = input.config.maxIterations ?? DEFAULT_MAX_ITERATIONS;
27
+ const perSource = new Map();
28
+ const loopMessages = []; // 循环私有消息,不进主对话
29
+ const tokenUsage = [];
30
+ // 空转追踪(换源反馈):工具 → 本次累计产出文档键集(sourceId:rawId),
31
+ // 供 rewrite 边界把"产出全被滤"归因到工具
32
+ const toolDocKeys = new Map();
33
+ // 上一轮空转的来源(只看上一轮,不累积历史;被提示来源再空转则下一轮继续提示,自然递进)
34
+ let idleSources = [];
35
+ let iteration = 0;
36
+ let query = input.query;
37
+ let rewritten = false;
38
+ let outcome;
39
+ let finalDocs = 0;
40
+ search: while (true) {
41
+ while (true) {
42
+ // 达硬上限必须先于 decide 判:上限轮的决策结果只会被丢弃,再烧一次纯费 token;
43
+ // 带着已有结果走融合收束,不报错中断
44
+ if (iteration >= maxIterations)
45
+ break;
46
+ // ① 决策调用(主力模型,原生 function-calling);空转换源提示只进决策上下文
47
+ const toolCalls = await decide(input, query, loopMessages, tokenUsage, idleHint(idleSources));
48
+ // 兜底:LLM 既没调工具也没调 rag_finish(不守格式)→ 视为已足够
49
+ if (!toolCalls || toolCalls.length === 0)
50
+ break;
51
+ // rag_finish 是控制信号,不执行任何东西
52
+ if (toolCalls.some((tc) => tc.name === RAG_FINISH_TOOL_NAME))
53
+ break;
54
+ // ② 三路派发(本轮所有 tool_call),先发 rag.round 事件供外部观测
55
+ emit(input, 'rag.round', {
56
+ nodeId: input.nodeId,
57
+ round: iteration + 1,
58
+ toolCalls: toolCalls.map((tc) => ({ name: tc.name, arguments: tc.arguments })),
59
+ });
60
+ const { messages: toolMessages, emptyTools } = await dispatchToolCalls(toolCalls, input, perSource, toolDocKeys);
61
+ // function-calling 协议:先 assistant(带 toolCalls)再 tool 消息
62
+ loopMessages.push({ role: 'assistant', content: '', toolCalls }, ...toolMessages);
63
+ // ③ 空转反馈(返回空子 case):本轮成功执行但零产出的工具,下一轮提示换源;
64
+ // "结果全被滤除"子 case 在 rewrite 边界降噪判定后回填(见 ⑦)
65
+ idleSources = emptyTools.map((tool) => ({ tool, note: '未检索到任何结果' }));
66
+ // ④ 决策轮 +1(同轮并发多个调用只计 1 次)
67
+ iteration += 1;
68
+ }
69
+ // ⑤ 融合:源内精确去重 → 配额切分
70
+ const perSourceDeduped = new Map([...perSource.entries()].map(([sourceId, docs]) => [sourceId, dedupExact(docs)]));
71
+ const merged = fuse(perSourceDeduped, input.config.totalK);
72
+ // ⑥ 降噪(M4 管线):近重去重 → LLM 相关性判定 → 句子编号抽取 → 拼装
73
+ const denoiseSpanId = await startTraceSpan(input, {
74
+ name: 'rag.denoise',
75
+ type: 'llm',
76
+ input: { query, candidates: merged.length },
77
+ });
78
+ const denoised = await denoise({
79
+ query,
80
+ docs: merged,
81
+ llmProvider: input.llmProvider,
82
+ model: input.config.model ?? 'default',
83
+ nodeId: input.nodeId,
84
+ nearDupThreshold: input.config.nearDupThreshold,
85
+ tokenUsage,
86
+ });
87
+ await endTraceSpan(input, denoiseSpanId, {
88
+ status: 'completed',
89
+ output: { kept: denoised.docs.length, candidates: merged.length },
90
+ });
91
+ emit(input, 'rag.denoise', {
92
+ nodeId: input.nodeId,
93
+ candidates: merged.length,
94
+ kept: denoised.docs.length,
95
+ filtered: denoised.filtered,
96
+ });
97
+ // ⑦ 空结果兜底:判定存活集为空且未重写过且有余量 → 重写 query 清空重查一次
98
+ if (denoised.verdictEmpty && !rewritten && iteration < maxIterations) {
99
+ const rewrittenQuery = await rewriteQuery({
100
+ query,
101
+ reasons: denoised.filtered.map((f) => f.reason),
102
+ llmProvider: input.llmProvider,
103
+ model: input.config.model ?? 'default',
104
+ nodeId: input.nodeId,
105
+ tokenUsage,
106
+ });
107
+ if (rewrittenQuery && rewrittenQuery !== query) {
108
+ emit(input, 'rag.rewrite', { nodeId: input.nodeId, from: query, to: rewrittenQuery });
109
+ query = rewrittenQuery;
110
+ // 空转反馈(全被滤除子 case):verdictEmpty ⇒ 存活集为空,本次产出过文档的工具
111
+ // 均为"结果全被滤除"空转;此前"返回空"的工具提示保留,重查轮同样享受
112
+ idleSources = rewriteIdleSources(toolDocKeys, denoised, idleSources);
113
+ perSource.clear(); // 判过无关的结果不参与下轮融合(§5.4)
114
+ toolDocKeys.clear(); // 归因已折算进 idleSources,随 perSource 一起清
115
+ rewritten = true;
116
+ continue search;
117
+ }
118
+ }
119
+ outcome = { contextText: denoised.contextText, empty: denoised.docs.length === 0 };
120
+ finalDocs = denoised.docs.length;
121
+ break;
122
+ }
123
+ emit(input, 'rag.finish', {
124
+ nodeId: input.nodeId,
125
+ rounds: iteration,
126
+ empty: outcome.empty,
127
+ docs: finalDocs,
128
+ });
129
+ return {
130
+ contextText: outcome.contextText,
131
+ empty: outcome.empty,
132
+ iterations: iteration,
133
+ tokenUsage,
134
+ };
135
+ }
136
+ // ─────────────────────────────────────────────
137
+ // 决策调用
138
+ // ─────────────────────────────────────────────
139
+ async function decide(input, query, loopMessages, tokenUsage, idleHint) {
140
+ const config = {
141
+ model: input.config.model ?? 'default',
142
+ systemPrompt: RAG_REACT, // provider 统一注入 system,不手动拼消息(与 LLMNode 同约定)
143
+ tools: input.apiTools,
144
+ toolChoice: 'auto',
145
+ };
146
+ // 任务行去重约束:history 末条 user 消息就是检索任务原文时不再追加"检索任务:"行
147
+ // (同文重复两份白占决策上下文);query 已被改写或来自别处(末条不同)时必须追加,
148
+ // RAG_REACT 对"检索任务"的指代不断供
149
+ const lastHistory = input.history.at(-1);
150
+ const taskInHistory = lastHistory?.role === 'user' && lastHistory.content.trim() === query.trim();
151
+ const messages = [
152
+ ...input.history,
153
+ ...loopMessages,
154
+ // 空转换源提示:仅决策上下文可见(不落 loopMessages、不进最终输出),每轮按最新空转重算
155
+ ...(idleHint ? [{ role: 'user', content: idleHint }] : []),
156
+ ...(taskInHistory ? [] : [{ role: 'user', content: `检索任务:${query}` }]),
157
+ ];
158
+ const startedAt = Date.now();
159
+ // 决策调用带重试(与 LLMNode 同语义:缺省 3 次 + 指数退避,config.retry 可覆盖)
160
+ const retryExecutor = new NodeRetryExecutor(input.nodeId, DEFAULT_LLM_RETRY_CONFIG);
161
+ const { result: response } = await retryExecutor.executeWithRetry(() => input.llmProvider.invoke(messages, config), input.config.retry);
162
+ tokenUsage.push({
163
+ nodeId: input.nodeId,
164
+ model: response.model,
165
+ inputTokens: response.inputTokens,
166
+ outputTokens: response.outputTokens,
167
+ durationMs: Date.now() - startedAt,
168
+ });
169
+ return response.toolCalls;
170
+ }
171
+ // ─────────────────────────────────────────────
172
+ // 三路派发
173
+ // ─────────────────────────────────────────────
174
+ async function dispatchToolCalls(toolCalls, input, perSource, toolDocKeys) {
175
+ const outgoing = [];
176
+ const kbRequests = [];
177
+ const emptyTools = []; // 本轮零产出工具(空转:返回空子 case,失败不算)
178
+ for (const tc of toolCalls) {
179
+ if (tc.name === LOCAL_SEARCH_TOOL_NAME) {
180
+ const { message, idle } = await runLocalSearch(tc, input, perSource, toolDocKeys, 'semantic');
181
+ outgoing.push(message);
182
+ if (idle)
183
+ emptyTools.push(LOCAL_SEARCH_TOOL_NAME);
184
+ }
185
+ else if (tc.name === KEYWORD_SEARCH_TOOL_NAME) {
186
+ const { message, idle } = await runLocalSearch(tc, input, perSource, toolDocKeys, 'keyword');
187
+ outgoing.push(message);
188
+ if (idle)
189
+ emptyTools.push(KEYWORD_SEARCH_TOOL_NAME);
190
+ }
191
+ else if (input.kbToolNames.has(tc.name)) {
192
+ kbRequests.push(toKbRequest(tc, input));
193
+ }
194
+ else {
195
+ outgoing.push(toolMsg(tc.id, tc.name, `[Tool Error] 未注册的工具 "${tc.name}",请只调用工具列表中的工具`, false));
196
+ }
197
+ }
198
+ if (kbRequests.length === 0)
199
+ return { messages: outgoing, emptyTools };
200
+ if (!input.toolOrchestrator) {
201
+ for (const req of kbRequests) {
202
+ outgoing.push(toolMsg(req.toolCallId, req.toolName, '[Tool Error] runtime 未装配 ToolOrchestrator', false));
203
+ }
204
+ return { messages: outgoing, emptyTools };
205
+ }
206
+ // executeAll 内部 allSettled:权限拒绝/执行失败均为结构化结果,不抛异常
207
+ // Trace span 照 ToolNode 模式:每次 kb 调用一个 span,span 类型用 tool + metadata.rag 标记
208
+ const spanIds = await Promise.all(kbRequests.map((req) => startTraceSpan(input, {
209
+ name: req.toolName,
210
+ type: 'tool',
211
+ input: req.arguments,
212
+ metadata: { toolCallId: req.toolCallId, toolType: req.toolType },
213
+ })));
214
+ const startedAt = Date.now();
215
+ const results = await input.toolOrchestrator.executeAll(kbRequests, input.executionContext);
216
+ const durationMs = Date.now() - startedAt;
217
+ for (const [i, r] of results.entries()) {
218
+ let hits = 0;
219
+ if (r.success) {
220
+ // 源桶可配:多工具源(知识库/数仓…)可分桶独立计配额,缺省同归 'kb'
221
+ const sourceId = input.config.toolSources?.[r.toolName] ?? 'kb';
222
+ const docs = normalizeToolResult(r.content, r.toolName, sourceId);
223
+ hits = docs.length;
224
+ const stored = pushDocs(perSource, sourceId, docs);
225
+ // 归因键用入桶后的 docs(撞号已重编号),与 perSource/filtered 复合键同源
226
+ recordDocKeys(toolDocKeys, r.toolName, stored);
227
+ if (hits === 0)
228
+ emptyTools.push(r.toolName);
229
+ outgoing.push(toolMsg(r.toolCallId, r.toolName, briefList(docs), true));
230
+ }
231
+ else {
232
+ // 失败也回给 LLM,让它下一轮自主决定换源重试还是收手
233
+ outgoing.push(toolMsg(r.toolCallId, r.toolName, `[Tool Error] ${r.error ?? '未知错误'}`, false));
234
+ }
235
+ emit(input, 'rag.search', {
236
+ nodeId: input.nodeId,
237
+ kind: 'kb',
238
+ toolName: r.toolName,
239
+ hits,
240
+ success: r.success,
241
+ ...(r.error ? { error: r.error } : {}),
242
+ durationMs, // 批次时长(executeAll 整批计时,批内共享)
243
+ });
244
+ await endTraceSpan(input, spanIds[i] ?? null, {
245
+ status: r.success ? 'completed' : 'failed',
246
+ output: r.success ? { hits } : undefined,
247
+ error: r.error,
248
+ });
249
+ }
250
+ return { messages: outgoing, emptyTools };
251
+ }
252
+ /** 路 A:内置伪工具(local_search 语义 / keyword_search 精确)→ 直连 retriever(多源并发),结果保持结构化零转换 */
253
+ async function runLocalSearch(tc, input, perSource, toolDocKeys, mode) {
254
+ const retrievers = input.retrievers ?? [];
255
+ const query = String(tc.arguments['query'] ?? '');
256
+ // topK 非法值防御:LLM 可能回 "abc"/0/负数,NaN/非正数进 sqlite LIMIT 会抛错白费一轮 → 回退缺省 10
257
+ const rawTopK = Number(tc.arguments['topK'] ?? 10);
258
+ const topK = Number.isFinite(rawTopK) && rawTopK > 0 ? rawTopK : 10;
259
+ const toolName = mode === 'keyword' ? KEYWORD_SEARCH_TOOL_NAME : LOCAL_SEARCH_TOOL_NAME;
260
+ // keyword 模式只发到实现了 retrieveByKeyword 的源;semantic 全源
261
+ const targets = mode === 'keyword' ? retrievers.filter((r) => typeof r.retrieveByKeyword === 'function') : retrievers;
262
+ if (targets.length === 0) {
263
+ emit(input, 'rag.search', {
264
+ nodeId: input.nodeId,
265
+ kind: mode === 'keyword' ? 'keyword' : 'local',
266
+ query,
267
+ topK,
268
+ hits: 0,
269
+ success: false,
270
+ error: mode === 'keyword' ? '当前部署无关键词检索源' : '当前部署无本地检索源',
271
+ durationMs: 0,
272
+ });
273
+ return {
274
+ message: toolMsg(tc.id, toolName, `[Tool Error] ${mode === 'keyword' ? '当前部署无关键词检索源' : '当前部署无本地检索源'}`, false),
275
+ idle: true, // 部署形态性缺席:永远零产出,按空转提示换源
276
+ };
277
+ }
278
+ const spanId = await startTraceSpan(input, {
279
+ name: toolName,
280
+ type: 'tool',
281
+ input: { query, topK, userId: input.userId, mode },
282
+ });
283
+ const startedAt = Date.now();
284
+ const settled = await Promise.allSettled(targets.map((r) => mode === 'keyword'
285
+ ? r.retrieveByKeyword(query, { topK, userId: input.userId, scope: input.ragScope })
286
+ : r.retrieve(query, { topK, userId: input.userId, scope: input.ragScope })));
287
+ const durationMs = Date.now() - startedAt;
288
+ const roundDocs = [];
289
+ let failCount = 0;
290
+ settled.forEach((s, i) => {
291
+ if (s.status === 'fulfilled') {
292
+ pushDocs(perSource, targets[i].sourceId, s.value);
293
+ roundDocs.push(...s.value);
294
+ }
295
+ else {
296
+ failCount += 1;
297
+ }
298
+ });
299
+ recordDocKeys(toolDocKeys, toolName, roundDocs);
300
+ const summary = failCount > 0 ? `(${failCount} 个源失败)` : '';
301
+ // 本次实际生效的本地检索范围摘要(与传给 retriever 的 scope 同源;缺省不限则不打字段)
302
+ const scopeSummary = summarizeScope(input.ragScope);
303
+ emit(input, 'rag.search', {
304
+ nodeId: input.nodeId,
305
+ kind: mode === 'keyword' ? 'keyword' : 'local',
306
+ query,
307
+ topK,
308
+ hits: roundDocs.length,
309
+ success: failCount === 0,
310
+ ...(failCount > 0 ? { error: `${failCount} 个源失败` } : {}),
311
+ durationMs,
312
+ ...(scopeSummary ? { scope: scopeSummary } : {}),
313
+ });
314
+ await endTraceSpan(input, spanId, {
315
+ status: failCount === 0 ? 'completed' : 'failed',
316
+ output: { hits: roundDocs.length },
317
+ ...(failCount > 0 ? { error: `${failCount} 个源失败` } : {}),
318
+ });
319
+ return {
320
+ message: toolMsg(tc.id, toolName, `${briefList(roundDocs)}${summary}`, roundDocs.length > 0),
321
+ // 空转(返回空子 case):全部源成功执行但零产出(失败已有 Tool Error 反馈,语义不同不混计)
322
+ idle: failCount === 0 && roundDocs.length === 0,
323
+ };
324
+ }
325
+ /** 路 B 请求组装:照 ToolNode 惯例从 manifest 取路由元数据(Router 选路依据,缺了会退化默认分支) */
326
+ function toKbRequest(tc, input) {
327
+ const loaded = input.loadedTools?.[tc.name];
328
+ return {
329
+ toolCallId: tc.id,
330
+ toolName: tc.name,
331
+ arguments: tc.arguments,
332
+ authContext: input.authContext,
333
+ forceServer: input.forceToolServer?.includes(tc.name),
334
+ toolType: loaded?.manifest.type,
335
+ toolRuntime: loaded?.manifest.toolRuntime,
336
+ dataScope: loaded?.manifest.dataScope,
337
+ execution: loaded?.manifest.execution,
338
+ };
339
+ }
340
+ /**
341
+ * 收集侧入桶(跨轮累计)。约束:同 sourceId+rawId 撞号且 content 不同时必须对
342
+ * 后来者重编号 `${rawId}#r${n}`(n 为该键冲突序号)——外部工具可能返回不含参数
343
+ * 区分的恒定 id(dw 恒 `dw-${code}`、normalize 位置 fallback 每次调用都从 0 编号),
344
+ * ReAct 对同一工具 ≥2 次调用是常态,id 冲突 ≠ 内容重复,不重编号会被融合前精确
345
+ * 去重当重复静默丢弃(briefList 已给决策 LLM 看过、最终 contextText 却缺失)。
346
+ * content 相同才按重复跳过(归一化口径与 dedupExact 同源,见 sameContent)。
347
+ * 重编号只动 rawId 去重键链路,metadata/title 等展示字段原样;briefList 仍用
348
+ * 原始 docs,决策上下文展示不受重编号影响。
349
+ * 返回实际入桶的 docs(重编号副本):空转归因键(toolDocKeys)必须与桶内键
350
+ * 同源,否则 rewrite 边界整键匹配会漏掉重编号条目。
351
+ */
352
+ function pushDocs(perSource, sourceId, docs) {
353
+ const bucket = perSource.get(sourceId) ?? [];
354
+ const stored = [];
355
+ for (const d of docs) {
356
+ // 同 id 且内容相同 → 真重复跳过;内容不同 → 走下方重编号保内容
357
+ if (bucket.some((b) => b.rawId === d.rawId && sameContent(b.content, d.content)))
358
+ continue;
359
+ let doc = d;
360
+ if (bucket.some((b) => b.rawId === d.rawId)) {
361
+ let n = 1;
362
+ while (bucket.some((b) => b.rawId === `${d.rawId}#r${n}`))
363
+ n++;
364
+ doc = { ...d, rawId: `${d.rawId}#r${n}` };
365
+ }
366
+ bucket.push(doc);
367
+ stored.push(doc);
368
+ }
369
+ perSource.set(sourceId, bucket);
370
+ return stored;
371
+ }
372
+ /** 工具产出登记(空转归因用):sourceId:rawId 为键(rawId 仅源内唯一),跨轮累计去重 */
373
+ function recordDocKeys(toolDocKeys, tool, docs) {
374
+ if (docs.length === 0)
375
+ return;
376
+ const keys = toolDocKeys.get(tool) ?? new Set();
377
+ for (const d of docs)
378
+ keys.add(`${d.sourceId}:${d.rawId}`);
379
+ toolDocKeys.set(tool, keys);
380
+ }
381
+ /** 决策上下文的换源提示文案:只描述上一轮空转的来源,不累积历史 */
382
+ function idleHint(sources) {
383
+ if (sources.length === 0)
384
+ return undefined;
385
+ const list = sources.map((s) => `${s.tool}(${s.note})`).join('、');
386
+ return `上一轮以下来源无高相关结果:${list}。请改用其他数据源工具或多源并行,不要继续重查这些来源。`;
387
+ }
388
+ /**
389
+ * rewrite 边界的空转归因:触发重写即存活集为空,本次产出过文档的工具全部是
390
+ * "结果全被滤除"空转,附一条最有代表性的被滤原因(融合序靠前优先);
391
+ * 此前"返回空"的工具不在产出集里(无产出),提示原样保留,重查轮同样享受。
392
+ * filtered 项与工具产出键同为复合键 sourceId:rawId,须整键精确匹配
393
+ * (rawId 仅源内唯一,按尾段匹配跨源碰撞会张冠李戴)。
394
+ */
395
+ function rewriteIdleSources(toolDocKeys, denoised, prevIdle) {
396
+ const keptKeys = new Set(denoised.docs.map((d) => `${d.sourceId}:${d.rawId}`));
397
+ const idle = [];
398
+ for (const [tool, keys] of toolDocKeys) {
399
+ if ([...keys].some((k) => keptKeys.has(k)))
400
+ continue; // 有产出存活 → 非空转(防御:重写触发时实际恒空)
401
+ const reason = [...keys]
402
+ .map((k) => denoised.filtered.find((f) => f.rawId === k && f.reason))
403
+ .find(Boolean)?.reason;
404
+ idle.push({ tool, note: `结果全被滤除${reason ? `,原因示例:${reason}` : ''}` });
405
+ }
406
+ return [...idle, ...prevIdle.filter((p) => !toolDocKeys.has(p.tool))];
407
+ }
408
+ /**
409
+ * rag.search 的本地 scope 摘要(仅 local/keyword 路径):与传给 retriever 的 scope 同源,
410
+ * 形如 `dirs=2[共享资料,月报] files=3`(目录取末段名、超长截断);显式空集
411
+ * (会话勾选关闭本地源)给 'closed';undefined(不限)不打字段。kb 路径不受 ragScope 约束,无此字段。
412
+ */
413
+ function summarizeScope(scope) {
414
+ if (!scope)
415
+ return undefined;
416
+ const dirs = scope.dirPaths ?? [];
417
+ const files = scope.fileIds ?? [];
418
+ if (dirs.length === 0 && files.length === 0)
419
+ return 'closed';
420
+ let names = dirs
421
+ .map((d) => d.split(/[\\/]/).filter(Boolean).at(-1) ?? d)
422
+ .join(',');
423
+ if (names.length > 40)
424
+ names = `${names.slice(0, 40)}…`;
425
+ const dirPart = dirs.length ? `dirs=${dirs.length}[${names}]` : 'dirs=0';
426
+ return `${dirPart} files=${files.length}`;
427
+ }
428
+ // ─────────────────────────────────────────────
429
+ // 观测出口(rag.* 事件 + Trace span)
430
+ // ─────────────────────────────────────────────
431
+ function emit(input, type, payload) {
432
+ input.emitEvent?.(type, payload);
433
+ }
434
+ /**
435
+ * Trace span 创建:照 ToolNode 模式(state.traceCollector + resolveTraceId)。
436
+ * span 类型枚举无 'rag'(foundation 契约),借用 tool/llm + metadata.rag 标记。
437
+ * 采集器缺失或异常 → 返回 null,不影响主路径。
438
+ */
439
+ async function startTraceSpan(input, event) {
440
+ if (!input.traceCollector || !input.traceId)
441
+ return null;
442
+ try {
443
+ return await input.traceCollector.createNode({
444
+ traceId: input.traceId,
445
+ name: event.name,
446
+ type: event.type,
447
+ input: event.input,
448
+ metadata: { rag: true, ...event.metadata },
449
+ });
450
+ }
451
+ catch {
452
+ return null;
453
+ }
454
+ }
455
+ async function endTraceSpan(input, spanId, params) {
456
+ if (!input.traceCollector || !spanId)
457
+ return;
458
+ try {
459
+ await input.traceCollector.completeNode(spanId, params);
460
+ }
461
+ catch {
462
+ // Trace 失败不影响主路径(照 ToolNode 惯例)
463
+ }
464
+ }
465
+ function toolMsg(toolCallId, toolName, content, success) {
466
+ return { role: 'tool', content, toolCallId, toolName, success };
467
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * HttpEmbedder — OpenAI 兼容 /v1/embeddings 客户端(IEmbedder 实现)
3
+ *
4
+ * embedding 统一走 gateway(design-v2 §7.3 实配:Qwen3-Embedding-8B @
5
+ * gtaiapi.gtcloud.cn,Bearer 认证,dimensions=1024 截断);配置经构造参数
6
+ * 注入(baseUrl/model/apiKey/dims/dimensions),换模型 = 换构造参数
7
+ * (库文件需配套,引擎 model_version/dims 锁会拦住混用)。
8
+ */
9
+ import type { IEmbedder } from '../types.js';
10
+ export interface HttpEmbedderOptions {
11
+ baseUrl: string;
12
+ model: string;
13
+ dims: number;
14
+ apiKey?: string;
15
+ /** 请求侧显式指定输出维度(Matryoshka 截断,如 gateway Qwen3-Embedding-8B 支持 1024–4096);不支持该参数的服务会忽略 */
16
+ dimensions?: number;
17
+ /** 单次批量上限(超出分多次请求,结果按序拼接);缺省 64 */
18
+ batchSize?: number;
19
+ }
20
+ export declare class HttpEmbedder implements IEmbedder {
21
+ readonly model: string;
22
+ readonly dims: number;
23
+ private readonly baseUrl;
24
+ private readonly apiKey?;
25
+ private readonly dimensions?;
26
+ private readonly batchSize;
27
+ constructor(opts: HttpEmbedderOptions);
28
+ embed(texts: string[]): Promise<number[][]>;
29
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * HttpEmbedder — OpenAI 兼容 /v1/embeddings 客户端(IEmbedder 实现)
3
+ *
4
+ * embedding 统一走 gateway(design-v2 §7.3 实配:Qwen3-Embedding-8B @
5
+ * gtaiapi.gtcloud.cn,Bearer 认证,dimensions=1024 截断);配置经构造参数
6
+ * 注入(baseUrl/model/apiKey/dims/dimensions),换模型 = 换构造参数
7
+ * (库文件需配套,引擎 model_version/dims 锁会拦住混用)。
8
+ */
9
+ export class HttpEmbedder {
10
+ model;
11
+ dims;
12
+ baseUrl;
13
+ apiKey;
14
+ dimensions;
15
+ batchSize;
16
+ constructor(opts) {
17
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
18
+ this.model = opts.model;
19
+ this.dims = opts.dims;
20
+ this.apiKey = opts.apiKey;
21
+ this.dimensions = opts.dimensions;
22
+ this.batchSize = opts.batchSize ?? 64;
23
+ }
24
+ async embed(texts) {
25
+ if (texts.length === 0)
26
+ return [];
27
+ const out = [];
28
+ for (let i = 0; i < texts.length; i += this.batchSize) {
29
+ const batch = texts.slice(i, i + this.batchSize);
30
+ const headers = { 'content-type': 'application/json' };
31
+ if (this.apiKey)
32
+ headers['authorization'] = `Bearer ${this.apiKey}`;
33
+ const res = await fetch(`${this.baseUrl}/v1/embeddings`, {
34
+ method: 'POST',
35
+ headers,
36
+ body: JSON.stringify({
37
+ input: batch.length === 1 ? batch[0] : batch,
38
+ model: this.model,
39
+ // dimensions 缺省发 dims:请求维度与返回校验期望必须同源,否则网关按原生维度返回必炸校验
40
+ dimensions: this.dimensions ?? this.dims,
41
+ }),
42
+ });
43
+ if (!res.ok) {
44
+ const text = (await res.text()).slice(0, 200);
45
+ throw new Error(`HttpEmbedder "${this.model}" HTTP ${res.status}: ${text}`);
46
+ }
47
+ const json = (await res.json());
48
+ const vectors = (json.data ?? []).map((d) => d.embedding);
49
+ if (vectors.length !== batch.length) {
50
+ throw new Error(`HttpEmbedder "${this.model}" 返回 ${vectors.length} 条向量 ≠ 请求 ${batch.length} 条`);
51
+ }
52
+ // 维度必须在 embed 侧断言:缺失/长度不符立即抛带模型名与期望/实际的明确错误。
53
+ // 掩成空数组会把错配延迟到 engine.search 才爆(无法归因为模型与库 dims 不配套)
54
+ const checked = [];
55
+ for (let j = 0; j < vectors.length; j++) {
56
+ const v = vectors[j];
57
+ if (!Array.isArray(v) || v.length !== this.dims) {
58
+ throw new Error(`HttpEmbedder "${this.model}" 第 ${j + 1} 条向量维度不符:期望 ${this.dims},实际 ${Array.isArray(v) ? v.length : '缺失/非数组'}(embedding 模型与向量库 dims 必须配套)`);
59
+ }
60
+ checked.push(v);
61
+ }
62
+ out.push(...checked);
63
+ }
64
+ return out;
65
+ }
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * InMemoryRetriever — 内存桩检索器(M1)
3
+ *
4
+ * 只为跑通/测试调用链,不做真检索;正式实现 LocalSqliteRetriever
5
+ * (gateway embed → 本机向量引擎 /search → 归一化,design-v2 §7.4)
6
+ * 面向同一 IRetriever 接口替换,react-loop 零改动。
7
+ */
8
+ import type { IRetriever, RetrievalDoc } from '../types.js';
9
+ export declare class InMemoryRetriever implements IRetriever {
10
+ readonly sourceId: string;
11
+ private readonly docs;
12
+ constructor(sourceId: string, docs: RetrievalDoc[]);
13
+ retrieve(_query: string, opts: {
14
+ topK: number;
15
+ userId: string;
16
+ }): Promise<RetrievalDoc[]>;
17
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * InMemoryRetriever — 内存桩检索器(M1)
3
+ *
4
+ * 只为跑通/测试调用链,不做真检索;正式实现 LocalSqliteRetriever
5
+ * (gateway embed → 本机向量引擎 /search → 归一化,design-v2 §7.4)
6
+ * 面向同一 IRetriever 接口替换,react-loop 零改动。
7
+ */
8
+ export class InMemoryRetriever {
9
+ sourceId;
10
+ docs;
11
+ constructor(sourceId, docs) {
12
+ this.sourceId = sourceId;
13
+ this.docs = docs;
14
+ }
15
+ async retrieve(_query, opts) {
16
+ return this.docs.slice(0, opts.topK);
17
+ }
18
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * LocalSqliteRetriever — 本地向量检索 IRetriever 实现(design-v2 §7.4)
3
+ *
4
+ * 两跳:embedder.embed(query)(远程向量化,统一走 gateway)→
5
+ * engine.search(后台线程 KNN,用户隔离 + 软删除过滤)→ 归一化 RetrievalDoc。
6
+ * 引擎实例与 embedder 均由宿主构造注入——runtime 不 import 引擎包,服务端
7
+ * 形态不挂本 retriever(部署差异零分支,§8)。
8
+ */
9
+ import type { IEmbedder, IRetriever, RagScope, RetrievalDoc, VectorEnginePort } from '../types.js';
10
+ export interface LocalSqliteRetrieverOptions {
11
+ /** 引擎端口实例(宿主 createAsyncEngine 产物即满足) */
12
+ engine: VectorEnginePort;
13
+ /** query 向量化(统一走 gateway embedding) */
14
+ embedder: IEmbedder;
15
+ /** 查询侧模型标识校验用;缺省取 embedder.model */
16
+ modelVersion?: string;
17
+ /** 源标识,缺省 'local' */
18
+ sourceId?: string;
19
+ }
20
+ export declare class LocalSqliteRetriever implements IRetriever {
21
+ readonly sourceId: string;
22
+ private readonly engine;
23
+ private readonly embedder;
24
+ private readonly modelVersion;
25
+ constructor(opts: LocalSqliteRetrieverOptions);
26
+ retrieve(query: string, opts: {
27
+ topK: number;
28
+ userId: string;
29
+ scope?: RagScope;
30
+ }): Promise<RetrievalDoc[]>;
31
+ /** 关键词精确检索(FTS5 bm25):端口未提供该能力时不暴露(上层不会把 keyword_search 进工具表) */
32
+ retrieveByKeyword(query: string, opts: {
33
+ topK: number;
34
+ userId: string;
35
+ scope?: RagScope;
36
+ }): Promise<RetrievalDoc[]>;
37
+ /**
38
+ * 请求级范围 → 引擎过滤键。语义三分:
39
+ * undefined(无 scope / 空对象)= 未圈定,不传 filter;
40
+ * fileIds 空数组且无 dirPaths = 圈定了但无本地源(会话勾选未含 local-file)→
41
+ * 引擎两路直接回空;fileIds 空数组与 dirPaths 并存是"无文件过滤、只圈目录"的
42
+ * 自然编码(契约并集语义),须走并集解析而非整体关闭;
43
+ * fileIds/dirPaths 有值 → dirPaths 经引擎解析(可选能力,未实现则仅 fileIds 生效)后并集。
44
+ */
45
+ private resolveFileIds;
46
+ }