fix(skill_tool): docker backend 下返回容器路径而非 host 绝对路径
实测部署 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) <noreply@anthropic.com>
This commit is contained in:
parent
203e14d15d
commit
4b7d7e6f77
|
|
@ -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 教学拼 `<skill_dir>/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=<container_skills_dir>/<skill_name>`(去重末尾斜杠),无值保持原 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/<name>` / 末尾斜杠拼接不双斜杠 / 未知 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 的 `<today>-<short_id>-<name>.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 友好。
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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/<name>`)去 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue