From b5ac175ccdb87f7b8f712fa19c6c5fa58aab1ae9 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 15 Jul 2026 10:49:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20mp=5Fsearch=5Fsummary=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=85=A5=E5=8F=82=20formulas[]=20=E5=AE=9A=E7=82=B9?= =?UTF-8?q?=E8=A7=A3=E6=A3=80=E7=B4=A2=E7=83=A7=20token(bump=200.58.28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 诊断 diag_tool_repeat.py 全库扫:pymatgen task fe2d8b73 里 mp_search_summary 占 562/687 次 tool_call、重复率 91%——逐化学式一 formula 一轮走 agent loop, 562 份中间 JSON 全流经主上下文且每轮重发。根因是工具只收单 formula + host-side 持 MP key(禁在 run_python 里循环)被迫一式一调。 这类"已知清单批量扇出"的正解是工具批量化而非 delegate 子循环(delegate 只把 562 轮搬进子上下文、基础 token 不省)。新增 formulas: list(≤300/次)触发 batch: 复用单个 MPRester 会话逐式查(num_chunks=1 fair-use 护栏不变)、每式按 energy_above_hull 升序、单式出错不连坐整批;结果落 materials/mp_search_batch_ .json(内容寻址幂等),只回紧凑摘要+路径,中间数据不进对话。 单查询路径字节不变保向后兼容;working_dir 设可选(裸构造/无 key 测试降级内联)。 agent_builder 注入 working_dir_path;pymatgen SKILL.md 加批量引导(WHY+WHAT)。 tests +1(去重/落盘/稳定相排序/单式错误隔离/无 key 泄漏),原测试补回 chunk_size 断言。 additive 内部工具能力,不碰对外 API/DB 契约。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 1 + core/__init__.py | 2 +- core/agent_builder.py | 6 +- skills/pymatgen/SKILL.md | 2 +- tests/test_secret_host_tools.py | 69 ++++++++++++++++++++++ tools/materials_project.py | 101 ++++++++++++++++++++++++++++++-- 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 09faf40..94f4bac 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-07 +- **07-15 / 0.58.28**:**`mp_search_summary` 加批量入参 `formulas=[...]`(检索型烧 token 头号根因的定点解)**(诊断:`diag_tool_repeat.py` 全库扫,pymatgen task `fe2d8b73` 687 次 tool_call 里 `mp_search_summary` 占 **562 次**、重复率 91%——逐化学式一 formula 一轮走 agent loop 建氧化物表,562 份中间 JSON 全流经主上下文且每轮重发)。根因不是"检索发散"而是**工具只收单 `formula` + host-side 持 MP key(§7.5 #7 禁在 run_python 里循环)→ 被迫一式一调**。**这类"已知清单批量扇出"的正解是工具批量化,不是 delegate 子循环**(delegate 只把 562 轮搬进子上下文、基础 token 一分不省;批量化直接把 562 次并成 1 次工具调用+全部中间数据落盘不进对话)。实现:`formulas: list[str]`(≤300/次,超则报错分批)触发 batch 分支,复用**单个 MPRester 会话**逐式查(`num_chunks=1` fair-use 护栏不变)、每式按 `energy_above_hull` 升序(稳定相优先)、**单式出错不连坐整批**(记 `{"formula","error"}` 继续);结果聚合写 `working_dir/materials/mp_search_batch_.json`(内容寻址、同批幂等)、**只回紧凑 per-formula 摘要 + 路径**(N 个有结果/M 个空错 + 空错清单前 20),引导模型用 `read`/`run_python` 直读文件、勿逐条 dump。单查询路径(`formula`/`material_ids`/`elements`)**字节不变**保向后兼容;`working_dir` 设可选(裸构造 `MaterialsProjectSearchSummaryTool()` 不破,测试/smoke 无 working_dir 时降级内联返回)。`agent_builder` 注入 `working_dir_path`(同 `mp_get_structure`);pymatgen SKILL.md 加"多式用 `formulas` 批量"引导(WHY+WHAT,不写死步骤);`tests/test_secret_host_tools.py` +1(去重/落盘/稳定相排序/单式错误隔离/无 key 泄漏),原单查询测试补回 `chunk_size` 断言。additive 内部工具能力,不碰对外 API/DB 契约。**背景**:这是"检索型烧 token"三头(mp_search 562 批量扇出 / document_search 122 探索检索 / research code 试错抖动)里第一头的定点解;探索检索型留待 §8.11 delegate,代码试错归 RepeatGuard,不混为一谈。 - **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx` 回 `state.loadedMessages` 取 `payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。 - **07-15 / 0.58.26**:**用户停止后「已停止」持久提示(补齐 cancel 终态,与 error 卡对称)**(用户报:点停止后对话底部什么都不剩)。根因同 `2a1bc25d` 案:cancel 时前端 live-append 的 `已停止` badge 会被紧随的收尾 `loadMessages` 整屏重建(`renderMessages` 先 `innerHTML=""`)冲掉——闪一下即消失;而 error 之所以稳,是因为它落 `run_status="error"` 持久态 + `renderPersistedRunError` 每次重渲重派生,cancel 却在收尾直接回 `idle` 不留痕。**方案 B(架构对称):把 cancel 提升为一等持久终态**——`run_status` 是 `Text` 列(非 enum,**无 migration**),`agent.run` 在任一取消路径都 `return "[cancelled]"`(`loop.py`),`_run_agent_bg` 据此收尾写 `run_status="cancelled"`(否则 `idle`)。前端 `renderPersistedRunError`→`renderPersistedRunTerminal` 扩成同时认 error/cancelled:cancelled 渲居中 muted 虚线条「已停止 · 你中断了上次运行」(良性态,不用错误卡的醒目样式),刷新/切任务仍在,下次起新 run(`post_message` 写 running)覆盖清掉。删掉会被冲掉的 live badge(及其死 CSS `.cancelled-badge`)。**未选方案 A(客户端临时 flag)**:它会把刚被 `2a1bc25d` 修掉的「终态挂 live-only DOM」反模式请回来(刷新即死、跨标签页不通、对 broker 的 DB 态机制不可见),且与 10 行外的 error 路径不对称。兼容性:`run_status` 加新值是纯增量(不改既有值语义),蓝绿旧实例读到 `cancelled` 落到 `in ("running","cancelling")` 判断的 else 当良性态、旧前端只认 `error`→优雅退化到今天行为、不崩;全部 run_status 消费点(起 run 409 门/reaper/SSE is_active/cancel 端点/clear_messages)逐一核对 cancelled 均按 idle/error 类良性态处理。filter allowlist + DESIGN §7.4 尾句 + models.py 注释同步。 - **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。 diff --git a/core/__init__.py b/core/__init__.py index d6def31..149f3ea 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.58.27" +__version__ = "0.58.28" diff --git a/core/agent_builder.py b/core/agent_builder.py index 06bd35d..28a9c80 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -600,7 +600,11 @@ def build_agent( if os.getenv("MP_API_KEY", "").strip(): for t in ( - MaterialsProjectSearchSummaryTool(base_dir=tool_base, user_root=ur_path), + MaterialsProjectSearchSummaryTool( + working_dir=working_dir_path, + base_dir=tool_base, + user_root=ur_path, + ), MaterialsProjectGetStructureTool( working_dir=working_dir_path, base_dir=tool_base, diff --git a/skills/pymatgen/SKILL.md b/skills/pymatgen/SKILL.md index 499c475..4772a17 100644 --- a/skills/pymatgen/SKILL.md +++ b/skills/pymatgen/SKILL.md @@ -53,7 +53,7 @@ from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry API key 只在宿主后端读取,不要在 `run_python` 里读 env。可用 tool: -- `mp_search_summary(formula?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON +- `mp_search_summary(formula?, formulas?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON。**要查多个化学式时传 `formulas=[...]` 一次批量查完**(结果落 `materials/mp_search_batch_*.json`,只回摘要),不要一个式子调一次 —— 逐条调既慢又把中间数据全灌进对话 - `mp_get_structure(material_id, filename?)` —— 把结构保存到 task_dir `materials/*.cif`,再用 `Structure.from_file()` 离线计算 - `mp_get_entries(elements, filename?, limit=200)` —— 把 chemsys entries 保存到 task_dir `materials/*.json` diff --git a/tests/test_secret_host_tools.py b/tests/test_secret_host_tools.py index c908a22..c77dac4 100644 --- a/tests/test_secret_host_tools.py +++ b/tests/test_secret_host_tools.py @@ -164,6 +164,75 @@ class TestMaterialsProjectHostTools(unittest.TestCase): self.assertEqual(captured["num_chunks"], 1) self.assertEqual(captured["chunk_size"], 2) + def test_mp_search_summary_batch_writes_file_and_isolates_failure(self): + from tools.materials_project import MaterialsProjectSearchSummaryTool + + class FakeDoc: + def __init__(self, mid, formula, hull): + self.material_id = mid + self.formula_pretty = formula + self.energy_above_hull = hull + + seen: list[str] = [] + + class FakeSummary: + def search(self, **kwargs): + f = kwargs["formula"] + seen.append(f) + if f == "BadX": + raise RuntimeError("boom") + if f == "Empty1": + return [] + # unsorted on purpose: stable phase (0.0) comes second + return [FakeDoc("mp-9", f, 0.2), FakeDoc("mp-1", f, 0.0)] + + class FakeMaterials: + def __init__(self): + self.summary = FakeSummary() + + class FakeMPRester: + def __init__(self, api_key): + self.materials = FakeMaterials() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + with tempfile.TemporaryDirectory() as td: + wd = Path(td) / "proj" + wd.mkdir() + with patch.dict("os.environ", {"MP_API_KEY": "host-secret"}, clear=False), patch( + "tools.materials_project.MPRester", FakeMPRester + ): + tool = MaterialsProjectSearchSummaryTool(working_dir=wd) + out = tool.execute( + formulas=["Ca3SiO5", "Ca3SiO5", "Empty1", "BadX"], + fields=["material_id", "formula_pretty", "energy_above_hull"], + limit=5, + ) + + # dedup: repeated Ca3SiO5 queried once + self.assertEqual(seen, ["Ca3SiO5", "Empty1", "BadX"]) + # digest, not the full payload, and no secret leak + self.assertIn("batch: 3 个化学式", out) + self.assertIn("1 个有结果", out) + self.assertNotIn("host-secret", out) + # empty/error formulas surfaced + self.assertIn("Empty1", out) + self.assertIn("BadX", out) + + # full results persisted to materials/*.json + files = list((wd / "materials").glob("mp_search_batch_*.json")) + self.assertEqual(len(files), 1) + agg = json.loads(files[0].read_text(encoding="utf-8")) + by_formula = {a["formula"]: a for a in agg} + # stable phase sorted first + self.assertEqual(by_formula["Ca3SiO5"]["results"][0]["energy_above_hull"], 0.0) + self.assertIn("error", by_formula["BadX"]) + self.assertEqual(by_formula["Empty1"]["n"], 0) + class TestHostFileToolPathResolution(unittest.TestCase): """send_email / wechat_push 在宿主进程读附件:agent 给的相对/容器 `/workspace` 路径 diff --git a/tools/materials_project.py b/tools/materials_project.py index 2b3a105..8c45cd0 100644 --- a/tools/materials_project.py +++ b/tools/materials_project.py @@ -5,6 +5,7 @@ written to task_dir, but it must not receive the Materials Project key. """ from __future__ import annotations +import hashlib import json import os from pathlib import Path @@ -62,22 +63,40 @@ class MaterialsProjectSearchSummaryTool(Tool): name = "mp_search_summary" description = ( "Search Materials Project summary data using the host MP_API_KEY. " - "Returns trimmed JSON; use mp_get_structure to save a CIF for offline pymatgen analysis." + "Single query: pass formula / material_ids / elements → returns trimmed JSON. " + "Batch: pass `formulas` (a list) to look up MANY formulae in ONE tool call — " + "full results are written to task_dir/materials/*.json and only a compact " + "per-formula digest is returned. Always prefer batch over calling this tool " + "once per formula. Use mp_get_structure to save a CIF for offline pymatgen analysis." ) parameters = { "type": "object", "properties": { - "formula": {"type": "string", "description": "Optional formula such as Ca3SiO5."}, + "formula": {"type": "string", "description": "Optional single formula such as Ca3SiO5."}, + "formulas": {"type": "array", "items": {"type": "string"}, "description": "Optional list of formulae for a BATCH lookup, e.g. ['Ca3SiO5','Ca2SiO4',...]. One tool call instead of one per formula; full results saved to a JSON file, only a digest returned. Max 300 per call — split larger lists. When you have >1 formula to look up, use THIS, not repeated formula= calls."}, "material_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional Materials Project ids such as mp-123."}, "elements": {"type": "array", "items": {"type": "string"}, "description": "Optional element symbols for a chemical system search."}, "fields": {"type": "array", "items": {"type": "string"}, "description": "Fields to return; defaults to material_id/formula_pretty/symmetry/energy_above_hull. Do NOT put the search formula here — use formula_pretty (not 'formula'). Common fields: formula_pretty, symmetry, energy_above_hull, band_gap, density, volume, is_stable, structure."}, - "limit": {"type": "integer", "default": 10, "description": "Maximum records returned, 1-50. Server-side bounded — keep small to stay within MP fair-use."}, + "limit": {"type": "integer", "default": 10, "description": "Maximum records returned, 1-50. In batch mode this is PER formula (keep small — 1-3 gives the most-stable phases). Server-side bounded to stay within MP fair-use."}, }, } + _MAX_BATCH = 300 + + def __init__( + self, + *, + working_dir: Optional[Path] = None, + base_dir: Optional[Path] = None, + user_root: Optional[Path] = None, + ) -> None: + super().__init__(base_dir=base_dir, user_root=user_root) + self.working_dir = Path(working_dir) if working_dir is not None else None + def execute( self, formula: str = "", + formulas: Optional[list[str]] = None, material_ids: Optional[list[str]] = None, elements: Optional[list[str]] = None, fields: Optional[list[str]] = None, @@ -85,6 +104,9 @@ class MaterialsProjectSearchSummaryTool(Tool): ) -> str: limit = min(max(int(limit), 1), 50) chosen_fields = fields or _DEFAULT_SUMMARY_FIELDS + batch = [str(f).strip() for f in (formulas or []) if str(f).strip()] + if batch: + return self._batch(batch, chosen_fields, limit) kwargs: dict[str, Any] = {"fields": chosen_fields} if formula: kwargs["formula"] = formula @@ -93,7 +115,7 @@ class MaterialsProjectSearchSummaryTool(Tool): if elements: kwargs["elements"] = elements if len(kwargs) == 1: - return "[Error] formula / material_ids / elements 至少传一个" + return "[Error] formula / formulas / material_ids / elements 至少传一个" try: with _mpr() as mpr: # num_chunks=1 + chunk_size=limit 让服务端单次只回 limit 条、不翻页。 @@ -107,6 +129,77 @@ class MaterialsProjectSearchSummaryTool(Tool): plain = [_to_plain(d) for d in list(docs)[:limit]] return json.dumps(plain, ensure_ascii=False, indent=2) + @staticmethod + def _hull(rec: Any) -> float: + v = rec.get("energy_above_hull") if isinstance(rec, dict) else None + return v if isinstance(v, (int, float)) else float("inf") + + def _batch(self, formulas: list[str], fields: list[str], limit: int) -> str: + if len(formulas) > self._MAX_BATCH: + return ( + f"[Error] 一次最多 {self._MAX_BATCH} 个化学式(收到 {len(formulas)});请分批调用。" + ) + seen: set[str] = set() + uniq = [f for f in formulas if not (f in seen or seen.add(f))] + try: + session = _mpr() + except Exception as e: + return f"[Error] mp_search_summary batch failed: {type(e).__name__}: {e}" + agg: list[dict[str, Any]] = [] + n_ok = 0 + # 单个 formula 出错不连坐整批;复用一个 MPRester 会话避免逐条重认证。 + with session as mpr: + for f in uniq: + try: + docs = mpr.materials.summary.search( + formula=f, fields=fields, num_chunks=1, chunk_size=limit + ) + results = [_to_plain(d) for d in list(docs)[:limit]] + results.sort(key=self._hull) # 稳定相优先 + if results: + n_ok += 1 + agg.append({"formula": f, "n": len(results), "results": results}) + except Exception as e: + agg.append({"formula": f, "error": f"{type(e).__name__}: {e}"}) + return self._render_batch(agg, fields, n_ok) + + def _render_batch(self, agg: list[dict[str, Any]], fields: list[str], n_ok: int) -> str: + empties = [a["formula"] for a in agg if "error" in a or a.get("n", 0) == 0] + header = ( + f"mp_search_summary batch: {len(agg)} 个化学式 → {n_ok} 个有结果," + f"{len(empties)} 个空/错误。\n" + f"每条记录字段: {', '.join(fields)}\n" + ) + if self.working_dir is not None: + h = hashlib.sha1( + ",".join(a["formula"] for a in agg).encode("utf-8") + ).hexdigest()[:8] + dest = self.working_dir / "materials" / f"mp_search_batch_{h}.json" + try: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + json.dumps(agg, ensure_ascii=False, indent=2), encoding="utf-8" + ) + body = ( + f"完整结果已存: {self._display(dest)}\n" + " 结构 [{\"formula\",\"n\",\"results\":[...]}...];" + "用 read 或 run_python/pandas 直接读该文件处理,勿逐条 dump 进对话。\n" + ) + except Exception as e: + body = ( + f"[warn] 写文件失败({type(e).__name__}: {e}),改为内联返回完整 JSON:\n" + + json.dumps(agg, ensure_ascii=False, indent=2) + + "\n" + ) + else: + body = json.dumps(agg, ensure_ascii=False, indent=2) + "\n" + tail = "" + if empties: + shown = ", ".join(empties[:20]) + more = f" …(+{len(empties) - 20})" if len(empties) > 20 else "" + tail = f"空/错误化学式: {shown}{more}\n" + return header + body + tail + class MaterialsProjectGetStructureTool(Tool): name = "mp_get_structure"