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.
- package/README.en.md +58 -145
- package/README.es.md +58 -145
- package/README.ja.md +64 -151
- package/README.ko.md +63 -150
- package/README.md +65 -154
- package/SKILL.md +3 -3
- package/assets/readme/hero.svg +2 -2
- package/backends/domain_profiles.json +48 -0
- package/backends/engine_registry.yaml +118 -4
- package/backends/quota_profiles.json +92 -12
- package/backends/source_types_cn.json +27 -1
- package/bin/argo +74 -5
- package/bin/argo.js +33 -2
- package/config.yaml +219 -60
- package/docs/ENGINE_CATALOG.md +50 -39
- package/docs/MCP_SETUP.md +3 -1
- package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
- package/engines/specs/cfets.yaml +41 -0
- package/engines/specs/defillama.yaml +38 -0
- package/engines/specs/firecrawl.yaml +1 -1
- package/engines/specs/fxtwitter.yaml +5 -0
- package/engines/specs/gleif.yaml +55 -0
- package/engines/specs/hkex.yaml +40 -0
- package/engines/specs/keenable_free.yaml +36 -0
- package/engines/specs/shfe.yaml +39 -0
- package/engines/specs/stackexchange.yaml +2 -2
- package/engines/specs/tavily_free.yaml +56 -0
- package/engines/specs/yuanbao.yaml +93 -0
- package/mcp/clients.yaml +32 -0
- package/package.json +6 -2
- package/packages/dsh-plugin/README.md +23 -5
- package/packages/dsh-plugin/dsh/client.js +304 -15
- package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
- package/packages/dsh-plugin/dsh/index.js +112 -1875
- package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
- package/packages/dsh-plugin/dsh/runner.js +666 -0
- package/packages/dsh-plugin/dsh/wide-research.js +878 -0
- package/packages/dsh-plugin/package.json +2 -2
- package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
- package/packages/dsh-plugin/test/client.test.js +175 -1
- package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
- package/packages/dsh-plugin/test/index.test.js +44 -0
- package/scripts/adaptive.py +24 -1
- package/scripts/article_meta.py +131 -0
- package/scripts/budget.py +247 -0
- package/scripts/budget_cli.py +93 -0
- package/scripts/build_release_assets.sh +65 -0
- package/scripts/cache.py +60 -72
- package/scripts/ci_drift_gate.py +90 -0
- package/scripts/content_boundary.py +46 -0
- package/scripts/content_signals.py +26 -5
- package/scripts/cross_lingual_trace.py +293 -0
- package/scripts/dispatch_result.py +98 -0
- package/scripts/engine_dispatch.py +49 -86
- package/scripts/engine_env.py +5 -0
- package/scripts/engine_families.py +6 -0
- package/scripts/engine_policy.py +7 -7
- package/scripts/engines.py +21 -0
- package/scripts/engines_base.py +3 -3
- package/scripts/engines_builders.py +12 -0
- package/scripts/engines_builders_batch10.py +48 -3
- package/scripts/engines_builders_batch14.py +656 -0
- package/scripts/engines_builders_batch15.py +249 -0
- package/scripts/engines_builders_keyless.py +111 -0
- package/scripts/engines_builders_tech.py +0 -1
- package/scripts/evidence.py +31 -3
- package/scripts/evidence_loop.py +54 -22
- package/scripts/fetch_v3.py +25 -0
- package/scripts/gen_routing_snapshot.py +11 -0
- package/scripts/install.ps1 +18 -0
- package/scripts/install.sh +16 -1
- package/scripts/job.py +3 -3
- package/scripts/local_seek.py +41 -5
- package/scripts/macro_countries.py +15 -0
- package/scripts/matrix_search_eval.py +82 -0
- package/scripts/mcp_compact.py +234 -0
- package/scripts/mcp_handlers.py +60 -642
- package/scripts/mcp_launch.sh +10 -8
- package/scripts/mcp_local.py +241 -0
- package/scripts/mcp_server.py +19 -14
- package/scripts/mcp_setup.py +33 -9
- package/scripts/mcp_tools.py +1 -0
- package/scripts/mcp_transport.py +1 -1
- package/scripts/mcp_warm.py +127 -0
- package/scripts/output_transparency.py +200 -0
- package/scripts/paid_policy.py +98 -0
- package/scripts/quota.py +158 -13
- package/scripts/recovery_runner.py +150 -0
- package/scripts/replay_eval.py +18 -0
- package/scripts/research.py +37 -21
- package/scripts/research_expand.py +24 -1
- package/scripts/retry_policy.py +76 -0
- package/scripts/route.py +6 -6
- package/scripts/route_cache.py +18 -0
- package/scripts/route_combo.py +7 -0
- package/scripts/route_domains.py +32 -19
- package/scripts/search.py +15 -27
- package/scripts/search_attribution.py +100 -3
- package/scripts/search_cli.py +24 -11
- package/scripts/search_entry.py +7 -1
- package/scripts/search_output.py +99 -15
- package/scripts/search_pipeline.py +42 -193
- package/scripts/search_types.py +107 -9
- package/scripts/semantic_evidence.py +179 -3
- package/scripts/serp_guard.py +115 -7
- package/scripts/serp_kill_log.py +25 -2
- package/scripts/stats_cli.py +13 -1
- package/scripts/wide_research.py +45 -0
- package/scripts/zhihu_quota.py +139 -0
- package/sub-skills/local-search/bm25.py +27 -3
- package/sub-skills/local-search/cli_runner.py +331 -0
- package/sub-skills/local-search/parsers.py +328 -0
- package/sub-skills/local-search/search_v3.py +31 -674
- package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
- package/assets/readme/settings-keys.png +0 -0
- package/assets/readme/settings-tools.png +0 -0
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +0 -321
- package/sub-skills/ego-search/references/browser-runtime.md +0 -43
- package/sub-skills/ego-search/references/install.md +0 -67
- package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
- package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
- package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
- package/sub-skills/js-run/tests/test_jsrun.py +0 -165
- package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
- package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
- package/sub-skills/local-seek/references/git-integration.md +0 -54
- package/sub-skills/local-seek/references/remote-handoff.md +0 -46
- package/sub-skills/local-seek/references/rg-recipes.md +0 -96
- package/sub-skills/local-seek/references/strategies.md +0 -52
- package/sub-skills/local-seek/references/structural-search.md +0 -59
- package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
- package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
- package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
- package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
- package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
- package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
- package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
- package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
- package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
package/scripts/mcp_handlers.py
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""mcp_handlers.py — MCP
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))),
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
815
|
-
if
|
|
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
|
|
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
|
-
|