skill.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. """
  2. Skill 工具 - 按需加载 Skill 文件
  3. Agent 可以调用此工具来加载特定的 skill 文档
  4. """
  5. import importlib.util
  6. import os
  7. import shutil
  8. import subprocess
  9. from pathlib import Path
  10. from typing import Optional
  11. from agent.tools import tool, ToolResult
  12. from agent.skill.skill_loader import SkillLoader
  13. # 飞书 openclaw-lark 子模块 skills 根目录(整体搬迁时只改此处或环境变量)
  14. _FEISHU_OPENCLAW_SKILLS_ROOT = os.path.join(
  15. os.getenv("FEISHU_OPENCLAW_ROOT", "./gateway/core/channels/feishu/openclaw-lark"),
  16. "skills",
  17. )
  18. _FEISHU_OPENCLAW_SKILL_NAMES = (
  19. "feishu-bitable",
  20. "feishu-calendar",
  21. "feishu-channel-rules",
  22. "feishu-create-doc",
  23. "feishu-fetch-doc",
  24. "feishu-im-read",
  25. "feishu-task",
  26. "feishu-troubleshoot",
  27. "feishu-update-doc",
  28. )
  29. # 默认 skills 目录(优先级:项目 skills > 框架 skills > 飞书 openclaw skills)
  30. DEFAULT_SKILLS_DIRS = [
  31. os.getenv("SKILLS_DIR", "./skills"),
  32. "./agent/skill/skills",
  33. *(os.path.join(_FEISHU_OPENCLAW_SKILLS_ROOT, name) for name in _FEISHU_OPENCLAW_SKILL_NAMES),
  34. ]
  35. # 默认单一目录(用于 list_skills)
  36. DEFAULT_SKILLS_DIR = DEFAULT_SKILLS_DIRS[0]
  37. def _browser_use_python_package_installed() -> bool:
  38. return importlib.util.find_spec("browser_use") is not None
  39. def _browser_use_runtime_likely_ready() -> bool:
  40. """启发式:常见系统浏览器、Playwright 缓存或 browser-use 配置目录是否存在。"""
  41. try:
  42. for name in ("chromium", "chromium-browser", "google-chrome", "chrome"):
  43. if shutil.which(name):
  44. return True
  45. pw = Path.home() / ".cache" / "ms-playwright"
  46. if pw.is_dir() and any(pw.glob("chromium-*")):
  47. return True
  48. if (Path.home() / ".browser_use").is_dir():
  49. return True
  50. except OSError:
  51. pass
  52. return False
  53. def _check_skill_setup(skill_name: str) -> Optional[str]:
  54. """
  55. 检查 skill 的环境配置,返回缺失依赖的警告信息
  56. Args:
  57. skill_name: Skill 名称
  58. Returns:
  59. 警告信息(如果有缺失的依赖),否则返回 None
  60. """
  61. # browser-use:仓库内从未存在 agent.skill.skills.browser_use.setup,旧逻辑会恒 ImportError 并被吞掉
  62. if skill_name in ["browser-use", "browser_use"]:
  63. pkg_ok = _browser_use_python_package_installed()
  64. runtime_ok = _browser_use_runtime_likely_ready()
  65. if not pkg_ok or not runtime_ok:
  66. warning = "\n⚠️ **Setup Required**\n\n"
  67. warning += "The following dependencies may be missing:\n\n"
  68. if not pkg_ok:
  69. warning += "- Python 包:`pip install browser-use`\n"
  70. if not runtime_ok:
  71. warning += "- 浏览器运行时:安装 Chromium(例如 `uvx browser-use install` 或 `playwright install chromium`)\n"
  72. warning += "\n若已安装仍提示缺失,可忽略本段(检测为启发式)。\n\n"
  73. return warning
  74. return None
  75. @tool(
  76. description="加载指定的 skill 文档。Skills 提供领域知识和最佳实践指导。"
  77. )
  78. async def skill(
  79. skill_name: str,
  80. skills_dir: Optional[str] = None,
  81. ) -> ToolResult:
  82. """
  83. 加载指定的 skill 文档
  84. Args:
  85. skill_name: Skill 名称(如 "browser-use", "error-handling")
  86. skills_dir: Skills 目录路径(可选,默认按优先级查找)
  87. Returns:
  88. ToolResult: 包含 skill 的详细内容
  89. 加载顺序:
  90. 1. 如果指定 skills_dir,只在该目录查找
  91. 2. 否则按优先级查找:./skills/ (项目) -> ./config/skills/ (框架)
  92. """
  93. # 确定要搜索的目录列表
  94. if skills_dir:
  95. search_paths = [Path(skills_dir)]
  96. else:
  97. search_paths = [Path(d) for d in DEFAULT_SKILLS_DIRS]
  98. # 在目录中查找 skill 文件
  99. skill_file = None
  100. found_in_dir = None
  101. for skills_path in search_paths:
  102. if not skills_path.exists():
  103. continue
  104. # 查找文件(支持 skill-name.md 或 skill_name.md)
  105. for ext in [".md"]:
  106. for name_format in [skill_name, skill_name.replace("-", "_"), skill_name.replace("_", "-")]:
  107. candidate = skills_path / f"{name_format}{ext}"
  108. if candidate.exists():
  109. skill_file = candidate
  110. found_in_dir = skills_path
  111. break
  112. if skill_file:
  113. break
  114. if skill_file:
  115. break
  116. if not skill_file:
  117. # 列出所有可用的 skills
  118. available_skills = []
  119. for skills_path in search_paths:
  120. if skills_path.exists():
  121. available_skills.extend([f.stem for f in skills_path.glob("**/*.md")])
  122. return ToolResult(
  123. title=f"Skill '{skill_name}' 未找到",
  124. output=f"可用的 skills: {', '.join(set(available_skills))}\n\n"
  125. f"查找路径: {', '.join([str(p) for p in search_paths])}",
  126. error=f"Skill not found: {skill_name}"
  127. )
  128. # 加载 skill
  129. try:
  130. loader = SkillLoader(str(skills_path))
  131. skill_obj = loader.load_file(skill_file)
  132. if not skill_obj:
  133. return ToolResult(
  134. title="加载失败",
  135. output=f"无法解析 skill 文件: {skill_file.name}",
  136. error="Failed to parse skill file"
  137. )
  138. # 格式化输出
  139. output = f"# {skill_obj.name}\n\n"
  140. output += f"**Category**: {skill_obj.category}\n\n"
  141. if skill_obj.description:
  142. output += f"## Description\n\n{skill_obj.description}\n\n"
  143. # 检查 skill 的环境配置
  144. setup_warning = _check_skill_setup(skill_name)
  145. if setup_warning:
  146. output += setup_warning
  147. if skill_obj.guidelines:
  148. output += f"## Guidelines\n\n"
  149. for i, guideline in enumerate(skill_obj.guidelines, 1):
  150. output += f"{i}. {guideline}\n"
  151. output += "\n"
  152. return ToolResult(
  153. title=f"Skill: {skill_obj.name}",
  154. output=output,
  155. long_term_memory=f"已加载 skill: {skill_obj.name} ({skill_obj.category}) from {found_in_dir}",
  156. include_output_only_once=True, # skill 内容只展示一次
  157. metadata={
  158. "skill_name": skill_obj.name,
  159. "category": skill_obj.category,
  160. "scope": skill_obj.scope,
  161. "guidelines_count": len(skill_obj.guidelines),
  162. "loaded_from": str(found_in_dir)
  163. }
  164. )
  165. except Exception as e:
  166. return ToolResult(
  167. title="加载错误",
  168. output=f"加载 skill 时出错: {str(e)}",
  169. error=str(e)
  170. )
  171. @tool(
  172. description="列出所有可用的 skills"
  173. )
  174. async def list_skills(
  175. skills_dir: Optional[str] = None,
  176. ) -> ToolResult:
  177. """
  178. 列出所有可用的 skills
  179. Args:
  180. skills_dir: Skills 目录路径(可选)
  181. Returns:
  182. ToolResult: 包含所有 skills 的列表
  183. """
  184. skills_path = Path(skills_dir or DEFAULT_SKILLS_DIR)
  185. if not skills_path.exists():
  186. return ToolResult(
  187. title="Skills 目录不存在",
  188. output=f"找不到 skills 目录: {skills_path}",
  189. error=f"Directory not found: {skills_path}"
  190. )
  191. try:
  192. loader = SkillLoader(str(skills_path))
  193. skills = loader.load_all()
  194. if not skills:
  195. return ToolResult(
  196. title="没有可用的 Skills",
  197. output="skills 目录中没有找到任何 .md 文件",
  198. metadata={"count": 0}
  199. )
  200. # 按 category 分组
  201. by_category = {}
  202. for skill_obj in skills:
  203. category = skill_obj.category or "general"
  204. if category not in by_category:
  205. by_category[category] = []
  206. by_category[category].append(skill_obj)
  207. # 格式化输出
  208. output = f"# 可用的 Skills ({len(skills)} 个)\n\n"
  209. for category in sorted(by_category.keys()):
  210. output += f"## {category.title()}\n\n"
  211. for skill_obj in by_category[category]:
  212. skill_id = skill_obj.skill_id or skill_obj.name.lower().replace(' ', '-')
  213. output += f"- **{skill_obj.name}** (`{skill_id}`)\n"
  214. if skill_obj.description:
  215. desc = skill_obj.description.split('\n')[0] # 第一行
  216. output += f" {desc[:100]}{'...' if len(desc) > 100 else ''}\n"
  217. output += "\n"
  218. output += "\n使用 `skill` 工具加载具体的 skill:`skill(skill_name=\"browser-use\")`"
  219. return ToolResult(
  220. title=f"可用 Skills ({len(skills)} 个)",
  221. output=output,
  222. long_term_memory=f"找到 {len(skills)} 个可用 skills,分为 {len(by_category)} 个类别",
  223. include_output_only_once=True,
  224. metadata={
  225. "count": len(skills),
  226. "categories": list(by_category.keys()),
  227. "skills": [{"name": s.name, "category": s.category} for s in skills]
  228. }
  229. )
  230. except Exception as e:
  231. return ToolResult(
  232. title="列出 Skills 错误",
  233. output=f"列出 skills 时出错: {str(e)}",
  234. error=str(e)
  235. )