@v587d/capital-generation 2.8.3 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +40 -19
  3. package/chart-ui/README.md +1 -1
  4. package/chart-ui/client.src.cjs +7 -16
  5. package/chart-ui/index.js +12 -33
  6. package/chart-ui/package.json +2 -2
  7. package/config/client.js +18 -39
  8. package/config/client.src.cjs +79 -217
  9. package/config/index.js +22 -72
  10. package/config/package.json +2 -2
  11. package/cordis.patch.yml +46 -51
  12. package/lib/agents/bash-guard.d.ts +9 -55
  13. package/lib/agents/bash-guard.js +27 -67
  14. package/lib/agents/preset-roster.d.ts +22 -0
  15. package/lib/agents/preset-roster.js +87 -0
  16. package/lib/agents/role-tool-policy.d.ts +46 -0
  17. package/lib/agents/role-tool-policy.js +205 -0
  18. package/lib/agents/root-tool-policy.d.ts +28 -124
  19. package/lib/agents/root-tool-policy.js +164 -237
  20. package/lib/agents/selected-skills-provider.d.ts +2 -6
  21. package/lib/agents/selected-skills-provider.js +27 -84
  22. package/lib/capital-settings.d.ts +27 -0
  23. package/lib/capital-settings.js +23 -0
  24. package/lib/chart/artifact-ref.d.ts +3 -8
  25. package/lib/chart/artifact-ref.js +3 -8
  26. package/lib/chart/downsample.d.ts +2 -5
  27. package/lib/chart/downsample.js +3 -8
  28. package/lib/chart/errors.d.ts +3 -7
  29. package/lib/chart/errors.js +1 -2
  30. package/lib/chart/event-context.d.ts +8 -0
  31. package/lib/chart/event-context.js +39 -0
  32. package/lib/chart/events.d.ts +21 -78
  33. package/lib/chart/events.js +40 -189
  34. package/lib/chart/html.d.ts +2 -5
  35. package/lib/chart/html.js +8 -15
  36. package/lib/chart/runtime.d.ts +5 -18
  37. package/lib/chart/runtime.js +14 -34
  38. package/lib/chart/series.d.ts +10 -17
  39. package/lib/chart/series.js +22 -47
  40. package/lib/chart/source-token.d.ts +5 -13
  41. package/lib/chart/source-token.js +18 -17
  42. package/lib/chart/spec.d.ts +6 -12
  43. package/lib/chart/spec.js +7 -31
  44. package/lib/chart/tool.d.ts +6 -29
  45. package/lib/chart/tool.js +9 -23
  46. package/lib/chart-render-entry.d.ts +9 -0
  47. package/lib/chart-render-entry.js +17 -0
  48. package/lib/chart-source-entry.d.ts +13 -0
  49. package/lib/chart-source-entry.js +20 -0
  50. package/lib/collect-entry.d.ts +9 -0
  51. package/lib/collect-entry.js +10 -0
  52. package/lib/collector-runtime.d.ts +22 -0
  53. package/lib/collector-runtime.js +97 -0
  54. package/lib/data-collector/args.d.ts +6 -24
  55. package/lib/data-collector/args.js +6 -24
  56. package/lib/data-collector/dataset-tools.d.ts +7 -1
  57. package/lib/data-collector/dataset-tools.js +27 -56
  58. package/lib/data-collector/describe.d.ts +8 -33
  59. package/lib/data-collector/describe.js +17 -70
  60. package/lib/data-collector/hub.d.ts +27 -85
  61. package/lib/data-collector/hub.js +19 -85
  62. package/lib/data-collector/query-time.d.ts +7 -26
  63. package/lib/data-collector/query-time.js +8 -27
  64. package/lib/data-collector/store.d.ts +40 -117
  65. package/lib/data-collector/store.js +32 -111
  66. package/lib/data-collector/time-axis.d.ts +16 -76
  67. package/lib/data-collector/time-axis.js +25 -111
  68. package/lib/data-collector/tools.d.ts +6 -6
  69. package/lib/data-collector/tools.js +34 -43
  70. package/lib/dataset-read-entry.d.ts +9 -0
  71. package/lib/dataset-read-entry.js +7 -0
  72. package/lib/index.d.ts +7 -117
  73. package/lib/index.js +43 -422
  74. package/lib/net/eastmoney-client.d.ts +13 -65
  75. package/lib/net/eastmoney-client.js +12 -53
  76. package/lib/net/throttle.d.ts +2 -13
  77. package/lib/net/throttle.js +3 -14
  78. package/lib/ocr/client.d.ts +12 -21
  79. package/lib/ocr/client.js +23 -40
  80. package/lib/ocr/markdown.d.ts +20 -26
  81. package/lib/ocr/markdown.js +23 -36
  82. package/lib/ocr/store.d.ts +14 -47
  83. package/lib/ocr/store.js +15 -45
  84. package/lib/ocr-entry.d.ts +9 -0
  85. package/lib/ocr-entry.js +10 -0
  86. package/lib/policy-entry.d.ts +17 -0
  87. package/lib/policy-entry.js +22 -0
  88. package/lib/preset-claim.d.ts +22 -0
  89. package/lib/preset-claim.js +24 -0
  90. package/lib/preset-ids.d.ts +38 -0
  91. package/lib/preset-ids.js +46 -0
  92. package/lib/retriever-config.d.ts +37 -0
  93. package/lib/retriever-config.js +36 -0
  94. package/lib/retriever-runtime.d.ts +31 -0
  95. package/lib/retriever-runtime.js +82 -0
  96. package/lib/root-extras-entry.d.ts +17 -0
  97. package/lib/root-extras-entry.js +25 -0
  98. package/lib/runtime-entry.d.ts +15 -0
  99. package/lib/runtime-entry.js +5 -0
  100. package/lib/runtime.d.ts +21 -0
  101. package/lib/runtime.js +33 -0
  102. package/lib/search-entry.d.ts +9 -0
  103. package/lib/search-entry.js +9 -0
  104. package/lib/sources/eastmoney-http.js +20 -68
  105. package/lib/sources/ftshare-rest.d.ts +6 -34
  106. package/lib/sources/ftshare-rest.js +84 -292
  107. package/lib/sources/fuyao-core.d.ts +8 -18
  108. package/lib/sources/fuyao-core.js +10 -21
  109. package/lib/sources/fuyao-rest.d.ts +2 -6
  110. package/lib/sources/fuyao-rest.js +23 -102
  111. package/lib/sources/security-code.d.ts +6 -12
  112. package/lib/sources/security-code.js +6 -12
  113. package/lib/sources/security-identity.d.ts +1 -4
  114. package/lib/sources/security-identity.js +3 -8
  115. package/lib/sources/tencent-http.js +14 -28
  116. package/lib/sources/tencent-public-core.d.ts +8 -60
  117. package/lib/sources/tencent-public-core.js +12 -77
  118. package/lib/sources/wind-mcp.d.ts +0 -5
  119. package/lib/sources/wind-mcp.js +23 -78
  120. package/lib/sources-entry.d.ts +9 -0
  121. package/lib/sources-entry.js +9 -0
  122. package/lib/time/tools.d.ts +8 -31
  123. package/lib/time/tools.js +30 -66
  124. package/lib/time-entry.d.ts +9 -0
  125. package/lib/time-entry.js +7 -0
  126. package/lib/tool-exec.d.ts +45 -11
  127. package/lib/tool-exec.js +59 -6
  128. package/lib/user-customization.d.ts +14 -0
  129. package/lib/user-customization.js +47 -0
  130. package/lib/watchlist/tools.d.ts +3 -13
  131. package/lib/watchlist/tools.js +10 -30
  132. package/lib/watchlist-entry.d.ts +9 -0
  133. package/lib/watchlist-entry.js +6 -0
  134. package/lib/web-retriever/engines.d.ts +6 -11
  135. package/lib/web-retriever/engines.js +9 -14
  136. package/lib/web-retriever/html-markdown.d.ts +14 -34
  137. package/lib/web-retriever/html-markdown.js +39 -75
  138. package/lib/web-retriever/local-fetch.d.ts +22 -58
  139. package/lib/web-retriever/local-fetch.js +33 -77
  140. package/lib/web-retriever/retriever.d.ts +6 -18
  141. package/lib/web-retriever/retriever.js +7 -7
  142. package/lib/web-retriever/sources.d.ts +37 -124
  143. package/lib/web-retriever/sources.js +64 -196
  144. package/lib/web-retriever/tools.d.ts +43 -27
  145. package/lib/web-retriever/tools.js +99 -137
  146. package/lib/web-retriever/wind-client.d.ts +14 -31
  147. package/lib/web-retriever/wind-client.js +16 -32
  148. package/locale/zh.json +1 -1
  149. package/package.json +67 -7
  150. package/preset/capital-generation/README.md +87 -53
  151. package/preset/capital-generation/agent.patch.yml +526 -337
  152. package/preset/capital-generation/skills/capital-data-profile/SKILL.md +2 -2
  153. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +1 -1
  154. package/preset/capital-generation/skills/capital-equity-research/SKILL.md +1 -1
  155. package/preset/capital-generation/skills/capital-event-impact/SKILL.md +1 -1
  156. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +15 -19
  157. package/preset/capital-generation/skills/capital-portfolio-review/SKILL.md +1 -1
  158. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +9 -12
  159. package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +1 -1
  160. package/watchlist/client.js +15 -148
  161. package/watchlist/client.src.cjs +148 -662
  162. package/watchlist/holding-rules.js +11 -38
  163. package/watchlist/index.js +127 -397
  164. package/watchlist/package.json +2 -2
@@ -2,49 +2,65 @@ import type { Context } from '@deepseek-ai/cordis';
2
2
  import { WebRetriever } from './retriever.js';
3
3
  import type { WindClient } from './wind-client.js';
4
4
  import type { LocalFetchOptions } from './local-fetch.js';
5
+ import { type AgentExecutionLike } from '../tool-exec.js';
5
6
  import { type WorkspaceDatasetStore } from '../data-collector/store.js';
6
7
  import { type PaddleOcrClient } from '../ocr/client.js';
7
8
  /**
8
- * 注册 web_retriever 的检索工具:anysearch(广度)两工具 + wind_docs(public_document
9
- * 精准)两工具 + `ocr`(文档解析)一工具。wind 与 ocr 都**无条件注册**——Key 缺失/服务
10
- * 不可用只影响调用结果(错误信封),不影响工具注册与子 Agent 创建。
11
- *
12
- * `ocr` 与 `datasetStore` 共用 workspace 落盘,而检索回声(`recent_retrievals` /
13
- * `provider_tally`)**必须**与另外四个工具共享同一份闭包,所以它在这同一个 `definitions`
14
- * 数组里注册,不另起一个注册函数。
9
+ * Use the framework's exec shape (`src/tool-exec.ts`): `ocr` needs the caller
10
+ * session; a self-made shape would bypass the real `header.cwd` path.
11
+ */
12
+ type ToolExecLike = AgentExecutionLike;
13
+ interface RetrievalTrace {
14
+ provider: string;
15
+ tool: string;
16
+ query: string;
17
+ at: number;
18
+ via?: string;
19
+ }
20
+ /**
21
+ * Per-caller-session, in-memory: capped recent retrievals plus per-provider tally.
22
+ * Advisory only (never blocks); retriever/client stay stateless.
23
+ */
24
+ interface RetrievalEcho {
25
+ recent: RetrievalTrace[];
26
+ tally: Record<string, number>;
27
+ /** The stored trace just written, so callers can backfill `via`. */
28
+ entry: RetrievalTrace;
29
+ /** Fresh snapshot after backfilling `entry.via`. */
30
+ snapshot: () => RetrievalTrace[];
31
+ }
32
+ /**
33
+ * Search family: anysearch search/fetch plus the two wind_docs tools. `echo` is
34
+ * per registration (one family row), so its receipts cover only this family.
35
+ */
36
+ export declare function registerSearchTools(ctx: Context, retriever: WebRetriever, windClient: WindClient, echo?: (exec: ToolExecLike, entry: RetrievalTrace) => RetrievalEcho): void;
37
+ /**
38
+ * Legacy single-bundle entry: search, wind and ocr tools share one echo, matching
39
+ * the old monolith behavior.
15
40
  */
16
41
  export declare function registerWebRetrieverTools(ctx: Context, retriever: WebRetriever, windClient: WindClient, ocr: OcrToolDependencies): void;
17
- /** `ocr` 的两项依赖:上游客户端 + workspace 落盘(与 Dataset 共用同一份实现)。 */
42
+ /** Register only `ocr` (root keeps local document parsing, no search/source tools). */
43
+ export declare function registerOcrTool(ctx: Context, ocr: OcrToolDependencies, echo?: (exec: ToolExecLike, entry: RetrievalTrace) => RetrievalEcho): void;
44
+ /** `ocr` deps: upstream client + workspace store (shared with Dataset). */
18
45
  export interface OcrToolDependencies {
19
46
  store: WorkspaceDatasetStore;
20
47
  client: PaddleOcrClient;
21
- /** 注入以便测试钉住 `created_at`。 */
48
+ /** Injectable for tests pinning `created_at`. */
22
49
  now?: () => number;
23
- /**
24
- * 注入以便测试:`url` 形态的公网性闸门默认走**真实 DNS**(与本机直连同一份实现,§9.7),
25
- * 生产路径不传。生产代码里没有任何调用方给它赋值——它只是把 DNS 这一步挪出断言范围。
26
- */
50
+ /** Test-only DNS override; production never sets it. */
27
51
  resolveAddresses?: LocalFetchOptions['resolveAddresses'];
28
52
  }
29
53
  /**
30
- * 一次具名来源输出的字符预算:取宿主 tool-result 剪枝阈值的 75%。
31
- * 阈值配在 `preset/capital-generation/agent.cordis.yml` 的 `thresholdChars: 8192`
32
- * (由 `test/persona.test.mjs` 钉住与本常量同源)。超过阈值的工具结果在会话历史里会被
33
- * `dsh-compaction-tool-result-pruner` 换成 head 4096 + `[... middle pruned ...]` + tail 1024,
34
- * 也就是**中间条目被无声吃掉**,而留在头部的 `count` 还写着原来的条数。
35
- * 留 25% 余量的做法与数据面能力目录的预算同一口径(`test/data-collector-hub.test.mjs`)。
54
+ * Source output budget = 75% of the host's 8192 tool-result pruning threshold.
55
+ * Over that, the middle is silently pruned while `count` still claims the full total.
36
56
  */
37
57
  export declare const SOURCE_OUTPUT_BUDGET_CHARS = 6144;
38
58
  /**
39
- * 注册九个具名来源工具(provider + operation 命名)。
40
- *
41
- * 为什么单独一个注册函数:这九个工具只依赖 HTTP 传输(`local-fetch` 的出口校验),
42
- * 不依赖 AnySearch / Wind;任何一路的配置缺失都不应连带影响另外几路的注册。
43
- *
44
- * `enabled`(设置卡片「允许启动本地提取网页内容」)**同时支配这九个工具的执行**:
45
- * 关闭时工具仍在注册面(不静默消失),但每次调用响亮失败并说明开关位置——
46
- * 卡片文案的字面语义对模型与用户都成立,用户同意不被旁路。
59
+ * Nine source tools depend only on HTTP egress, so they register independently of
60
+ * AnySearch/Wind config. `enabled=false` still registers but each call fails loudly
61
+ * naming the settings switch, so the switch semantics hold for user and model.
47
62
  */
48
63
  export declare function registerSourceTools(ctx: Context, localFetchOptions: LocalFetchOptions & {
49
64
  enabled?: boolean;
50
65
  }): void;
66
+ export {};
@@ -98,24 +98,9 @@ function fetchOutput(result, recent, tally) {
98
98
  };
99
99
  }
100
100
  /**
101
- * 把失败回执变成**工具错误**。
102
- *
103
- * 为什么必须抛而不是正常返回:DSH 用 `isError` 表达工具失败,而 `dsh-tools` 的
104
- * `createSuccessResult` 硬编码 `isError: false`——**返回值没有任何办法标记失败**,
105
- * 唯一通道是从 `execute` 抛出(`dispatchToolBody` 的 catch → `toolErrorResult`,
106
- * 产出 `{ text: "Error: …", isError: true, error: { message } }`)。官方
107
- * `web_fetch` / `web_search` 就是抛 `WebError`。
108
- *
109
- * 2026-09 实测缺陷:本工具此前把失败做成 `ok:false` 信封**正常返回**,于是子 Agent
110
- * 会话日志里 7 次实际失败的抓取全部记成 `isError: false`,UI 与模型都当成成功,
111
- * 任何基于 `isError` 的重试/统计/压缩逻辑也一并失效。
112
- *
113
- * 抛出的消息带**完整结构化回执**,所以 `via` / `fallback` / `local_error` /
114
- * `recent_retrievals` / `provider_tally` 与来源标注协议字段一个都不丢。
115
- *
116
- * 范围:只覆盖 `web_retriever_fetch`(本地回退所在工具)。`anysearch_search`
117
- * 与两个 `wind_docs_*` 工具的错误信号是同一类问题,但按 2026-09 决定**不在本次改动**
118
- * 内,审计与后续安排见 `docs/design/tool-result-error-signal.md`。
101
+ * Tool failures must be thrown: DSH hardcodes `isError: false` on success results,
102
+ * so a returned `ok:false` envelope is logged as success. The thrown message carries
103
+ * the full structured receipt so via/fallback/local_error survive.
119
104
  */
120
105
  function throwToolFailure(payload) {
121
106
  throw new Error(JSON.stringify(payload));
@@ -139,19 +124,13 @@ const WIND_NEWS_TOOL = 'wind_docs_news';
139
124
  const WIND_ANNOUNCEMENTS_UPSTREAM = 'get_company_announcements';
140
125
  const WIND_NEWS_UPSTREAM = 'get_financial_news';
141
126
  /**
142
- * 注册 web_retriever 的检索工具:anysearch(广度)两工具 + wind_docs(public_document
143
- * 精准)两工具 + `ocr`(文档解析)一工具。wind 与 ocr 都**无条件注册**——Key 缺失/服务
144
- * 不可用只影响调用结果(错误信封),不影响工具注册与子 Agent 创建。
145
- *
146
- * `ocr` 与 `datasetStore` 共用 workspace 落盘,而检索回声(`recent_retrievals` /
147
- * `provider_tally`)**必须**与另外四个工具共享同一份闭包,所以它在这同一个 `definitions`
148
- * 数组里注册,不另起一个注册函数。
127
+ * Search family: anysearch search/fetch plus the two wind_docs tools. `echo` is
128
+ * per registration (one family row), so its receipts cover only this family.
149
129
  */
150
- export function registerWebRetrieverTools(ctx, retriever, windClient, ocr) {
130
+ export function registerSearchTools(ctx, retriever, windClient, echo = createRetrievalEcho()) {
151
131
  const tools = ctx.get('tools');
152
132
  if (!tools)
153
133
  return;
154
- const echo = createRetrievalEcho();
155
134
  const trace = (exec, provider, tool, query) => echo(exec, { provider, tool, query, at: Date.now() });
156
135
  const definitions = [
157
136
  {
@@ -182,7 +161,7 @@ export function registerWebRetrieverTools(ctx, retriever, windClient, ocr) {
182
161
  const result = await retriever.fetch(url, exec.signal);
183
162
  entry.via = result.via;
184
163
  const payload = fetchOutput(result, snapshot(), tally);
185
- // 失败必须变成工具错误(isError: true),不能做成"正常返回的失败信封"。
164
+ // A failure must throw (isError: true); a successful-looking error envelope is not.
186
165
  if (result.ok === false)
187
166
  throwToolFailure(payload);
188
167
  return payload;
@@ -220,40 +199,58 @@ export function registerWebRetrieverTools(ctx, retriever, windClient, ocr) {
220
199
  return windOutput(WIND_NEWS_TOOL, query, result, recent, tally);
221
200
  },
222
201
  },
223
- {
224
- name: 'ocr',
225
- timeoutMs: OCR_TOOL_TIMEOUT_MS,
226
- description: '把一份 PDF 或图片解析成模型可读的 markdown(PaddleOCR 文档解析,需要 Token)。一次调用只走一种形态:url=公网 .pdf 直链(本机不抓文件,由上游取);file=session workspace 内的相对路径(pdf/png/jpg/jpeg/webp/bmp/tiff,@mention 带来的开头 @ 可省);job_id + doc_id=继续等上一次 status:pending 回执里的作业(**不重复提交、不再花钱**);doc_id=读已落盘的结果,可配 pages("1-3" / "5" / "2-4,9",一次最多 8 页)或 query(关键词定位,回命中片段与页号)。⚠️ 作业**整篇一次算完**:pages 只影响读、不影响算价,也不会更便宜;同一份文档重复调用会命中 doc_id 缓存、不再出网。结果一律落 capital-data/ocr/<doc_id>/,回执在预算内给全文、超预算只给页索引与第 1 页。没有 Token、解析失败或文档超页时须如实说明边界:不得把标题当正文引用,不得把 PDF 链接当正文来源标注(正文来自解析结果,引用写 doc_id 与页号)。',
227
- parameters: jsonObject({
228
- url: { type: 'string', description: '公网可达的 .pdf 直链(http/https,不含凭据,不接受内网地址与 IP 字面量)' },
229
- file: { type: 'string', description: 'session workspace 内的相对路径,例如 refs/贵州茅台研报.pdf' },
230
- job_id: { type: 'string', description: '上一次回执 status:pending 里的 job_id;必须与同一回执的 doc_id 一起传' },
231
- doc_id: { type: 'string', description: '解析结果标识(形如 ocr_1a2b3c4d5e6f),来自回执' },
232
- pages: { type: 'string', description: '配合 doc_id 读取的页区间,1 起始闭区间,例如 "2-4,9";一次最多 8 页' },
233
- query: { type: 'string', description: `配合 doc_id 的关键词定位,最多 ${OCR_MAX_QUERY_CHARS} 字符` },
234
- limit: { type: 'integer', description: `query 命中数上限 1~${OCR_MAX_QUERY_LIMIT},默认 ${OCR_DEFAULT_QUERY_LIMIT}` },
235
- charts: { type: 'boolean', description: '可选,是否启用图表识别(默认 true);只影响 url/file 形态的这一次提交' },
236
- refresh: { type: 'boolean', description: '可选,忽略缓存重新解析(默认 false);只接受与 url/file 同时传,重跑会再花一次 job' },
237
- }, []),
238
- output: { schema: { type: 'object', additionalProperties: true }, render },
239
- async execute(args, exec) {
240
- return runOcr(ocr, trace, args, exec);
241
- },
242
- },
243
202
  ];
244
203
  for (const definition of definitions) {
245
204
  ctx.effect(() => tools.register(definition), `capital-generation.tool(${definition.name})`);
246
205
  }
247
206
  }
248
- // ── 文档解析面:ocr(PDF / 图片 → markdown,产物一律落盘)───────────────────
207
+ /**
208
+ * Legacy single-bundle entry: search, wind and ocr tools share one echo, matching
209
+ * the old monolith behavior.
210
+ */
211
+ export function registerWebRetrieverTools(ctx, retriever, windClient, ocr) {
212
+ const echo = createRetrievalEcho();
213
+ registerSearchTools(ctx, retriever, windClient, echo);
214
+ registerOcrTool(ctx, ocr, echo);
215
+ }
216
+ /** `ocr` tool definition shared by the retrieve entry and the root local-only entry. */
217
+ function ocrToolDefinition(ocr, trace) {
218
+ return {
219
+ name: 'ocr',
220
+ timeoutMs: OCR_TOOL_TIMEOUT_MS,
221
+ description: '把一份 PDF 或图片解析成模型可读的 markdown(PaddleOCR 文档解析,需要 Token)。一次调用只走一种形态:url=公网 .pdf 直链(本机不抓文件,由上游取);file=session workspace 内的相对路径(pdf/png/jpg/jpeg/webp/bmp/tiff,@mention 带来的开头 @ 可省);job_id + doc_id=继续等上一次 status:pending 回执里的作业(**不重复提交、不再花钱**);doc_id=读已落盘的结果,可配 pages("1-3" / "5" / "2-4,9",一次最多 8 页)或 query(关键词定位,回命中片段与页号)。⚠️ 作业**整篇一次算完**:pages 只影响读、不影响算价,也不会更便宜;同一份文档重复调用会命中 doc_id 缓存、不再出网。结果一律落 capital-data/ocr/<doc_id>/,回执在预算内给全文、超预算只给页索引与第 1 页。没有 Token、解析失败或文档超页时须如实说明边界:不得把标题当正文引用,不得把 PDF 链接当正文来源标注(正文来自解析结果,引用写 doc_id 与页号)。',
222
+ parameters: jsonObject({
223
+ url: { type: 'string', description: '公网可达的 .pdf 直链(http/https,不含凭据,不接受内网地址与 IP 字面量)' },
224
+ file: { type: 'string', description: 'session workspace 内的相对路径,例如 refs/贵州茅台研报.pdf' },
225
+ job_id: { type: 'string', description: '上一次回执 status:pending 里的 job_id;必须与同一回执的 doc_id 一起传' },
226
+ doc_id: { type: 'string', description: '解析结果标识(形如 ocr_1a2b3c4d5e6f),来自回执' },
227
+ pages: { type: 'string', description: '配合 doc_id 读取的页区间,1 起始闭区间,例如 "2-4,9";一次最多 8 页' },
228
+ query: { type: 'string', description: `配合 doc_id 的关键词定位,最多 ${OCR_MAX_QUERY_CHARS} 字符` },
229
+ limit: { type: 'integer', description: `query 命中数上限 1~${OCR_MAX_QUERY_LIMIT},默认 ${OCR_DEFAULT_QUERY_LIMIT}` },
230
+ charts: { type: 'boolean', description: '可选,是否启用图表识别(默认 true);只影响 url/file 形态的这一次提交' },
231
+ refresh: { type: 'boolean', description: '可选,忽略缓存重新解析(默认 false);只接受与 url/file 同时传,重跑会再花一次 job' },
232
+ }, []),
233
+ output: { schema: { type: 'object', additionalProperties: true }, render },
234
+ async execute(args, exec) {
235
+ return runOcr(ocr, trace, args, exec);
236
+ },
237
+ };
238
+ }
239
+ /** Register only `ocr` (root keeps local document parsing, no search/source tools). */
240
+ export function registerOcrTool(ctx, ocr, echo = createRetrievalEcho()) {
241
+ const tools = ctx.get('tools');
242
+ if (!tools)
243
+ return;
244
+ const trace = (exec, provider, tool, query) => echo(exec, { provider, tool, query, at: Date.now() });
245
+ const definition = ocrToolDefinition(ocr, trace);
246
+ ctx.effect(() => tools.register(definition), 'capital-generation.tool(ocr)');
247
+ }
248
+ // Document parsing: ocr (PDF / image -> markdown, always persisted)
249
249
  //
250
- // 与前两组的分工:search / fetch 是**检索**工作面,具名来源是**查询**工作面,`ocr` 是
251
- // **解析**工作面——把一个已经确定的文档变成模型可读的正文。它因此不绑任何具体来源
252
- // (谁的 `.pdf` 直链都能喂进来),也**不受 `localFetch.enabled` 支配**:那道开关的字面
253
- // 语义是"允许本机直连提取网页内容",而这里的文件由上游解析服务取,用户同意的是另一件事
254
- // (Token 卡片)。判据写在 `docs/dev/web-retriever.md` §4.3。
250
+ // This is the parse workface: not bound to a source, and not gated by
251
+ // `localFetch.enabled` (upstream fetches the file; user consent is the token card).
255
252
  const OCR_TOOL_TIMEOUT_MS = 240_000;
256
- /** 一次 `pages` 读取的页数上限:与"整篇一次算完"配对,限制的是**回集体积**,不是算力。 */
253
+ /** Read-back cap per `pages` call; limits receipt size, not compute (whole doc was parsed). */
257
254
  const OCR_MAX_PAGES_PER_READ = 8;
258
255
  const OCR_QUERY_WINDOW_CHARS = 320;
259
256
  const OCR_MAX_QUERY_CHARS = 64;
@@ -261,7 +258,7 @@ const OCR_DEFAULT_QUERY_LIMIT = 5;
261
258
  const OCR_MAX_QUERY_LIMIT = 20;
262
259
  const OCR_LOCAL_SUFFIXES = ['pdf', 'png', 'jpg', 'jpeg', 'webp', 'bmp', 'tiff'];
263
260
  const PDF_URL_SUFFIX = /\.pdf$/iu;
264
- /** 工具侧自己判定出来的失败(参数形态、缓存缺失),带一条能照做的 hint。 */
261
+ /** Tool-side failure (argument shape, missing cache) with an actionable hint. */
265
262
  class OcrToolError extends Error {
266
263
  code;
267
264
  hint;
@@ -280,10 +277,8 @@ function errorMessage(error) {
280
277
  return error instanceof Error ? error.message : String(error);
281
278
  }
282
279
  /**
283
- * 半件产物(meta 在、正文不在)怎么修:把**能照抄的那一次调用**写进 hint。
284
- *
285
- * 指认来自 meta,不来自调用方——`doc_id` 是内容寻址的,用同一个 source 重跑必然落回同一个
286
- * `doc_id`,所以这条 hint 是无脑可执行的。认不出 source 形态时只说"重新解析一次",不编参数。
280
+ * Build the repair hint from meta: doc_id is content-addressed, so replaying the
281
+ * stored source lands on the same artifact. Unknown source => generic advice.
287
282
  */
288
283
  function artifactRepair(sourceKind, source) {
289
284
  const prefix = sourceKind === 'url' ? 'url:' : sourceKind === 'file' ? 'file:' : undefined;
@@ -293,11 +288,7 @@ function artifactRepair(sourceKind, source) {
293
288
  const argument = JSON.stringify(source.slice(prefix.length));
294
289
  return `再调一次 { ${prefix.slice(0, -1)}: ${argument}, refresh: true } 重新解析(doc_id 由内容寻址,重跑落回同一个产物)`;
295
290
  }
296
- /**
297
- * 三条实现各自的异常(`OcrToolError` / `OcrError` / 出口闸门 / `DatasetStoreError`)
298
- * 翻成**一套**回执码。认不出的仍退回 `INVALID_INPUT` 并照抄消息——绝不假装知道上游
299
- * 或宿主失败的原因。
300
- */
291
+ /** Map all implementation errors to one receipt code set; unknown => INVALID_INPUT. */
301
292
  function ocrFailure(error) {
302
293
  if (error instanceof OcrToolError) {
303
294
  return { code: error.code, error: error.message, ...(error.hint ? { hint: error.hint } : {}) };
@@ -305,8 +296,7 @@ function ocrFailure(error) {
305
296
  if (error instanceof OcrError)
306
297
  return { code: error.code, error: error.message };
307
298
  if (error instanceof OcrArtifactError) {
308
- // 产物坏了不是调用方传错了:说成 INVALID_INPUT 会把模型推到"换个参数再猜",
309
- // 而正确的下一步只有一条——拿 meta 里的 source 重跑那一次解析。
299
+ // A corrupt artifact is not caller error; the only fix is replaying meta.source.
310
300
  return { code: 'RESULT_INVALID', error: error.message, hint: artifactRepair(error.sourceKind, error.source) };
311
301
  }
312
302
  const code = error?.code;
@@ -359,10 +349,8 @@ function requireDocId(value) {
359
349
  return docId;
360
350
  }
361
351
  /**
362
- * 四形态的**边界归一化**(`docs/dev/tool-schema.md` §9.2:多形态靠可选属性 + 归一化,一个工具名)。
363
- *
364
- * 歧义一律响亮拒绝而不是"挑一个":`url` + `file` 同时给、`pages` 配 `query`、`refresh`
365
- * 配 `doc_id`——静默选边会让模型以为自己要的东西还在。
352
+ * Normalize the four input forms here. Ambiguous combinations fail loudly instead
353
+ * of silently picking one.
366
354
  */
367
355
  function ocrRequest(args) {
368
356
  const present = (key) => args[key] !== undefined && args[key] !== null && args[key] !== '';
@@ -412,7 +400,7 @@ function ocrRequest(args) {
412
400
  const file = requiredString(args.file, 'file', 512);
413
401
  return { mode: 'file', label: file, file, charts, refresh };
414
402
  }
415
- /** 本地文档路径:剥掉 `@mention` 带进来的开头 `@`,再按后缀放行(真正的归属校验在 store)。 */
403
+ /** Strip a leading `@` from @mention; suffix allowlist only (ownership is checked in store). */
416
404
  function normalizeLocalPath(value) {
417
405
  const raw = value.startsWith('@') ? value.slice(1).trim() : value.trim();
418
406
  const suffix = (raw.includes('.') ? raw.slice(raw.lastIndexOf('.') + 1) : '').toLowerCase();
@@ -454,7 +442,7 @@ function metaFields(meta, cached) {
454
442
  : {}),
455
443
  };
456
444
  }
457
- /** 排版噪音的量化说明:证明"变小"是剥样式,不是被截断。 */
445
+ /** Quantify cleanup: proves the shrink is style removal, not truncation. */
458
446
  function cleanupNote(meta) {
459
447
  if (meta.style_chars_removed === 0 && meta.tables === 0)
460
448
  return undefined;
@@ -465,10 +453,7 @@ function cleanupNote(meta) {
465
453
  parts.push(`${meta.tables} 张 HTML 表已转成 markdown 表格`);
466
454
  return parts.join(';');
467
455
  }
468
- /**
469
- * `done` 回执的装载:整篇放得下就给全文,否则给页索引 + 第 1 页;连索引都放不下就只给
470
- * 边界说明。**永不半路截断**——截断的正文会被当成完整正文引用,比少给一页危险。
471
- */
456
+ /** Whole text if it fits, else page index + page 1; never truncate mid-page. */
472
457
  function doneReceipt(artifact, input) {
473
458
  const { meta, document } = artifact;
474
459
  const cleanup = cleanupNote(meta);
@@ -486,7 +471,7 @@ function doneReceipt(artifact, input) {
486
471
  ? undefined
487
472
  : { page: first.page, chars: first.chars, ...(first.heading === undefined ? {} : { heading: first.heading }), text: document.document.slice(first.start, first.end) };
488
473
  const withIndex = { ...base, index, note: budgetNote(meta, cleanup, false) };
489
- // 判断装载用的是**那一条**note:换措辞后再测会让"放不下"变成"其实放不下"(多 3 个字符也算越界)。
474
+ // Measure with the exact note being shipped; a rewording could push it over.
490
475
  const withPageOne = pageOne === undefined ? undefined : { ...withIndex, page_one: pageOne, note: budgetNote(meta, cleanup, true) };
491
476
  if (withPageOne && fitsBudget(withPageOne))
492
477
  return withPageOne;
@@ -499,8 +484,8 @@ function doneReceipt(artifact, input) {
499
484
  return { ...base, note: `${meta.pages} 页超出预算,连页索引都放不下:用 pages="1-${OCR_MAX_PAGES_PER_READ}" 分段读,或用 query 定位` };
500
485
  }
501
486
  /**
502
- * 超预算时的那句说明必须**算得过来**:早先写的是"整篇 N 字符超出预算 6144",而 5717 < 6144
503
- * 也是这句话——被装的从来不只是正文,还有信封与页索引。说成"正文 + 回执装不进"才是事实。
487
+ * Budget note must be accurate: the budget covers text plus envelope and index,
488
+ * so say "text + receipt does not fit", not "text exceeds N".
504
489
  */
505
490
  function budgetNote(meta, cleanup, withPageOne) {
506
491
  const line = `整篇正文 ${meta.chars} 字符连同回执装不进 ${SOURCE_OUTPUT_BUDGET_CHARS} 字符预算:这里给${withPageOne ? '页索引与首页' : '页索引'},正文用 pages 或 query 取(不再出网、不再花钱)`;
@@ -519,7 +504,7 @@ function pendingReceipt(input) {
519
504
  ...echoFields(input.echo),
520
505
  };
521
506
  }
522
- /** `pages` 形态:整页给,放不下就**整页**从尾部让路(不切半页)。 */
507
+ /** `pages` form: whole pages; overflow drops whole pages from the tail. */
523
508
  function pagesReceipt(input) {
524
509
  const { meta, document } = input.artifact;
525
510
  const envelope = (excerpts, omitted) => ({
@@ -538,7 +523,7 @@ function pagesReceipt(input) {
538
523
  ...(entry.heading === undefined ? {} : { heading: entry.heading }),
539
524
  text: entry.text,
540
525
  };
541
- // 第一条永不因预算丢弃以外的理由跳过:单页就超预算时宁可空手报错,也不给半页。
526
+ // First page is never skipped for budget: a single oversized page errors instead of half-shipping.
542
527
  if (!fitsBudget(envelope([...excerpts, item], []))) {
543
528
  omitted = input.requested.slice(excerpts.length);
544
529
  break;
@@ -555,7 +540,7 @@ function pagesReceipt(input) {
555
540
  ...(notes.length > 0 ? { note: notes.join(';') } : {}),
556
541
  };
557
542
  }
558
- /** `query` 形态:命中窗口从尾部裁,永不给出半截列表还假装完整。 */
543
+ /** `query` form: hit windows are trimmed from the tail, never a half list. */
559
544
  function queryReceipt(input) {
560
545
  const { meta, document } = input.artifact;
561
546
  const found = locateQuery(document, input.query, input.limit, OCR_QUERY_WINDOW_CHARS);
@@ -583,7 +568,7 @@ function queryReceipt(input) {
583
568
  note: notes.join(';'),
584
569
  };
585
570
  }
586
- /** 解析完成后的统一出口:落盘(内容寻址、默认不覆盖)→ 回执。 */
571
+ /** Persist (content-addressed, no overwrite by default), then build the receipt. */
587
572
  async function persistAndReceipt(deps, input) {
588
573
  const meta = buildArtifactMeta({
589
574
  doc_id: input.doc_id,
@@ -599,8 +584,8 @@ async function persistAndReceipt(deps, input) {
599
584
  await writeOcrArtifact(deps.store, { session: input.session, meta, document: input.document, signal: input.signal, overwrite: input.refresh });
600
585
  }
601
586
  catch (error) {
602
- // 两个会话同时在解析同一份文档:后写的撞 createIfAbsent。读回先写的那份即可——
603
- // 内容寻址保证两者同文,把它报成失败只是把一次成功说成错误。
587
+ // Concurrent writers race on createIfAbsent: read back the winner (same content
588
+ // address) instead of reporting a success as failure.
604
589
  if (input.refresh)
605
590
  throw error;
606
591
  const existing = await readOcrArtifact(deps.store, { session: input.session, doc_id: input.doc_id, signal: input.signal });
@@ -611,16 +596,9 @@ async function persistAndReceipt(deps, input) {
611
596
  return doneReceipt({ meta, document: input.document }, { cached: false, elapsed_ms: input.elapsed_ms, echo: input.echo });
612
597
  }
613
598
  /**
614
- * 失败回执:`{provider, status:'error', ok:false, code, error, hint?}` + 已知的 `doc_id` /
615
- * `job_id` / 检索回声。
616
- *
617
- * 形态归一化(`ocrRequest`)的失败**同样**要走这里:那一层的 `hint` 是"下一步该传什么",
618
- * 抛裸 `Error` 就只剩一句中文,模型照旧会再猜一次。回声此时还没有(连请求都无法指认),
619
- * 所以缺省不带,而不是硬编一个空标签冒充。
620
- *
621
- * `spent` 是**本次调用已经产生花费**的指认:`url`/`file` 形态的 `doc_id` 是算出来的、
622
- * `job_id` 是提交回来的,都不在入参里。失败时把它们交回去,模型才能走"只传 job_id + doc_id
623
- * 续查"那条不花钱的路;不给就等于把一次已经付费的作业丢在半路。
599
+ * Error receipt. Normalization failures also land here so their hint survives.
600
+ * `spent` returns the computed doc_id / submitted job_id for already-paid work,
601
+ * so the model can resume instead of paying again.
624
602
  */
625
603
  function ocrErrorReceipt(error, input) {
626
604
  const docId = input.requested?.doc_id ?? input.spent?.doc_id;
@@ -686,7 +664,7 @@ async function runOcr(deps, trace, args, exec) {
686
664
  echo,
687
665
  });
688
666
  }
689
- // 解析形态:先算 doc_id,命中缓存就**一次都不出网**。
667
+ // Compute doc_id first; a cache hit makes zero upstream calls.
690
668
  const target = requested.mode === 'url'
691
669
  ? await pdfUrlTarget(requested.url, signal, deps.resolveAddresses)
692
670
  : await localFileTarget(deps, requested.file, session, signal);
@@ -718,15 +696,15 @@ async function runOcr(deps, trace, args, exec) {
718
696
  });
719
697
  }
720
698
  catch (error) {
721
- // 取消原样抛出:那是调用方的意志,不能被降级成一条"看起来像上游坏了"的错误信封。
699
+ // Cancellation is the caller's intent: rethrow, do not wrap as upstream failure.
722
700
  if (isAbortError(error))
723
701
  throw error;
724
702
  throwToolFailure(ocrErrorReceipt(error, { requested, echo, spent }));
725
703
  }
726
704
  }
727
705
  /**
728
- * `url` 形态:出口闸门复用本机直连那一份实现(形态 + 主机公网性),再收 `.pdf` 后缀。
729
- * 本机**不抓**这个文件——上游替我们取,所以这里没有体积上限,只有 SSRF 面。
706
+ * `url` form: reuse the local-fetch public-host gate, then require `.pdf`. We do
707
+ * not download the file (upstream does), so there is no size cap here, only SSRF.
730
708
  */
731
709
  async function pdfUrlTarget(value, signal, resolveAddresses) {
732
710
  const url = await assertPublicUrlTarget(value, signal, resolveAddresses);
@@ -736,31 +714,26 @@ async function pdfUrlTarget(value, signal, resolveAddresses) {
736
714
  }
737
715
  return { seed: `url:${url}`, source: `url:${url}`, sourceKind: 'url', submit: { fileUrl: url } };
738
716
  }
739
- /** `file` 形态:读出字节 + 指认(`source` 只记 workspace 相对路径,绝不记绝对路径)。 */
717
+ /** `file` form: read bytes; provenance records the workspace-relative path only. */
740
718
  async function localFileTarget(deps, value, session, signal) {
741
719
  const path = normalizeLocalPath(value);
742
720
  const { bytes, filename } = await readOcrSourceBytes(deps.store, { session, path, signal });
743
721
  return { seed: bytes, source: `file:${path}`, sourceKind: 'file', submit: { file: { bytes, filename } } };
744
722
  }
745
- // ── 具名来源的结构化查询工具 ────────────────────────────────────────────────
723
+ // Named-source query tools
746
724
  //
747
- // 与上面四个工具的关系:search / fetch 是**检索**工作面(候选发现、按 URL 取正文),
748
- // 这里是**查询**工作面(具名来源 + 业务参数 → 确定、有序、可翻页、同参可复现的结果集)。
749
- // 九个来源各自是一次具体实现,没有配方注册表 / 通用适配层——见 `sources.ts` 文件头。
725
+ // Query workface vs. search/fetch: source + params -> deterministic, ordered,
726
+ // pageable result set. Each source is concrete; see `sources.ts` header.
750
727
  const SOURCE_RESULT_CAP = MAX_ITEMS;
751
728
  const SOURCE_PAGE_SIZE_MAX = 50;
752
- /** 六个带 code 的工具共用这一句"能写什么",描述的写法与校验的实现因此不会各说各话。 */
729
+ /** Shared by six code-taking tools so description and validation cannot drift. */
753
730
  const CODE_FORMS = '(也可写 SH600519 / 600519.SH,会归一化成 6 位)';
754
731
  /**
755
- * 一次具名来源输出的字符预算:取宿主 tool-result 剪枝阈值的 75%。
756
- * 阈值配在 `preset/capital-generation/agent.cordis.yml` 的 `thresholdChars: 8192`
757
- * (由 `test/persona.test.mjs` 钉住与本常量同源)。超过阈值的工具结果在会话历史里会被
758
- * `dsh-compaction-tool-result-pruner` 换成 head 4096 + `[... middle pruned ...]` + tail 1024,
759
- * 也就是**中间条目被无声吃掉**,而留在头部的 `count` 还写着原来的条数。
760
- * 留 25% 余量的做法与数据面能力目录的预算同一口径(`test/data-collector-hub.test.mjs`)。
732
+ * Source output budget = 75% of the host's 8192 tool-result pruning threshold.
733
+ * Over that, the middle is silently pruned while `count` still claims the full total.
761
734
  */
762
735
  export const SOURCE_OUTPUT_BUDGET_CHARS = 6144;
763
- /** 预算里为"承认省略了几条"那句话预留的体积:不预留的话,加上这句话反而会再次越界。 */
736
+ /** Headroom for the "N items omitted" note, which itself counts against the budget. */
764
737
  const TRUNCATION_NOTE_RESERVE = 200;
765
738
  function sourceOutput(outcome, recent, tally) {
766
739
  const candidates = outcome.items.slice(0, SOURCE_RESULT_CAP);
@@ -781,7 +754,8 @@ function sourceOutput(outcome, recent, tally) {
781
754
  provider_tally: tally,
782
755
  };
783
756
  };
784
- // 按上游顺序**从尾部裁**,不跳着放:跳着放会让"同参数同结果"失效,count 也不再是前缀长度。
757
+ // Trim from the tail in upstream order: skipping items breaks "same params, same
758
+ // result" and `count` would no longer be a prefix length.
785
759
  let taken = 0;
786
760
  let used = JSON.stringify(envelope([], 0)).length + TRUNCATION_NOTE_RESERVE;
787
761
  for (const item of candidates) {
@@ -793,19 +767,15 @@ function sourceOutput(outcome, recent, tally) {
793
767
  }
794
768
  let items = candidates.slice(0, taken);
795
769
  let payload = envelope(items, candidates.length - items.length);
796
- // 兜底自检:算术里已经预留了截断说明的体积,真超了(note 本身很长)就再退一条,
797
- // 宁可少一条也不把整份结果交给剪枝器切成头尾两截。
770
+ // Safety net: if the reserved headroom was not enough (long note), drop one more
771
+ // item rather than let the pruner split the result.
798
772
  while (items.length > 1 && JSON.stringify(payload).length > SOURCE_OUTPUT_BUDGET_CHARS) {
799
773
  items = items.slice(0, -1);
800
774
  payload = envelope(items, candidates.length - items.length);
801
775
  }
802
776
  return payload;
803
777
  }
804
- /**
805
- * 失败也做成**工具错误**(`isError`),与 `web_retriever_fetch` 同一口径:
806
- * 宿主用 `isError` 表达失败,正常返回无法标记失败(见 `throwToolFailure` 注释)。
807
- * 抛出的消息带完整结构化回执,`recent_retrievals` / `provider_tally` 一个不丢。
808
- */
778
+ /** Same isError convention as `web_retriever_fetch`; receipt stays structured. */
809
779
  function throwSourceFailure(source, operation, error, recent, tally) {
810
780
  const code = error instanceof SourceError ? error.code : 'NETWORK';
811
781
  throwToolFailure({
@@ -822,14 +792,9 @@ function sourceItemsText(items) {
822
792
  return items.length === 0 ? '(0 条)' : `${items.length} 条`;
823
793
  }
824
794
  /**
825
- * 注册九个具名来源工具(provider + operation 命名)。
826
- *
827
- * 为什么单独一个注册函数:这九个工具只依赖 HTTP 传输(`local-fetch` 的出口校验),
828
- * 不依赖 AnySearch / Wind;任何一路的配置缺失都不应连带影响另外几路的注册。
829
- *
830
- * `enabled`(设置卡片「允许启动本地提取网页内容」)**同时支配这九个工具的执行**:
831
- * 关闭时工具仍在注册面(不静默消失),但每次调用响亮失败并说明开关位置——
832
- * 卡片文案的字面语义对模型与用户都成立,用户同意不被旁路。
795
+ * Nine source tools depend only on HTTP egress, so they register independently of
796
+ * AnySearch/Wind config. `enabled=false` still registers but each call fails loudly
797
+ * naming the settings switch, so the switch semantics hold for user and model.
833
798
  */
834
799
  export function registerSourceTools(ctx, localFetchOptions) {
835
800
  const tools = ctx.get('tools');
@@ -837,11 +802,9 @@ export function registerSourceTools(ctx, localFetchOptions) {
837
802
  return;
838
803
  const echo = createRetrievalEcho();
839
804
  const transport = createSourceTransport(localFetchOptions);
840
- // 东财系三个工具(7×24 快讯 / 个股新闻 / 研报)**共用同一个节流器**:它们打到同一个上游、
841
- // 共用主机的出口 IP,各自限流等于没限(详见 `src/net/eastmoney-client.ts`)。
842
- // 与数据面共享**同一份进程级节流**(`sharedEastmoneyThrottle()`),但 transport 注入
843
- // 闸门实现(`createHttpRequester`):出口校验、逐跳重定向重校验、大小/超时上限与其余
844
- // 六个来源同一份(此前走裸 fetch,是"声称一份、实际两份"的事故形态,AGENTS.md §9.7)。
805
+ // The three eastmoney tools hit one upstream from one egress IP, so they share one
806
+ // process-wide throttle and the same gated transport; a second bare fetch path is
807
+ // the "two implementations" regression (AGENTS.md §9.7).
845
808
  const eastmoneyClient = createEastmoneyClient({
846
809
  userAgent: localFetchOptions.userAgent,
847
810
  throttle: sharedEastmoneyThrottle(),
@@ -877,9 +840,8 @@ export function registerSourceTools(ctx, localFetchOptions) {
877
840
  outcome = await produce();
878
841
  }
879
842
  catch (error) {
880
- // 调用方取消**原样抛出**,绝不降级成失败信封:取消不是"这个来源查不到",
881
- // 而把它写成 ok:false 会让上层以为来源失败(与 web_retriever_fetch 同口径)。
882
- // 传输层抛的是 `LocalFetchError('ABORTED')`,来源层抛的是 `SourceError('ABORTED')`。
843
+ // Caller cancel rethrows untouched (`LocalFetchError('ABORTED')` or
844
+ // `SourceError('ABORTED')`); only real failures become receipts.
883
845
  const code = error instanceof SourceError ? error.code : error?.code;
884
846
  if (code === 'ABORTED' || (error instanceof Error && error.name === 'AbortError'))
885
847
  throw error;