argo-search 2.9.7 → 2.9.8

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 (140) hide show
  1. package/README.en.md +58 -145
  2. package/README.es.md +58 -145
  3. package/README.ja.md +64 -151
  4. package/README.ko.md +63 -150
  5. package/README.md +65 -154
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +74 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +219 -60
  15. package/docs/ENGINE_CATALOG.md +50 -39
  16. package/docs/MCP_SETUP.md +3 -1
  17. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  18. package/engines/specs/cfets.yaml +41 -0
  19. package/engines/specs/defillama.yaml +38 -0
  20. package/engines/specs/firecrawl.yaml +1 -1
  21. package/engines/specs/fxtwitter.yaml +5 -0
  22. package/engines/specs/gleif.yaml +55 -0
  23. package/engines/specs/hkex.yaml +40 -0
  24. package/engines/specs/keenable_free.yaml +36 -0
  25. package/engines/specs/shfe.yaml +39 -0
  26. package/engines/specs/stackexchange.yaml +2 -2
  27. package/engines/specs/tavily_free.yaml +56 -0
  28. package/engines/specs/yuanbao.yaml +93 -0
  29. package/mcp/clients.yaml +32 -0
  30. package/package.json +6 -2
  31. package/packages/dsh-plugin/README.md +23 -5
  32. package/packages/dsh-plugin/dsh/client.js +304 -15
  33. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  34. package/packages/dsh-plugin/dsh/index.js +112 -1875
  35. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  36. package/packages/dsh-plugin/dsh/runner.js +666 -0
  37. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  38. package/packages/dsh-plugin/package.json +2 -2
  39. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  40. package/packages/dsh-plugin/test/client.test.js +175 -1
  41. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  42. package/packages/dsh-plugin/test/index.test.js +44 -0
  43. package/scripts/adaptive.py +24 -1
  44. package/scripts/article_meta.py +131 -0
  45. package/scripts/budget.py +247 -0
  46. package/scripts/budget_cli.py +93 -0
  47. package/scripts/build_release_assets.sh +65 -0
  48. package/scripts/cache.py +60 -72
  49. package/scripts/ci_drift_gate.py +90 -0
  50. package/scripts/content_boundary.py +46 -0
  51. package/scripts/content_signals.py +26 -5
  52. package/scripts/cross_lingual_trace.py +293 -0
  53. package/scripts/dispatch_result.py +98 -0
  54. package/scripts/engine_dispatch.py +49 -86
  55. package/scripts/engine_env.py +5 -0
  56. package/scripts/engine_families.py +6 -0
  57. package/scripts/engine_policy.py +7 -7
  58. package/scripts/engines.py +21 -0
  59. package/scripts/engines_base.py +3 -3
  60. package/scripts/engines_builders.py +12 -0
  61. package/scripts/engines_builders_batch10.py +48 -3
  62. package/scripts/engines_builders_batch14.py +656 -0
  63. package/scripts/engines_builders_batch15.py +249 -0
  64. package/scripts/engines_builders_keyless.py +111 -0
  65. package/scripts/engines_builders_tech.py +0 -1
  66. package/scripts/evidence.py +31 -3
  67. package/scripts/evidence_loop.py +54 -22
  68. package/scripts/fetch_v3.py +25 -0
  69. package/scripts/gen_routing_snapshot.py +11 -0
  70. package/scripts/install.ps1 +18 -0
  71. package/scripts/install.sh +16 -1
  72. package/scripts/job.py +3 -3
  73. package/scripts/local_seek.py +41 -5
  74. package/scripts/macro_countries.py +15 -0
  75. package/scripts/matrix_search_eval.py +82 -0
  76. package/scripts/mcp_compact.py +234 -0
  77. package/scripts/mcp_handlers.py +60 -642
  78. package/scripts/mcp_launch.sh +10 -8
  79. package/scripts/mcp_local.py +241 -0
  80. package/scripts/mcp_server.py +19 -14
  81. package/scripts/mcp_setup.py +33 -9
  82. package/scripts/mcp_tools.py +1 -0
  83. package/scripts/mcp_transport.py +1 -1
  84. package/scripts/mcp_warm.py +127 -0
  85. package/scripts/output_transparency.py +200 -0
  86. package/scripts/paid_policy.py +98 -0
  87. package/scripts/quota.py +158 -13
  88. package/scripts/recovery_runner.py +150 -0
  89. package/scripts/replay_eval.py +18 -0
  90. package/scripts/research.py +37 -21
  91. package/scripts/research_expand.py +24 -1
  92. package/scripts/retry_policy.py +76 -0
  93. package/scripts/route.py +6 -6
  94. package/scripts/route_cache.py +18 -0
  95. package/scripts/route_combo.py +7 -0
  96. package/scripts/route_domains.py +32 -19
  97. package/scripts/search.py +15 -27
  98. package/scripts/search_attribution.py +100 -3
  99. package/scripts/search_cli.py +24 -11
  100. package/scripts/search_entry.py +7 -1
  101. package/scripts/search_output.py +99 -15
  102. package/scripts/search_pipeline.py +42 -193
  103. package/scripts/search_types.py +107 -9
  104. package/scripts/semantic_evidence.py +179 -3
  105. package/scripts/serp_guard.py +115 -7
  106. package/scripts/serp_kill_log.py +25 -2
  107. package/scripts/stats_cli.py +13 -1
  108. package/scripts/wide_research.py +45 -0
  109. package/scripts/zhihu_quota.py +139 -0
  110. package/sub-skills/local-search/bm25.py +27 -3
  111. package/sub-skills/local-search/cli_runner.py +331 -0
  112. package/sub-skills/local-search/parsers.py +328 -0
  113. package/sub-skills/local-search/search_v3.py +31 -674
  114. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  115. package/assets/readme/settings-keys.png +0 -0
  116. package/assets/readme/settings-tools.png +0 -0
  117. package/scripts/image_vision/vision_probe +0 -0
  118. package/scripts/local_engines.py +0 -321
  119. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  120. package/sub-skills/ego-search/references/install.md +0 -67
  121. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  122. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  123. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  124. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  125. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  126. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  127. package/sub-skills/local-seek/references/git-integration.md +0 -54
  128. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  129. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  130. package/sub-skills/local-seek/references/strategies.md +0 -52
  131. package/sub-skills/local-seek/references/structural-search.md +0 -59
  132. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  133. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  134. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  135. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  136. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  137. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  138. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  139. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  140. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env python3
2
- """mcp_handlers.py — MCP 工具执行逻辑(P2-4 拆分自 mcp_server.py)。
3
-
4
- 路径引导、延迟导入缓存、结果压缩、14 个工具的 execute_tool 分派、
5
- 后台预热(含 local-seek 模块导入预热,为进程内调用铺路)。
6
- schema 来源在 mcp_tools.py;JSON-RPC 帧处理在 mcp_transport.py。
2
+ """mcp_handlers.py — MCP 工具执行逻辑与集中分发。
3
+
4
+ 路径引导、延迟导入缓存、参数守卫与 19 个工具的 execute_tool 分派。
5
+ 架构解耦:
6
+ - mcp_compact.py 结果裁剪与紧凑压缩协议
7
+ - mcp_local.py 本地文件检索、受限预览与可复算沙箱
8
+ - mcp_warm.py 核心后台预热、连接池预建与社交抓取并发编排
9
+ - mcp_handlers_surface.py 表面五工具分发
10
+ - mcp_tools.py 工具 schema 唯一真源
11
+ - mcp_transport.py JSON-RPC 帧协议与 stdio 传输
7
12
  """
8
13
 
9
14
  from __future__ import annotations
@@ -19,19 +24,37 @@ import threading
19
24
  import cli_io
20
25
  from typing import Any
21
26
 
27
+ from mcp_compact import (
28
+ _cap_extract_output,
29
+ _compact_research_result,
30
+ _compact_search_result,
31
+ _trim_snippet,
32
+ )
33
+ from mcp_local import (
34
+ _LOCAL_READ_ALLOWED_EXTS,
35
+ _local_read_preview,
36
+ _local_read_whitelist,
37
+ _seek_py,
38
+ handle_local_read,
39
+ handle_local_search,
40
+ handle_recompute,
41
+ )
42
+ from mcp_warm import (
43
+ _prewarm_anysearch_connection,
44
+ _resolve_research_profile as _warm_resolve_research_profile,
45
+ _search_social_platforms as _warm_search_social_platforms,
46
+ _warm_core_async as _warm_core_async_impl,
47
+ )
48
+
22
49
  SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
23
50
  ARGO_DIR = os.path.dirname(SCRIPT_DIR) # argo 根目录
24
51
  sys.path.insert(0, SCRIPT_DIR)
25
- # 子技能目录,供 sub-skills/local-search/ 等模块本地导入。
26
- # append 而非 insert(0):sub-skills 下的顶层模块(health_check 等)不得
27
- # 劫持 scripts/ 下同名模块的解析(否则 route 的健康检查分支会静默漂移)。
28
52
  SUB_SKILLS_DIR = os.path.join(ARGO_DIR, "sub-skills")
29
53
  if os.path.isdir(SUB_SKILLS_DIR):
30
54
  for sub in os.listdir(SUB_SKILLS_DIR):
31
55
  sub_path = os.path.join(SUB_SKILLS_DIR, sub)
32
56
  if os.path.isdir(sub_path) and sub_path not in sys.path:
33
57
  sys.path.append(sub_path)
34
- # 切换 CWD 到 argo 根目录,确保相对路径和子进程 work
35
58
  os.chdir(ARGO_DIR)
36
59
 
37
60
  # 启动日志(写入 stderr,不影响 stdio 通信)
@@ -39,90 +62,9 @@ sys.stderr.write("[argo-mcp] starting (lazy imports enabled)\n")
39
62
  sys.stderr.flush()
40
63
 
41
64
  _cache_instance = None
42
- _warm_started = False
43
65
  _module_cache: dict[str, Any] = {}
44
66
 
45
67
 
46
-
47
-
48
- _LOCAL_READ_ALLOWED_EXTS = {
49
- ".md", ".txt", ".csv", ".tsv", ".json", ".yaml", ".yml", ".log",
50
- ".py", ".js", ".ts", ".html", ".xml", ".rst", ".ipynb",
51
- }
52
-
53
-
54
- def _local_read_whitelist() -> list[str]:
55
- """白名单目录:ARGO_LOCAL_READ_DIRS(逗号分隔);没配置就返回空,不放行。"""
56
- raw = (os.environ.get("ARGO_LOCAL_READ_DIRS") or "").strip()
57
- if not raw:
58
- return []
59
- return [os.path.realpath(os.path.expanduser(d))
60
- for d in raw.split(",") if d.strip()]
61
-
62
-
63
- def _local_read_preview(
64
- path: str,
65
- max_chars: int = 4000,
66
- line_start: int | None = None,
67
- line_end: int | None = None,
68
- ) -> tuple[str, dict[str, Any]]:
69
- """白名单内文本文件预览(行窗口或字符截断);越权/二进制拒绝。"""
70
- root = os.path.realpath(os.path.expanduser(path))
71
- allowed = _local_read_whitelist()
72
- if not allowed:
73
- raise PermissionError(
74
- "本地读取未配置白名单:设 ARGO_LOCAL_READ_DIRS=目录1,目录2(没配置就不放行)"
75
- )
76
- if not any(root == d or root.startswith(d + os.sep) for d in allowed):
77
- raise PermissionError(f"路径不在白名单目录内: {path}")
78
- if not os.path.isfile(root):
79
- raise FileNotFoundError(f"文件不存在: {path}")
80
- ext = os.path.splitext(root)[1].lower()
81
- if ext not in _LOCAL_READ_ALLOWED_EXTS:
82
- raise PermissionError(f"仅支持文本类文件预览({ext} 不支持;pdf 走其他路径)")
83
- size = os.path.getsize(root)
84
- if size > 4 * 1024 * 1024:
85
- raise PermissionError("文件超过 4MB,拒绝预览(请缩小范围或改用搜索)")
86
-
87
- with open(root, encoding="utf-8", errors="replace") as f:
88
- full = f.read()
89
- # total_lines 语义是「整个文件的总行数」,必须从全文计——历史上曾从
90
- # 切片/截断后的 content 数换行,行窗口分支会把「共 50 行」报成「共 3 行」。
91
- # 注意尾巴:文件以换行结尾时不能再 +1,否则 50 行会被报成 51 行。
92
- total_lines = full.count("\n") + (1 if full and not full.endswith("\n") else 0)
93
- if line_start is not None or line_end is not None:
94
- start = max(1, int(line_start or 1))
95
- end = int(line_end) if line_end else start + 200
96
- lines = full.splitlines(keepends=True)[(start - 1): end]
97
- content = "".join(lines)
98
- lines_slice = (start, start + len(lines) - 1)
99
- else:
100
- content = full
101
- lines_slice = None
102
-
103
- truncated = len(content) > max_chars
104
- if truncated:
105
- content = content[:max_chars]
106
- return content, {
107
- "path": root,
108
- "chars": len(content),
109
- "lines": (lines_slice[1] - lines_slice[0] + 1) if lines_slice else None,
110
- "total_lines": total_lines,
111
- "is_preview": truncated or lines_slice is not None,
112
- }
113
-
114
-
115
- def _seek_py() -> str:
116
- """定位 local-seek 的 seek.py(安装感知、唯一来源,见 seek_locator)。
117
-
118
- local-seek 已于 2026-08-05 收编为 argo 子技能,标准位置是打包的
119
- SUB_SKILLS_DIR/local-seek;自定义/遗留位置由 ARGO_LOCAL_SEEK_PATH 或
120
- ARGO_LOCAL_SEEK_ROOTS 承载(不再硬编码 ~/.agents/skills 等主机路径)。
121
- 委托 seek_locator,避免与 search.py 各自维护一套发现逻辑而漂移。"""
122
- from seek_locator import resolve_seek_py # 延迟导入:mcp_handlers 常被早拉入
123
- return resolve_seek_py()
124
-
125
-
126
68
  def _lazy_import(module_name: str):
127
69
  """延迟导入模块,首次调用时加载(进程内缓存)。"""
128
70
  return importlib.import_module(module_name)
@@ -146,12 +88,7 @@ def _get_cache():
146
88
 
147
89
 
148
90
  def _dumps(obj: Any, pretty: bool = False) -> str:
149
- """MCP 的 JSON 序列化:复用 CLI stdout 的同一套计算方式(cli_io 是唯一来源)。
150
-
151
- 两边此前各写一份实现(这里 separators=(",", ":")、CLI 侧各写 indent=2),
152
- 同一份载荷两套格式——计算方式分叉后没人会发现「MCP 紧凑、CLI 美化」这件事本身
153
- 就是不一致。合并后只留一个来源;`pretty=True` 仍供人读场景使用。
154
- """
91
+ """MCP 的 JSON 序列化:复用 CLI stdout 的同一套计算方式(cli_io 是唯一来源)。"""
155
92
  return cli_io.dumps_pretty(obj) if pretty else cli_io.dumps(obj)
156
93
 
157
94
 
@@ -165,339 +102,21 @@ def _ok(payload: Any, pretty: bool = False) -> dict[str, Any]:
165
102
  return {"content": [{"type": "text", "text": _dumps(payload, pretty=pretty)}]}
166
103
 
167
104
 
168
- def _trim_snippet(text: Any, n: int = 120) -> str | None:
169
- if not text:
170
- return None
171
- s = str(text)
172
- return s if len(s) <= n else s[:n]
173
-
174
-
175
- def _compact_search_result(result: dict[str, Any], summary: bool = False) -> dict[str, Any]:
176
- """去掉 MCP 热路径用不到的重字段,缩小首包。"""
177
- snip_n = 80 if summary else 160
178
- out: dict[str, Any] = {
179
- "query": result.get("query"),
180
- "engine": result.get("engine"),
181
- "engines_used": result.get("engines_used") or result.get("engines"),
182
- "count": result.get("count"),
183
- "elapsed_ms": result.get("elapsed_ms"),
184
- "cached": result.get("cached"),
185
- "mode": result.get("mode"),
186
- "depth": result.get("depth"),
187
- "execution_tier": result.get("execution_tier"),
188
- "input_kind": result.get("input_kind"),
189
- "login_hint": result.get("login_hint"),
190
- }
191
- results = []
192
- for r in result.get("results") or []:
193
- if not isinstance(r, dict):
194
- continue
195
- item = {
196
- "title": r.get("title"),
197
- "url": r.get("url"),
198
- "snippet": _trim_snippet(r.get("snippet"), snip_n),
199
- "source": r.get("source"),
200
- "score": r.get("score"),
201
- }
202
- # 保留发布时间维度(仅引擎输出该字段时,避免空字段噪音)
203
- if r.get("published_at"):
204
- item["published_at"] = r["published_at"]
205
- # 顶包标记:recovery 替换了引擎原结果时透传,供上层识别结果已被代答
206
- if r.get("_recovered"):
207
- item["recovered"] = r["_recovered"]
208
- # 保留快评,丢掉大块 meta
209
- for k in ("selection", "absorption", "credibility_fast", "evidence_flags"):
210
- if k in r:
211
- item[k] = r[k]
212
- # 证据完整链路 P0:保留核验信号(仅存在时,避免空字段噪音)
213
- for k in ("fetch_suggested", "has_fetched_evidence", "post_fetch_absorption"):
214
- if r.get(k) is not None:
215
- item[k] = r[k]
216
- results.append(item)
217
- out["results"] = results
218
- # 证据完整链路 P0:顶层门控信号(轻量 4 字段,供 Agent 判断「能否下结论」)
219
- if result.get("fetch_required") is not None:
220
- out["fetch_required"] = result["fetch_required"]
221
- el = result.get("evidence_loop")
222
- if isinstance(el, dict):
223
- out["evidence_loop"] = {
224
- "suggested": el.get("suggested") or [],
225
- "verified_count": el.get("verified_count") or 0,
226
- "pending_count": el.get("pending_count") or 0,
227
- }
228
- # 引擎可观测性:精简版 engine_outcomes(engine/status/latency,去掉 detail)
229
- # 让 Agent 能看到「哪个引擎失败、为什么类别」,不占大体积
230
- outcomes = result.get("engine_outcomes") or []
231
- if outcomes:
232
- out["engine_outcomes"] = [
233
- {
234
- "engine": o.get("engine"),
235
- "status": o.get("status"),
236
- "latency_ms": o.get("latency_ms"),
237
- "results_count": o.get("results_count"),
238
- }
239
- for o in outcomes
240
- if isinstance(o, dict)
241
- ][:12]
242
- wasted = result.get("wasted_engine_ms")
243
- if wasted:
244
- out["wasted_engine_ms"] = wasted
245
- if result.get("early_stopped"):
246
- out["early_stopped"] = True
247
- if result.get("minhash_removed"):
248
- out["minhash_removed"] = result.get("minhash_removed")
249
- if result.get("domain_filter"):
250
- out["domain_filter"] = result["domain_filter"]
251
- if result.get("rank_method"):
252
- out["rank_method"] = result.get("rank_method")
253
- if result.get("reranker"):
254
- out["reranker"] = result.get("reranker")
255
- # sources 沉底同构(若已有)
256
- sources = result.get("sources")
257
- if sources:
258
- out["sources"] = [
259
- {
260
- "ref": s.get("ref"),
261
- "title": s.get("title"),
262
- "url": s.get("url"),
263
- "engine": s.get("engine") or s.get("source"),
264
- }
265
- for s in sources
266
- if isinstance(s, dict) and s.get("url")
267
- ][:20]
268
- if result.get("errors"):
269
- out["errors"] = result["errors"]
270
- if result.get("limitations"):
271
- out["limitations"] = result["limitations"]
272
- # 语言偏好观测(精简:去掉 habit_counts 明细,保留决策用字段)
273
- lp = result.get("lang_pref")
274
- if isinstance(lp, dict):
275
- out["lang_pref"] = {
276
- "query_lang": lp.get("query_lang"),
277
- "system_lang": lp.get("system_lang"),
278
- "habit_lang": lp.get("habit_lang"),
279
- "prefer_langs": lp.get("prefer_langs"),
280
- "engine_lang": lp.get("engine_lang"),
281
- }
282
- rec = result.get("recovery")
283
- if isinstance(rec, dict) and rec.get("triggered"):
284
- out["recovery"] = {
285
- "recovered": rec.get("recovered"),
286
- "level_used": rec.get("level_used"),
287
- "strategy_used": rec.get("strategy_used"),
288
- "note": rec.get("note"),
289
- }
290
- return out
291
-
292
-
293
- def _compact_research_result(report: dict[str, Any], summary: bool = False) -> dict[str, Any]:
294
- """研究包:只保留 Agent 写作需要的字段,砍掉子查询原始 dumps。"""
295
- snip_n = 100 if summary else 160
296
- keys = (
297
- "query", "query_original", "execution_tier", "topic_profile", "topic_profile_key",
298
- "discipline", "quality_gates", "report_sections", "source_grades", "disclaimer",
299
- "academic_discipline", "engines_used", "source_distribution", "elapsed_ms",
300
- "sub_query_count", "total_sources", "gaps", "mode", "sub_queries",
301
- "engines_priority", "vertical_engines", "fetch_required",
302
- "kind", "conclusion_cap", "quality_gate_results", "work_packages",
303
- "work_package_stages", "protocol",
304
- )
305
- out: dict[str, Any] = {k: report[k] for k in keys if k in report and report[k] is not None}
306
-
307
- # 证据完整链路 P0:研究包门控(pending_fetch 截断到 5 条,保留可编程核验信号)
308
- el = report.get("evidence_loop")
309
- if isinstance(el, dict):
310
- out["evidence_loop"] = {
311
- "high_consequence_domain": el.get("high_consequence_domain"),
312
- "pending_fetch": (el.get("pending_fetch") or [])[:5],
313
- "verified_count": el.get("verified_count") or 0,
314
- "pending_count": el.get("pending_count") or 0,
315
- }
316
- if isinstance(report.get("verify"), dict):
317
- v = report["verify"]
318
- out["verify"] = {
319
- "revision_summary": v.get("revision_summary") or {},
320
- "pending": (v.get("pending") or [])[:5],
321
- }
322
-
323
- findings = []
324
- for kf in report.get("key_findings") or []:
325
- if not isinstance(kf, dict):
326
- continue
327
- top = dict(kf.get("top_result") or {})
328
- if top.get("snippet"):
329
- top["snippet"] = _trim_snippet(top.get("snippet"), snip_n)
330
- findings.append({
331
- "aspect": kf.get("aspect"),
332
- "strategy": kf.get("strategy"),
333
- "top_result": top,
334
- "result_count": kf.get("result_count"),
335
- "citation_refs": kf.get("citation_refs"),
336
- })
337
- out["key_findings"] = findings
338
-
339
- sources = []
340
- for s in report.get("sources") or report.get("citations") or []:
341
- if not isinstance(s, dict) or not s.get("url"):
342
- continue
343
- sources.append({
344
- "ref": s.get("ref") or s.get("id"),
345
- "title": s.get("title"),
346
- "url": s.get("url"),
347
- "engine": s.get("engine") or s.get("source"),
348
- "snippet": _trim_snippet(s.get("snippet"), snip_n),
349
- })
350
- if summary and len(sources) >= 10:
351
- break
352
- out["sources"] = sources
353
- if not summary:
354
- out["citations"] = report.get("citations") or sources
355
- # 交叉引用截断
356
- crs = report.get("cross_references") or []
357
- out["cross_references"] = crs[:5]
358
- # 覆盖度 / 验证记录 / 盲区(科研方法论增强)
359
- if report.get("coverage_map"):
360
- out["coverage_map"] = report["coverage_map"]
361
- if report.get("verification_records"):
362
- out["verification_records"] = report["verification_records"][:12]
363
- if report.get("blind_spots"):
364
- out["blind_spots"] = report["blind_spots"][:8]
365
- return out
366
-
367
-
368
- def _prewarm_anysearch_connection() -> None:
369
- """在 warm 线程里预建 anysearch 的隧道连接(2026-10-18)。
370
-
371
- 为什么放在 MCP 而不是 CLI 启动:实测 cli 路径上没有任何窗口——引擎在
372
- 117–140ms 就借连接,而经代理的 TLS 握手要 320–970ms,预连接总是慢一步
373
- (对拍见 docs-local;强行预连接反而多付一次握手)。MCP 是长驻进程,
374
- initialize 之后的 warm 与用户第一次 tools/call 之间通常有整秒级间隔,
375
- 这批固定成本正好藏在里面。
376
-
377
- 只做 anysearch:它是 general 域主力**POST** 引擎,也是当前唯一每次都
378
- 走 _do_post 的域;失败静默(离线/代理不可用/池关闭都不影响服务)。
379
- """
380
- if os.environ.get("ARGO_NO_PREWARM", "").strip() not in ("", "0"):
381
- return
382
- try:
383
- import urllib.parse
384
- from conn_pool import (give_back, key_for, stats as pool_stats)
385
- from net_proxy import open_connection, resolve_proxy
386
- if not pool_stats().get("enabled"):
387
- return
388
- url = "https://api.anysearch.com/mcp"
389
- parsed = urllib.parse.urlparse(url)
390
- proxy_url = resolve_proxy(url)
391
- conn, _via = open_connection(parsed, 8.0, proxy_url)
392
- # open_connection 返回的是**未连接**的 HTTPSConnection(懒连接),
393
- # 必须显式 connect;否则还进池的只是个空壳,_sock_alive 会直接丢弃。
394
- conn.connect()
395
- give_back(key_for(parsed, proxy_url), conn)
396
- except Exception as e:
397
- sys.stderr.write(f"[argo-mcp] prewarm-skip: {type(e).__name__}: {e}\n")
398
-
399
-
400
105
  def _warm_core_async() -> None:
401
106
  """initialize 后后台预热 search+cache,摊平首次 tools/call 延迟。"""
402
- global _warm_started
403
- if _warm_started:
404
- return
405
- _warm_started = True
406
-
407
- def _run() -> None:
408
- t0 = __import__("time").time()
409
- try:
410
- _lazy_cached("search")
411
- _get_cache()
412
- # 路由域正则全量预热(2026-09-29):match_domains 懒编译后,长驻
413
- # 进程的首次 search 仍要付「扫到域」的编译税;这里一次付清,首包
414
- # tools/call 不再带路由冷启动。唯一来源在 route_domains——曾在此
415
- # 内联复刻同一句,使 warm_compiled_domains 成了没人调的死函数。
416
- try:
417
- from route_domains import warm_compiled_domains
418
- warm_compiled_domains()
419
- except Exception:
420
- pass
421
- # 连接预热排在模块预热之后:先把 search/route 的导入税付掉,
422
- # 再付 TLS。两段都在同一个 daemon 线程里串行,互不抢占首包。
423
- _prewarm_anysearch_connection()
424
- ms = int((__import__("time").time() - t0) * 1000)
425
- sys.stderr.write(f"[argo-mcp] warm-core ok {ms}ms\n")
426
- except Exception as e:
427
- sys.stderr.write(f"[argo-mcp] warm-core fail: {type(e).__name__}: {e}\n")
428
- sys.stderr.flush()
429
-
430
- threading.Thread(target=_run, name="argo-mcp-warm", daemon=True).start()
107
+ _warm_core_async_impl(_lazy_cached, _get_cache)
431
108
 
432
109
 
433
110
  def _resolve_research_profile(arguments: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
434
111
  """解析 topic profile;全部在 argo 内,不加载外部 skill。"""
435
- try:
436
- profiles = _lazy_cached("topic_research_profiles")
437
- except Exception:
438
- return None, None
439
- topic = arguments.get("topic")
440
- key = None
441
- if topic:
442
- prof = profiles.get_profile(str(topic))
443
- if prof:
444
- # 归一化 key
445
- raw = str(topic).strip()
446
- key = profiles.ALIASES.get(raw) or profiles.ALIASES.get(raw.lower()) or raw.lower()
447
- return prof, key
448
- return None, None
449
- if arguments.get("auto_topic", True):
450
- key = profiles.detect_topic_from_query(arguments.get("query") or "")
451
- if key:
452
- return profiles.get_profile(key), key
453
- return None, None
112
+ return _warm_resolve_research_profile(arguments, _lazy_cached)
454
113
 
455
114
 
456
115
  def _search_social_platforms(
457
116
  platforms: list[str], query: str, n: int, timeout: float = 20.0,
458
117
  ) -> tuple[dict[str, list], list[str], list[str]]:
459
- """并行抓取多社交平台(MCP 热路径)。
460
-
461
- timeout 秒后放弃未完成平台、返回已完成部分(不抛错),
462
- 避免单平台卡死拖垮整次搜索;超时平台以空结果 + errors 提示可观测。
463
- """
464
- platform_results: dict[str, list] = {}
465
- errors: list[str] = []
466
- engines_used: list[str] = []
467
-
468
- def _one(platform: str) -> tuple[str, list, str | None]:
469
- module_name = platform.replace("-", "_") + "_engine"
470
- try:
471
- # 走 _lazy_cached,复用进程内模块缓存,避免重复 importlib 解析
472
- mod = _lazy_cached(f"social_engines.{module_name}")
473
- return platform, mod.search(query, n=n), None
474
- except ImportError:
475
- return platform, [], f"Platform {platform} not available (module social_engines.{module_name})"
476
- except Exception as e:
477
- return platform, [], f"{platform}: {str(e)[:100]}"
478
-
479
- workers = min(max(len(platforms), 1), 4)
480
- import concurrent.futures # 惰性导入:py3.14 的它模块级导入 dataclasses→inspect(~5.5ms),social-sentiment 分支外每次 MCP 调用都白付
481
- ex = concurrent.futures.ThreadPoolExecutor(max_workers=workers)
482
- futs = {ex.submit(_one, p): p for p in platforms}
483
- try:
484
- for fut in concurrent.futures.as_completed(futs, timeout=timeout):
485
- platform, results, err = fut.result()
486
- platform_results[platform] = results
487
- if err:
488
- errors.append(err)
489
- elif results is not None:
490
- engines_used.append(platform)
491
- except concurrent.futures.TimeoutError:
492
- for fut, platform in futs.items():
493
- if not fut.done():
494
- fut.cancel()
495
- platform_results[platform] = []
496
- errors.append(f"{platform}: timeout (>{timeout}s)")
497
- finally:
498
- # 不等待未完成平台,让卡住的线程自生自灭(各引擎内部有 10-15s 保底超时)
499
- ex.shutdown(wait=False, cancel_futures=True)
500
- return platform_results, engines_used, errors
118
+ """并行抓取多社交平台(MCP 热路径)。"""
119
+ return _warm_search_social_platforms(platforms, query, n, timeout=timeout, lazy_cached_fn=_lazy_cached)
501
120
 
502
121
 
503
122
  def _env_bool(name: str, default: bool = False) -> bool:
@@ -528,21 +147,11 @@ def _clamp_int(value: Any, default: int, lo: int, hi: int) -> int:
528
147
 
529
148
 
530
149
  class _InvalidParam(Exception):
531
- """工具参数类型/取值非法:外层转 -32602(区别于引擎内部故障的 -32000)。
532
-
533
- 刻意不继承 ValueError:工具分支里的 `except ValueError`(接 URL/解析
534
- 错误的)不能把参数错误半路截胡——实测 argo_article 就这样把它包成
535
- 了无错误码的裸 error。参数错误要活着到达 execute_tool 的专属 handler。
536
- """
150
+ """工具参数类型/取值非法:外层转 -32602(区别于引擎内部故障的 -32000)。"""
537
151
 
538
152
 
539
153
  def _int_param(arguments: dict[str, Any], key: str, default: int) -> int:
540
- """数值参数严格校验:非整数显式 -32602,不静默回默认。
541
-
542
- 此前裸 `int(arguments.get("timeout", N))` 遇 "10s" 直接 ValueError,
543
- 被兜底 except 包成 -32000 内部错误——调用方看不出是参数给错了。
544
- 与 _required 缺参 -32602 同一契约:参数级错误走参数级错误码。
545
- """
154
+ """数值参数严格校验:非整数显式 -32602,不静默回默认。"""
546
155
  value = arguments.get(key, default)
547
156
  try:
548
157
  return int(value)
@@ -550,44 +159,6 @@ def _int_param(arguments: dict[str, Any], key: str, default: int) -> int:
550
159
  raise _InvalidParam(f"{key} 必须是整数,收到 {value!r}")
551
160
 
552
161
 
553
- def _cap_extract_output(output: dict[str, Any]) -> dict[str, Any]:
554
- """argo_fetch mode=extract 的输出上限(防大页表格撑爆上下文)。
555
-
556
- 结构级裁剪保持 JSON 可解析:表格数量/单元格、metadata 值、jsonld
557
- 条目体积分别设上限,超限截断并打标记。
558
- """
559
- tables = output.get("tables")
560
- if isinstance(tables, list):
561
- if len(tables) > 12:
562
- output["tables"] = tables[:12]
563
- output["tables_truncated"] = True
564
- for t in output["tables"]:
565
- if isinstance(t, list):
566
- for i, row in enumerate(t):
567
- if isinstance(row, list):
568
- t[i] = [str(c)[:300] if c is not None else "" for c in row]
569
- meta = output.get("metadata")
570
- if isinstance(meta, dict):
571
- output["metadata"] = {k: str(v)[:500] for k, v in meta.items()}
572
- jsonld = output.get("jsonld")
573
- if isinstance(jsonld, list):
574
- capped = []
575
- for entry in jsonld:
576
- try:
577
- text = json.dumps(entry, ensure_ascii=False)
578
- except (TypeError, ValueError):
579
- continue
580
- if len(text) > 4000:
581
- capped.append({"_clipped": True, "preview": text[:4000]})
582
- else:
583
- capped.append(entry)
584
- if len(capped) > 8:
585
- capped = capped[:8]
586
- output["jsonld_truncated"] = True
587
- output["jsonld"] = capped
588
- return output
589
-
590
-
591
162
  def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
592
163
  """执行 MCP 工具,按需导入模块。
