From 4b7d7e6f77fa35fe7aa6f461890ba4cebb23bd90 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 28 May 2026 13:37:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(skill=5Ftool):=20docker=20backend=20?= =?UTF-8?q?=E4=B8=8B=E8=BF=94=E5=9B=9E=E5=AE=B9=E5=99=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=20host=20=E7=BB=9D=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测部署 dogfood analyze skill 时,LLM load_skill 拿到 host 绝对路径 `/home/lighthouse/zcbot/skills/analyze`,照 SKILL.md 拼 references 路径调 read → 容器内 namespace 没这条路径(容器只有 /sandbox/skills:ro 这 mount),抓瞎。 修法:LoadSkillTool 加 container_skills_dir 构造参数;agent_builder 在装它时 判 ZCBOT_SANDBOX_BACKEND==docker → 传 "/sandbox/skills",其它 → 保持原 host 绝对路径(开发期 host backend 不破)。 结构性收益:proposal/ppt/research/coding/pymatgen/stats_ml/plot_pub 全部 skill references 在 docker backend 下自动 work,不用一个个改 SKILL.md 教用容器路径。 tests/test_load_skill.py 4 case 锁:host 走 host 路径 / docker 走 /sandbox / 末尾斜杠拼接不双斜杠 / 未知 skill 走原路径。docker executor 15/15 回归 PASS。 部署后:git pull + 重启 agent 进程让新代码生效(SkillRegistry 每请求重建但 LoadSkillTool 实例化在 build_agent 里,需要新进程)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- PROGRESS.md | 3 +- core/agent_builder.py | 16 ++++++++- tests/test_load_skill.py | 72 ++++++++++++++++++++++++++++++++++++++++ tools/skill_tool.py | 13 +++++++- 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/test_load_skill.py diff --git a/PROGRESS.md b/PROGRESS.md index 5c29194..099cdb5 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-05-28(新增 analyze skill 引导式问题拆解 + Python 3.10→3.12 升级 + Docker backend PYTHONPATH 修 + 3 个科学计算 skill smoke 通过) +最后更新:2026-05-28(修 docker backend LoadSkillTool 路径改写 + 新增 analyze skill + Python 3.10→3.12 升级 + Docker backend PYTHONPATH 修 + 3 个科学计算 skill smoke 通过) --- @@ -23,6 +23,7 @@ ### 2026-05-28 +- **修 `LoadSkillTool` 在 docker backend 下返回 host 绝对路径导致容器内 fs 工具找不到 references 的 bug**:实测部署机 dogfood `analyze` skill 时,LLM 调 `load_skill('analyze')` 拿到 header `[skill=analyze, dir=/home/lighthouse/zcbot/skills/analyze]`,照 SKILL.md 教学拼 `/references/pico_template.md` 给 `read` →"file not found"。**根因**:`core/executor_docker.py` 设计上 fs/shell/run_python 全走 `docker exec` 进容器(行 56-60 `CONTAINER_TOOLS`),skills/ bind mount 到容器内 `/sandbox/skills:ro`(`core/sandbox/pool.py:227-229`)—— 容器 namespace 里**没有 host 路径**(`/home/lighthouse/zcbot/...` 不存在),只有 `/sandbox/skills/analyze`。`LoadSkillTool` 跑在 host agent 进程里,塞给 LLM 的 `dir=...` 一直是 host 绝对路径,docker backend 下 LLM 用这条路径调容器内 read/glob/grep 必抓瞎。**为什么没早暴露**:proposal/research/ppt 这些 references-heavy skill 历史多在 host backend(开发期)跑通,docker backend 是部署期才打开;且 LLM 经常就着 SKILL.md 本体直接干活不去 read references,踩到的人不多;analyze 拆成 5 references 强制 read,首次集中暴露。**修法(A 候选,user 选)**:`LoadSkillTool` 加 `container_skills_dir: Optional[str]` 构造参数,有值时返回头 `dir=/`(去重末尾斜杠),无值保持原 host 绝对路径。`agent_builder.py:392-405` 在装 LoadSkillTool 时复用 `select_executor` 同款 env 判断(`os.getenv("ZCBOT_SANDBOX_BACKEND")=="docker"`),为 True 时传 `"/sandbox/skills"`(与 pool.py mount target 一致)。`tests/test_load_skill.py` 4 case 锁住:host backend host 路径 / docker backend `/sandbox/skills/` / 末尾斜杠拼接不双斜杠 / 未知 skill 报错走原路径。全套 4/4 PASS + `tests/test_executor_docker.py` 15/15 PASS 回归无破。**结构性收益**:所有现存 skill(proposal/ppt/research/coding/pymatgen/stats_ml/plot_pub/...)references 在 docker backend 下自动 work,不用一个个改 SKILL.md 教 LLM 用容器路径(那会破 host backend 开发环境)。**部署后操作**:部署机需 `git pull` 拉这条 commit + 重启 agent 进程让新代码生效(skill 注册表已经是每请求重建 §c4229be,但 LoadSkillTool 实例化在 build_agent 里,需要新进程或新连接才能拿到带 container_skills_dir 的实例)。否决:(b) bind mount host 路径到容器同样位置 —— 容器路径跟 host 强耦合,部署路径换地方就跪;(c) 改全部 SKILL.md 让 LLM 用 `/sandbox/skills/...` —— 散点改易漏,且 host backend 下 `/sandbox` 不存在,反破 dev 环境。`DESIGN.md` 不动(无架构变化,纯实现修);`RUN.md` 不动(无 CLI / env 变化)。 - **新增 `analyze` skill(科学问题分析 / 拆解 / 引导),服务建材院 R&D 早期问题翻译场景**:用户拿"模糊的高层科研问题"(典型句式"想搞清楚 X 原因 / 怎么提升 Y / 该不该做 Z")过来时,既不是写本子(proposal)/也不是查文献(research)/也不是建模(stats_ml),而是**问题还在概念阶段需要先想清楚**——之前 10 个 skill 没人接这个场景,模型只能凭直觉糊弄。本 skill 定位为"协调器 / 问题翻译器",**不执行任务**,只把模糊命题拆成可操作子问题 + 实施路线图,最终接力给下游 skill。**四段式工作流**:① PICO/PECO 规范化(P 对象 / I 干预 / C 对照 / O 量化输出 + FINER 五维自检)—— 卡 BLOCKING;② Issue Tree 拆解(MECE 原则,默认"机理-现象-工艺"三层,叶子节点标 `[类型 / 优先级 / 能力描述]`)—— 卡 BLOCKING;③ 按叶子类型分支深化:根因型走 Fishbone(六大支:材料/工艺/设备/检测/环境/人员)+ 5Whys、创新型走 First-principles 拆假设 + TRIZ 矛盾矩阵(摘 10 对建材常见冲突),优化型走 DoE 选型导航(PB/全因子/CCD/Box-Behnken/混料/序贯);④ 实施路线图 + TODO + 接力建议(`analysis.md` §6 每步四件事:干什么 / 能力描述 / 产物 / 判停条件)。**文件结构**:`skills/analyze/SKILL.md`(121 行)+ 5 份 references(78-95 行,按需 always read 或分支 read)+ 1 份 `templates/analysis_report.md`(87 行 = 最终 `analysis.md` 骨架),共 7 文件 657 行。**关键决策**:(a) **不硬编码"叶子能力 → skill 名"映射表** —— runtime 的 skill discovery 已经把所有 skill description 注入 prompt(DESIGN §3.5),硬编码等于重复 + 改名要回来改;改用"能力描述"(动词短语)让 LLM 按当时看到的 skill 清单自匹配;(b) **触发 description 双重防护** —— A 写死"还在想方向 / 不知道从哪入手"触发条件 + 显式列出何时不用(proposal/research/stats_ml/review 走对应 skill),B 在 §输出末尾推荐"下一步用 X 能力推进",前者拦"路由进"后者拦"路由出"卡死;(c) **不需要 Python helper** —— 全引导式对话 + markdown 输出,跟 review skill 同范式,无代码;(d) **TRIZ 不抄全 40 原理矩阵** —— 摘 10 对建材常见矛盾(强度↑韧性↑ / 早强↑后期↓ / 致密↑透气↑ 等),够 80% 场景 + 不污染上下文;(e) **DoE 选型表不生成实验点位** —— analyze 只规划设计类型 + 因素表,具体随机化 / 点位生成由下游 stats_ml 跑 pyDOE2,职责清晰;(f) **产物文件简单命名 `analysis.md`** —— 不学 proposal 的 `--.spec.md` 多版本机制(spec 是"宪法"需要定调一次,analysis 是工作文档迭代覆盖即可);(g) **examples 全打建材域**(P42.5 早强偏低 / 熔铸 AZS 砖热震 / 低碳水泥探索 / 矿粉粉煤灰配方 DoE),触发 description 保持领域无关(框架本身通用),只在 references 里塞建材 case 让 LLM 学场景适配。否决:(a) `proposal` 直接覆盖问题分析功能 —— proposal 已包含"先写要点再写正文"两段式,但那是"已定调要立项"之后的拆解,跟"还没决定要不要立项"的探索阶段语义不同;(b) 合并到 `research` —— research 是查文献执行能力,问题拆解不查文献也能做;(c) 写成 Python framework(自动拆解 + 自动 PICO 填空)—— 强行结构化反而压死开放探索,引导式对话更贴 R&D 实际节奏。`DESIGN.md` 不动(新加 skill 无架构变化);`RUN.md` 不动(无 CLI / env / 文件布局变化);`SCIENTIFIC_SKILLS.md` 不动(该文件是 K-Dense 仓库引进评估笔记,analyze 是自主设计不在其列)。 - **Python 3.10→3.12 升级(host + Dockerfile)+ DockerExecutor PYTHONPATH 加 `/sandbox` 修历史 import bug + 3 个科学 skill smoke 通过**:上一条加完 3 个科学 skill 后跑 smoke 发现 step D mp_rester 联网炸 `ImportError: cannot import name 'NotRequired' from 'typing'` —— Materials Project 官方依赖 `emmet-core 0.86.0rc1` 的 `outcar_adapter.py` 直接 `from typing import NotRequired`(3.11+ 才有,没走 `typing_extensions` 兜底),原 host .venv 是 Python 3.10.9 → mp-api 整链路 import 不进。**选 3.12 而非 3.11/3.13**:3.12 是当下 ML/AI 生态默认推荐版本(稳一年半 + 所有主流包预编译 wheel 覆盖完整),3.11 跟容器对齐但少一年优化,3.13 释放才半年冷门 wheel 偶尔退源码编译 Windows 上易踩坑(没新特性需求,激进升只是踩雷概率)。**实施**:① host py -3.12 -m venv 重建 .venv,pip install -r requirements.txt 装齐(pymatgen 2026.5.4 / mp-api 0.46.1 / emmet-core 0.86.4 / sklearn 1.8.0 / statsmodels 0.14.6 / numpy 2.4 / scipy 1.17 / matplotlib 3.10.9 / litellm / fastapi / sqlalchemy / 全套传递依赖);② Dockerfile FROM `python:3.11-slim` → `python:3.12-slim`(host / 容器同步升,部署机 rebuild image 时生效);③ **顺手修 `core/executor_docker.py:172` PYTHONPATH** `/workspace` → `/sandbox:/workspace`:历史 bug —— 多个 skill(`research/paper`、新加 `pymatgen/materials`、`plot_pub/style`)SKILL.md 都教 LLM `from skills.xxx.yyy import zzz`,host backend 因 base_dir=Path.cwd()(zcbot repo 根)注入 PYTHONPATH 能 work;docker backend 下容器只有 `PYTHONPATH=/workspace` + skills/ bind mount 到 `/sandbox/skills:ro`,`import skills.xxx` 找不到。本次加 `/sandbox` 前缀(在 /workspace 前,让 skills 优先级高于用户 task 目录的同名 shadow),`tests/test_executor_docker.py:243-245` regression test 改 `assertIn("PYTHONPATH=/sandbox:/workspace", ...)`,**全套 15/15 PASS**。**smoke 实跑**:step A pymatgen helper + XRDCalculator MgO 11 个峰 ✅ / step B sklearn R²=0.575 + statsmodels R²=0.911 p≪0.05 ✅ / step C plot_pub SimHei + PNG+PDF 出图 ✅ / step D mp_rester 联网 ⚠️ 返 403 "Your IP/ASN blocked"(Materials Project 服务侧 IP 临时封禁,跟代码无关,LBNL 服务对中国大陆 IP 段或同 ASN abusive traffic 触发 → 等几小时自动解 / 邮件 support@materialsproject.org 报公网 IP 申请解封 / VPS 走代理 fallback)。**非阻塞**:pymatgen 本地功能(CIF I/O / XRDCalculator / SpacegroupAnalyzer / PhaseDiagram / VASP 输入)100% 能用,只是 `mp_rester` 在线查询暂不能用。否决:(a) 升 3.11(只跟容器对齐,少一年优化,3.12 同样兼容容器);(b) 升 3.13(释放半年,冷门 wheel 偶尔退源码编译 Windows 踩坑,激进升无收益);(c) pin `emmet-core<0.86` + `mp-api<0.45`(临时,下次 pip install 不 pin 又炸,且丢 emmet 新功能);(d) monkey patch `typing.NotRequired = typing_extensions.NotRequired`(hacky 且挡不住 mp_api 下游其他 3.11+ 假设);(e) executor PYTHONPATH 改 `/workspace:/sandbox`(/workspace 优先 → 用户 task 目录如果手贱建 `skills/` 同名子目录会 shadow 真 skills,/sandbox 在前更稳)。`DESIGN.md` 不动(纯实施层 Python 版本 + 容器 PYTHONPATH 修);`RUN.md` 不动(env 段 MP_API_KEY 已在上一条 skill commit 加入,Python 版本要求记 `requirements.txt` + Dockerfile 自表)。 - **新增 3 个科学计算 skill(pymatgen / stats_ml / plot_pub),服务建材院无机非金属材料 R&D**:`SCIENTIFIC_SKILLS.md` 评估完 K-Dense/scientific-agent-skills 仓库后落地选 4 个 ★★★ 中前 3 个动手(materials_db 后置,USPTO 部分留并入 `skills/patent`)。命名取**工具名直接**(`pymatgen` / `plot_pub`)+ **业务前缀**(`stats_ml` 因合三库需要场景导航),贴合现有 skill 命名风格(coding/ppt/research/...)。① **`skills/pymatgen/`**:`SKILL.md`(无机相中文→化学式映射表说明 / XRD 比对 / 对称性 / 相图 / VASP 输入文件,八条反模式)+ `materials.py`(`CEMENT_PHASES` dict 覆盖水泥熟料 / 水化产物 / 石膏 / 碳酸盐 / 陶瓷耐火 / 玻璃晶相 / 常见矿物共 50+ 条目,中英文 / 简写多 key 指同一化学式;`lookup_phase()` 大小写不敏感查找;`mp_rester()` context manager 自动从 env 拿 `MP_API_KEY`,缺则 RuntimeError 带申请链接;mp_api 局部 import 避免装包前 import 即崩)。② **`skills/stats_ml/`**:`SKILL.md` 纯指南(场景导航表选 sklearn / statsmodels / PyMC、5 个工作流示例 A-E 含配方-性能回归 / DoE 二阶响应面 / 显著性分析 / 贝叶斯小样本 / DBSCAN 异常配方、16 条反模式分库列示)+ 无 helper(三库 API 直接用)。③ **`skills/plot_pub/`**:`SKILL.md`(XRD 多相叠图 / TG-DSC 双 Y 轴 / 强度发展曲线 / 多 panel 论文 figure 4 个工作流 + 中文字体说明 + 10 条反模式)+ `style.py`(`apply_pub_style()` 一键设置:中文字体跨平台 fallback SimHei→YaHei→WenQuanYi→DejaVu / dpi 150 屏幕 300 保存 / viridis 默 cmap / 刻度朝内 / legend 无框 / PDF Type 42 字体合规期刊 / `font.size` `linewidth` 等可参数化;`_find_chinese_font()` 在 `font_manager.fontManager.ttflist` 查实装字体而非靠 try-load)。**关键决策**:(a) **不一键装 138 个 skill** —— 上下文噪声 + 误触发(用户"分析一下"模型可能跳 Scanpy),挑 4 个 ★★★ fork 单装;(b) **PyMC 装包延后** —— 带 pytensor 装 5min+ 体积大,真要做贝叶斯再装;requirements.txt 注释掉以 `# pymc>=5.10.0` 形式留接口;(c) **MP_API_KEY 走 .env** —— 跟 DEEPSEEK_API_KEY / ARK_API_KEY 同范式,litellm 不读但 `os.environ.get` 拿到;(d) **化学式映射表对中文 / 简写 / 英文学名同等待遇** —— 用户报相名习惯混杂(C3S / 硅酸三钙 / alite 都常见),多 key 同 value 比强迫归一化体验好;(e) **不写示例数据/单元测试**:开发期 LLM 工作流场景多变,跑了 SKILL.md 工作流验证而非脚本测试 —— skill 是 prompt 不是代码模块。requirements.txt 加 pymatgen / mp-api / scikit-learn / statsmodels(PyMC 注释)。`RUN.md` env 段加 MP_API_KEY 说明(可选 + 申请链接 + 未设抛 RuntimeError)。`DESIGN.md` 不动(纯 skill 加,无架构变化);`SCIENTIFIC_SKILLS.md`(根目录调研笔记)已沉淀整体评估,后续 materials_db 落地参考。装包未执行 —— 等用户跑 `.venv/Scripts/pip install pymatgen mp-api scikit-learn statsmodels` 装上才能验证 import 路径生效。否决:(a) 三个 skill 合并成一个 `science` skill —— 触发语义糊,LLM 难判,各做各的更清;(b) `materials_pymatgen` 这种业务前缀全打 —— pymatgen 本身就是材料库,前缀冗余;(c) helper 过度封装(写 `simulate_xrd(formula)` 全自动)—— 隐藏 pymatgen 真实 API,LLM 学不到本来好用的上游能力,反模式段在 SKILL.md 里讲清更轻;(d) plot_pub 内 `apply_pub_style()` 失败抛错 —— 中文字体没装也应该继续画(图能看就行,只是中文方块),warn 比 raise 友好。 diff --git a/core/agent_builder.py b/core/agent_builder.py index 6abd915..aab2719 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -390,7 +390,21 @@ def build_agent( tools[wf.name] = wf if skills.skills: - ls = LoadSkillTool(registry=skills, base_dir=tool_base, user_root=ur_path) + import os + # docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到 + # /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM + # 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。 + container_skills_dir = ( + "/sandbox/skills" + if os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() == "docker" + else None + ) + ls = LoadSkillTool( + registry=skills, + base_dir=tool_base, + user_root=ur_path, + container_skills_dir=container_skills_dir, + ) tools[ls.name] = ls if caps.enable_run_python: diff --git a/tests/test_load_skill.py b/tests/test_load_skill.py new file mode 100644 index 0000000..987bc65 --- /dev/null +++ b/tests/test_load_skill.py @@ -0,0 +1,72 @@ +"""LoadSkillTool 路径改写测试。 + +docker backend 下 fs/shell/run_python 在容器里跑,skills/ bind mount 到 +`/sandbox/skills:ro`。LoadSkillTool 返回头里的 `dir` 必须是容器路径而不是 host +绝对路径,否则 LLM 拿 host 路径调 read references 时容器 namespace 不通。 +""" +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from core.skills import SkillRegistry +from tools.skill_tool import LoadSkillTool + + +class TestLoadSkillToolPathRewrite(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.skills_dir = Path(self.tmpdir.name) + skill_dir = self.skills_dir / "demo" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\nname: demo\ndescription: 测试用\n---\n\n# Demo body\n", + encoding="utf-8", + ) + self.registry = SkillRegistry(self.skills_dir) + + def tearDown(self): + self.tmpdir.cleanup() + + def test_host_backend_returns_host_path(self): + """没传 container_skills_dir → header 用 host 绝对路径(原行为)。""" + tool = LoadSkillTool(registry=self.registry) + out = tool.execute(name="demo") + host_path = str((self.skills_dir / "demo")) + self.assertIn(f"dir={host_path}", out) + self.assertIn("# Demo body", out) + + def test_docker_backend_rewrites_to_sandbox_path(self): + """传 container_skills_dir=/sandbox/skills → header 用容器路径,且不漏 host 路径。""" + tool = LoadSkillTool( + registry=self.registry, + container_skills_dir="/sandbox/skills", + ) + out = tool.execute(name="demo") + self.assertIn("dir=/sandbox/skills/demo", out) + # host 临时目录路径不应出现在 header(防止改写不彻底) + host_path = str((self.skills_dir / "demo")) + self.assertNotIn(host_path, out) + # body 不变 + self.assertIn("# Demo body", out) + + def test_docker_backend_strips_trailing_slash(self): + """container_skills_dir 带末尾斜杠 → 拼接路径不应出现双斜杠。""" + tool = LoadSkillTool( + registry=self.registry, + container_skills_dir="/sandbox/skills/", + ) + out = tool.execute(name="demo") + self.assertIn("dir=/sandbox/skills/demo", out) + self.assertNotIn("//demo", out) + + def test_unknown_skill_returns_error(self): + tool = LoadSkillTool(registry=self.registry) + out = tool.execute(name="nonexistent") + self.assertIn("not found", out) + self.assertIn("demo", out) # available list + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/skill_tool.py b/tools/skill_tool.py index 0356f1d..1b558fe 100644 --- a/tools/skill_tool.py +++ b/tools/skill_tool.py @@ -36,9 +36,16 @@ class LoadSkillTool(Tool): registry: SkillRegistry, base_dir: Optional[Path] = None, user_root: Optional[Path] = None, + container_skills_dir: Optional[str] = None, ) -> None: super().__init__(base_dir, user_root=user_root) self.registry = registry + # docker backend 下,fs / shell / run_python 都在容器里跑,host skills/ bind + # mount 到 /sandbox/skills:ro(pool.py)。header 里的 dir 要给容器内可用路径 + # —— 否则 LLM 拿 host 绝对路径(`/home/.../skills/`)去 read references + # 时容器看不见,抓瞎报 file not found。POSIX 串(容器恒为 Linux),与 host OS 无关。 + # None = host backend,保持 skill.skill_dir 原 host 绝对路径。 + self.container_skills_dir = container_skills_dir def execute(self, name: str) -> str: skill = self.registry.get(name) @@ -46,5 +53,9 @@ class LoadSkillTool(Tool): available = ", ".join(self.registry.skills.keys()) or "(none)" return f"[Error] skill '{name}' not found. Available: {available}" body = skill.full_content() - header = f"[skill={skill.name}, dir={skill.skill_dir}]\n" + if self.container_skills_dir is not None: + dir_str = f"{self.container_skills_dir.rstrip('/')}/{skill.name}" + else: + dir_str = str(skill.skill_dir) + header = f"[skill={skill.name}, dir={dir_str}]\n" return header + body