593
164
 
@@ -598,7 +169,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
598
169
  """
599
170
  pretty = _env_bool("ARGO_MCP_PRETTY", bool(arguments.get("pretty", False)))
600
171
  try:
601
- # 必填参数验证:缺参数时返回清晰错误,而非 KeyError → -32000
602
172
  _required = {
603
173
  "argo_search": ("query",),
604
174
  "argo_research": ("query",),
@@ -623,23 +193,20 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
623
193
  }})}],
624
194
  "isError": True,
625
195
  }
196
+
626
197
  if name == "argo_search":
627
198
  search_mod = _lazy_cached("search")
628
- # MCP 默认 envelope=False:减 candidates/coverage 构造开销;
629
- # 紧凑序列化仍由 _compact_search_result 负责
630
199
  result = search_mod.super_search(
631
200
  query=arguments["query"],
632
201
  engine=arguments.get("engine", "auto"),
633
202
  n=arguments.get("max_results", 5),
634
- skip_cache=_env_bool("ARGO_MCP_SKIP_CACHE", _env_bool("ARGO_NO_CACHE", False) or bool(arguments.get("skip_cache", False))), # ARGO_NO_CACHE(CLI 等价 --no-cache)也覆盖 MCP:任一为真即跳过,显式传参优先
203
+ skip_cache=_env_bool("ARGO_MCP_SKIP_CACHE", _env_bool("ARGO_NO_CACHE", False) or bool(arguments.get("skip_cache", False))),
635
204
  timeout=_env_int("ARGO_MCP_TIMEOUT", _int_param(arguments, "timeout", 10)),
636
205
  depth=arguments.get("depth", "fast"),
637
206
  mode=arguments.get("mode", "auto"),
638
207
  since=arguments.get("since"),
639
208
  until=arguments.get("until"),
640
209
  sort=arguments.get("sort", "relevance"),
641
- # 缺省 True(2026-09-29 扶正):本机文件命中默认并入对话搜索;
642
- # 显式 false 可关。安全垫:宽根守卫 + 3s 子进程超时 + 并行提交。
643
210
  include_local=bool(arguments.get("include_local", True)),
644
211
  include_domains=arguments.get("include_domains") or None,
645
212
  exclude_domains=arguments.get("exclude_domains") or None,
@@ -647,7 +214,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
647
214
  envelope=False,
648
215
  context="search",
649
216
  )
650
- # 默认精简;pretty 且 summary=false 时返回接近全量
651
217
  summary = arguments.get("summary", True)
652
218
  if summary or not pretty:
653
219
  payload = _compact_search_result(result, summary=bool(summary))
@@ -656,142 +222,22 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
656
222
  return _ok(payload, pretty=pretty)
657
223
 
658
224
  elif name == "argo_local_search":
659
- # 本地文件搜索:封装 local-seek 的 seek.py(搜本机文件/记录,非联网)
660
- query = str(arguments.get("query", "")).strip()
661
-
662
- def _local_err(msg: str) -> dict:
663
- return _ok({"query": query, "engine": "local_files", "count": 0, "results": [],
664
- "errors": [msg]}, pretty=pretty)
665
-
666
- if not query:
667
- return _local_err("query 不能为空")
668
- path = str(arguments.get("path", "~"))
669
- max_results = _clamp_int(arguments.get("max_results", 5), 5, 1, 20)
670
- exact = bool(arguments.get("exact", False))
671
- # 宽泛根守卫(与 CLI --include-local 同判据):缺省 ~ 会对整个 home
672
- # 跑 rg,实测 30 s 后报错(CLI 22.6 s 事故同形态)。判据唯一来源是
673
- # local_seek._is_broad_local_root——它 2026-09-28 从 search.py 拆出后
674
- # 这里还在调旧家,AttributeError 被 fail-open 吞掉,守卫整条失效
675
- # (实测 home 路径一路跑到 15s 子进程超时)。
676
- try:
677
- if _lazy_cached("local_seek")._is_broad_local_root(os.path.expanduser(path)):
678
- return _local_err("路径过宽(home / 根目录),扫全盘既慢又失准,已拒绝;请传具体目录")
679
- except Exception as e:
680
- # 守卫不可用 fail-open:按原行为继续,但必须留痕——静默跳过守卫
681
- # 与「查询确实无命中」在外部看起来一样,排查时无从下手。
682
- sys.stderr.write(
683
- f"[argo-mcp] local_search 宽泛根守卫不可用({type(e).__name__}),fail-open\n")
684
- # H2(2026-09-29):进程内直调 seek.run_query——常驻 MCP server 免
685
- # 每次调用的解释器启动(实测省 30-110ms/次);time_budget 沿用
686
- # 显式调用上界 15s(下传给内部 rg/fd 子进程)。任何失败回退下方
687
- # 子进程路径:能力不回退,只是慢。
688
- payload = None
689
- try:
690
- from local_seek import seek_query_payload
691
- payload = seek_query_payload(query, os.path.expanduser(path),
692
- max_results, exact=exact, time_budget=15)
693
- except Exception:
694
- payload = None
695
- if payload is None:
696
- seek_py = _seek_py()
697
- cmd = [sys.executable, seek_py, query, "--path", path, "--json",
698
- "--max", str(max_results)]
699
- if exact:
700
- cmd.append("--exact")
701
- try:
702
- # 子进程是我们的 seek_py:显式 UTF-8 双向(Windows 默认 GBK,中文
703
- # query 的 JSON 输出会 mojibake/解码崩);超时 15 s 是显式调用上界
704
- proc = subprocess.run(
705
- cmd, capture_output=True, text=True,
706
- encoding="utf-8", errors="replace", timeout=15,
707
- env={**os.environ, "PYTHONUTF8": "1"})
708
- except Exception as e:
709
- return _local_err(f"本地搜索执行失败: {e}")
710
- if proc.returncode != 0:
711
- msg = (proc.stdout or proc.stderr or "").strip() or "本地搜索无结果"
712
- return _local_err(msg)
713
- try:
714
- payload = json.loads(proc.stdout)
715
- except Exception:
716
- return _local_err("本地搜索输出解析失败")
717
- mode = payload.get("mode", "fast")
718
- score = 0.9 if mode == "fast" else 0.7 # 精确命中 0.9,扩展召回 0.7
719
- results = []
720
- for r in payload.get("results") or []:
721
- abspath = os.path.abspath(r.get("path", ""))
722
- line = r.get("line") or 0
723
- results.append({
724
- "title": abspath,
725
- "url": f"file://{abspath}" + (f"#L{line}" if line else ""),
726
- "snippet": _trim_snippet(r.get("snippet"), 120),
727
- "source": "local_files", "score": score,
728
- })
729
- return _ok({
730
- "query": query, "engine": "local_files", "engines_used": ["local_files"],
731
- "count": len(results), "elapsed_ms": payload.get("elapsed_ms"),
732
- "cached": False, "mode": mode, "depth": None,
733
- "results": results, "errors": [],
734
- }, pretty=pretty)
225
+ return handle_local_search(
226
+ arguments, pretty=pretty, ok_fn=_ok,
227
+ clamp_int_fn=_clamp_int, lazy_cached_fn=_lazy_cached,
228
+ )
735
229
 
736
230
  elif name == "argo_local_read":
737
- # 白名单本地文本预览(非联网):分析本地数据/研究成果用;没配置就不放行
738
- raw_path = str(arguments.get("path", "")).strip()
739
- max_chars = max(200, min(_int_param(arguments, "max_chars", 4000), 20000))
740
- line_start = arguments.get("line_start")
741
- line_end = arguments.get("line_end")
742
- if not raw_path:
743
- return _ok({"path": "", "content": "", "errors": ["path 不能为空"]}, pretty=pretty)
744
- try:
745
- content, meta = _local_read_preview(
746
- raw_path, max_chars=max_chars,
747
- line_start=line_start, line_end=line_end,
748
- )
749
- except Exception as e:
750
- return _ok({"path": raw_path, "content": "",
751
- "errors": [str(e)]}, pretty=pretty)
752
- return _ok({
753
- "path": meta["path"],
754
- "content": content,
755
- "chars": meta["chars"],
756
- "lines": meta["lines"],
757
- "total_lines": meta["total_lines"],
758
- "is_preview": meta["is_preview"],
759
- "errors": [],
760
- }, pretty=pretty)
231
+ return handle_local_read(
232
+ arguments, pretty=pretty, ok_fn=_ok,
233
+ int_param_fn=_int_param,
234
+ )
761
235
 
762
236
  elif name == "argo_recompute":
763
- # 可复算执行器(默认拒绝):受限子进程运行计算脚本,验证数值。
764
- # 默认拒绝(allow_exec=false / ARGO_ALLOW_RECOMPUTE 未设),返回 skipped_reason。
765
- script = str(arguments.get("script", "")).strip()
766
- inputs_raw = arguments.get("file_inputs")
767
- inputs = []
768
- if isinstance(inputs_raw, str) and inputs_raw.strip():
769
- try:
770
- inputs = json.loads(inputs_raw)
771
- except ValueError:
772
- inputs = []
773
- elif isinstance(inputs_raw, list):
774
- inputs = inputs_raw
775
- elif inputs_raw:
776
- try:
777
- inputs = json.loads(inputs_raw)
778
- except (ValueError, TypeError):
779
- inputs = []
780
- timeout_s = _clamp_int(arguments.get("timeout_s", 30), 30, 1, 120)
781
- max_mem_mb = _clamp_int(arguments.get("max_mem_mb", 512), 512, 64, 4096)
782
- allow_exec = bool(arguments.get("allow_exec", False))
783
- if not script:
784
- return _ok({"ok": False, "skipped_reason": "script 为空"}, pretty=pretty)
785
- try:
786
- from recompute import run_recompute
787
- result = run_recompute(
788
- script, inputs or None,
789
- timeout_s=timeout_s, max_mem_mb=max_mem_mb,
790
- allow_exec=allow_exec,
791
- )
792
- except Exception as e:
793
- result = {"ok": False, "skipped_reason": f"执行器异常: {e}"}
794
- return _ok(result, pretty=pretty)
237
+ return handle_recompute(
238
+ arguments, pretty=pretty, ok_fn=_ok,
239
+ clamp_int_fn=_clamp_int,
240
+ )
795
241
 
796
242
  elif name == "argo_research":
797
243
  research_mod = _lazy_cached("research")
@@ -810,12 +256,9 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
810
256
  max_results = arguments.get("max_results")
811
257
  depth = arguments.get("depth")
812
258
  if profile:
813
- if num_sub is None:
814
- num_sub = profile.get("sub_queries", 4)
815
- if max_results is None:
816
- max_results = profile.get("max_results", 5)
817
- if depth is None:
818
- depth = profile.get("depth", "balanced")
259
+ num_sub = num_sub if num_sub is not None else profile.get("sub_queries", 4)
260
+ max_results = max_results if max_results is not None else profile.get("max_results", 5)
261
+ depth = depth if depth is not None else profile.get("depth", "balanced")
819
262
  result = research_mod.deep_research(
820
263
  query=arguments["query"],
821
264
  num_sub_queries=int(num_sub or 4),
@@ -825,6 +268,7 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
825
268
  profile=profile,
826
269
  work_packages=arguments.get("work_packages"),
827
270
  allow_recompute=bool(arguments.get("allow_recompute", False)),
271
+ cross_lingual_trace=bool(arguments.get("cross_lingual_trace", False)),
828
272
  )
829
273
  if profile and profile_key:
830
274
  result["topic_profile"] = profile.get("name")
@@ -842,11 +286,9 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
842
286
  platforms_str = arguments.get("platforms", "hackernews,zhihu,bilibili")
843
287
  platforms = [p.strip() for p in platforms_str.split(",") if p.strip()]
844
288
  query = arguments["query"]
845
- # 边界夹取与 schema(1..20)同计算方式:客户端越界值不放大平台请求
846
289
  n = _clamp_int(arguments.get("max_results", 5), 5, 1, 20)
847
290
  platform_results, engines_used, errors = _search_social_platforms(platforms, query, n)
848
291
  if arguments.get("mode", "text") == "sentiment":
849
- # 舆情聚合逻辑下沉在 research.aggregate_social_sentiment
850
292
  research_mod = _lazy_cached("research")
851
293
  output = research_mod.aggregate_social_sentiment(
852
294
  query=query, platforms=platforms, platform_results=platform_results,
@@ -895,7 +337,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
895
337
  elif name == "argo_crawl":
896
338
  crawl_mod = _lazy_cached("crawl")
897
339
  strategy = arguments.get("strategy", "bfs")
898
- # 夹取与 schema 同计算方式(max_pages 1..50,max_depth 1..5)
899
340
  max_pages = _clamp_int(arguments.get("max_pages", 10), 10, 1, 50)
900
341
  max_depth = _clamp_int(arguments.get("max_depth", 2), 2, 1, 5)
901
342
  timeout = _env_int("ARGO_MCP_TIMEOUT_CRAWL", _int_param(arguments, "timeout", 8))
@@ -907,12 +348,9 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
907
348
 
908
349
  elif name == "argo_fetch":
909
350
  if arguments.get("mode", "text") == "extract":
910
- # 结构化提取(原 argo_extract):raw fetch + tables/meta/jsonld
911
351
  extract_mod = _lazy_cached("extract")
912
352
  fetch_mod = _lazy_cached("fetch_v3")
913
353
  emode = arguments.get("extract_mode", "all")
914
- # 与 schema 同计算方式(默认 8000,500..50000);禁止硬编码 50000
915
- # 把调用方 bound 顶掉——那是「上限字段被内部魔法数覆盖」一类 bug。
916
354
  max_chars = _clamp_int(_int_param(arguments, "max_chars", 8000), 8000, 500, 50000)
917
355
  fetch_result = fetch_mod.fetch_page_v3(
918
356
  arguments["url"], max_chars=max_chars,
@@ -936,8 +374,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
936
374
 
937
375
  focus_query = arguments.get("focus")
938
376
  fetch_v3_mod = _lazy_cached("fetch_v3")
939
- # 聚焦时按放大额度抓取,否则 focus 只能在文档头部选段;
940
- # 额度契约与 CLI 同源(focus_extract.focus_fetch_chars)。
941
377
  result = fetch_v3_mod.fetch_v3(
942
378
  url=arguments["url"],
943
379
  max_chars=fetch_v3_mod._focus_fetch_chars(
@@ -947,36 +383,30 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
947
383
  force_browser=arguments.get("use_browser", False),
948
384
  )
949
385
  if focus_query and result.get("success"):
950
- # 裁剪契约唯一来源在 focus_extract.apply_focus(CLI 的
951
- # `argo fetch --focus` 走同一函数),此处不再自写一套:
952
- # 两处各写一份的代价是语义静默分叉(CLI 侧曾整个漏掉该参数)。
953
386
  focus_mod = _lazy_cached("focus_extract")
954
387
  focus_mod.apply_focus(result, focus_query,
955
388
  max_chars=_int_param(arguments, "max_chars", 8000))
956
389
  if not result.get("focus_applied"):
957
- # 正文过短、BM25 无从裁剪:如实标注,不谎报已省 token
958
390
  result["focus_note"] = "内容未达聚焦阈值,返回全文"
959
- # 默认截断 content 以控 token
960
391
  if arguments.get("summary", True) and isinstance(result.get("content"), str):
961
392
  max_c = _int_param(arguments, "max_chars", 8000)
962
393
  if len(result["content"]) > max_c:
963
394
  result["content"] = result["content"][:max_c]
964
395
  result["truncated"] = True
965
- # 双保险:fetch_v3 公共返回已剥 html;这里再剥一次,防其他入口漏
966
396
  if isinstance(result, dict):
967
397
  result.pop("html", None)
398
+ if not arguments.get("include_links", False):
399
+ result.pop("links", None)
968
400
  return _ok(result, pretty=pretty)
969
401
 
970
402
  elif name == "argo_screenshot":
971
403
  import time as _time
972
- import tempfile # 惰性导入:仅 screenshot 分支需要,否则每次 MCP 调用白付 tempfile→shutil→random ~6ms
404
+ import tempfile
973
405
  output = arguments.get("output_path") or os.path.join(
974
406
  tempfile.gettempdir(), f"argo_screenshot_{int(_time.time())}.png")
975
407
  full_page = arguments.get("full_page", False)
976
408
  try:
977
409
  cdp_mod = _lazy_cached("chrome_cdp")
978
- # with 上下文:无论 navigate/screenshot 成功或抛异常,
979
- # __exit__ 都会 stop() 回收 Chrome 进程与临时目录
980
410
  with cdp_mod.ChromeCDP(auto_start=True) as cdp:
981
411
  cdp.navigate(arguments["url"])
982
412
  path = cdp.screenshot(output, full_page=full_page)
@@ -1010,7 +440,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
1010
440
  _int_param(arguments, "timeout", 30)),
1011
441
  )
1012
442
  except ValueError as e:
1013
- # URL 不合规格式:可行动错误(仅支持 mp.weixin.qq.com)
1014
443
  return {"content": [{"type": "text", "text": _dumps({"error": str(e)})}],
1015
444
  "isError": True}
1016
445
  if out.get("ok"):
@@ -1026,8 +455,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
1026
455
  out = job_mod.search(
1027
456
  arguments["query"],
1028
457
  city=arguments.get("city", ""),
1029
- # 夹取与 schema 同计算方式(num 1..20,fetch_detail 0..10):
1030
- # 六平台并发 × 越界条数会同时放大请求量与返回体积
1031
458
  num=_clamp_int(arguments.get("num", 5), 5, 1, 20),
1032
459
  platforms=arguments.get("platforms", ""),
1033
460
  loose=bool(arguments.get("loose", False)),
@@ -1038,18 +465,12 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
1038
465
  "isError": True}
1039
466
  return _ok(out, pretty=pretty)
1040
467
 
1041
- # 2026-09-26 补齐 CLI 面的五工具(extract/preflight/answer/watch/cite)
1042
- # 独立成模块:本文件涨破 1000 行硬上限,门禁要求拆分(route_* 先例)
1043
468
  surface = _lazy_cached("mcp_handlers_surface")
1044
469
  surface_result = surface.handle_surface_tool(name, arguments, pretty=pretty)
1045
470
  if surface_result is not None:
1046
- # 约定:surface 分支已返回完整 result(_ok 产物或 isError 形态),
1047
- # 不再二次包装
1048
471
  return surface_result
1049
472
 
1050
473
  else:
1051
- # 未知工具走 isError 形态(与其他工具级错误同契约):裸 {"error":...}
1052
- # 会被 transport 包成成功 result,客户端无法区分错误与正常输出
1053
474
  return {
1054
475
  "content": [{"type": "text",
1055
476
  "text": _dumps({"error": {"code": -32601,
@@ -1058,8 +479,6 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
1058
479
  }
1059
480
 
1060
481
  except _InvalidParam as e:
1061
- # 参数级错误走 -32602:与 _required 缺参同契约,调用方能分辨
1062
- # 「参数给错了」和「引擎内部坏了」(后者才是 -32000)
1063
482
  return {
1064
483
  "content": [{"type": "text",
1065
484
  "text": _dumps({"error": {"code": -32602,
@@ -1071,4 +490,3 @@ def execute_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
1071
490
  "content": [{"type": "text", "text": _dumps({"error": {"code": -32000, "message": f"{type(e).__name__}: {e}"}})}],
1072
491
  "isError": True
1073
492
  }
1074
-