code.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. #!/usr/bin/env python3
  2. """
  3. s07: 技能加载 — 两级按需知识注入。
  4. 第 1 层(便宜,始终存在):
  5. SYSTEM 提示词包含技能名称 + 单行描述(每个技能约 100 tokens)
  6. "可用技能: agent-builder, code-review, mcp-builder, pdf"
  7. 第 2 层(昂贵,按需加载):
  8. Agent 调用 load_skill("code-review") → 完整 SKILL.md 内容
  9. 通过 tool_result 注入(每个技能约 2000 tokens)
  10. skills/
  11. agent-builder/SKILL.md
  12. code-review/SKILL.md
  13. mcp-builder/SKILL.md
  14. pdf/SKILL.md
  15. 相对 s06 的变化:
  16. + build_system() — 启动时扫描 skills/ 目录,把目录注入 SYSTEM
  17. + load_skill(name) — 通过 tool_result 返回完整 SKILL.md 内容
  18. + SKILLS_DIR 配置
  19. 循环不变:load_skill 通过 TOOL_HANDLERS 自动分发。
  20. 运行: python s07_skill_loading/code.py
  21. 需要: pip install anthropic python-dotenv pyyaml + .env 中配置 ANTHROPIC_API_KEY
  22. """
  23. import ast, json, os, subprocess
  24. from pathlib import Path
  25. import yaml
  26. try:
  27. import readline
  28. readline.parse_and_bind('set bind-tty-special-chars off')
  29. except ImportError:
  30. pass
  31. from anthropic import Anthropic
  32. from dotenv import load_dotenv
  33. load_dotenv(override=True)
  34. if os.getenv("ANTHROPIC_BASE_URL"):
  35. os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
  36. WORKDIR = Path.cwd()
  37. SKILLS_DIR = WORKDIR / "skills"
  38. client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
  39. MODEL = os.environ["MODEL_ID"]
  40. CURRENT_TODOS: list[dict] = []
  41. # s07: 技能目录扫描 (供下方 build_system 使用)
  42. def _parse_frontmatter(text: str) -> tuple[dict, str]:
  43. """解析 SKILL.md 的 YAML frontmatter,返回 (meta, body)。"""
  44. if not text.startswith("---"):
  45. return {}, text
  46. parts = text.split("---", 2)
  47. if len(parts) < 3:
  48. return {}, text
  49. try:
  50. meta = yaml.safe_load(parts[1]) or {}
  51. except yaml.YAMLError:
  52. meta = {}
  53. return meta, parts[2].strip()
  54. # 启动时构建技能注册表 (用于在 load_skill 中安全查找)
  55. SKILL_REGISTRY: dict[str, dict] = {}
  56. def _scan_skills():
  57. """扫描 skills/ 目录,把名称、描述和内容写入 SKILL_REGISTRY。"""
  58. if not SKILLS_DIR.exists():
  59. return
  60. for d in sorted(SKILLS_DIR.iterdir()):
  61. if not d.is_dir():
  62. continue
  63. manifest = d / "SKILL.md"
  64. if manifest.exists():
  65. raw = manifest.read_text()
  66. meta, body = _parse_frontmatter(raw)
  67. name = meta.get("name", d.name)
  68. desc = meta.get("description", raw.split("\n")[0].lstrip("#").strip())
  69. SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": raw}
  70. _scan_skills()
  71. def list_skills() -> str:
  72. """列出所有技能:名称 + 单行描述。"""
  73. if not SKILL_REGISTRY:
  74. return "(未找到技能)"
  75. return "\n".join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values())
  76. # s07: SYSTEM 包含技能目录 (成本低 — 只有名称和描述)
  77. def build_system() -> str:
  78. """构建 SYSTEM 提示词,并注入启动时扫描到的技能目录。"""
  79. catalog = list_skills()
  80. return (
  81. f"你是位于 {WORKDIR}. "
  82. f"可用技能:\n{catalog}\n"
  83. "需要时使用 load_skill 获取完整详情。"
  84. )
  85. SYSTEM = build_system()
  86. # s07: 子 Agent 使用自己的系统提示词 — 不加载技能,也没有 task
  87. SUB_SYSTEM = (
  88. f"你是位于 {WORKDIR}. "
  89. "完成交给你的任务,然后返回简洁摘要。"
  90. "不要继续委派。"
  91. )
  92. # ═══════════════════════════════════════════════════════════
  93. # 来自 s02-s06 (未改动): 工具实现
  94. # ═══════════════════════════════════════════════════════════
  95. def safe_path(p: str) -> Path:
  96. path = (WORKDIR / p).resolve()
  97. if not path.is_relative_to(WORKDIR):
  98. raise ValueError(f"路径逃逸出工作区:{p}")
  99. return path
  100. def run_bash(command: str) -> str:
  101. try:
  102. r = subprocess.run(command, shell=True, cwd=WORKDIR,
  103. capture_output=True, text=True, timeout=120)
  104. out = (r.stdout + r.stderr).strip()
  105. return out[:50000] if out else "(无输出)"
  106. except subprocess.TimeoutExpired:
  107. return "错误:执行超时(120 秒)"
  108. def run_read(path: str, limit: int | None = None) -> str:
  109. try:
  110. lines = safe_path(path).read_text().splitlines()
  111. if limit and limit < len(lines):
  112. lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
  113. return "\n".join(lines)
  114. except Exception as e:
  115. return f"错误:{e}"
  116. def run_write(path: str, content: str) -> str:
  117. try:
  118. file_path = safe_path(path)
  119. file_path.parent.mkdir(parents=True, exist_ok=True)
  120. file_path.write_text(content)
  121. return f"已写入 {len(content)} 字节到 {path}"
  122. except Exception as e:
  123. return f"错误:{e}"
  124. def run_edit(path: str, old_text: str, new_text: str) -> str:
  125. try:
  126. file_path = safe_path(path)
  127. text = file_path.read_text()
  128. if old_text not in text:
  129. return f"错误:在文件中未找到目标文本:{path}"
  130. file_path.write_text(text.replace(old_text, new_text, 1))
  131. return f"已编辑 {path}"
  132. except Exception as e:
  133. return f"错误:{e}"
  134. def run_glob(pattern: str) -> str:
  135. import glob as g
  136. try:
  137. results = []
  138. for match in g.glob(pattern, root_dir=WORKDIR):
  139. if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
  140. results.append(match)
  141. return "\n".join(results) if results else "(无匹配)"
  142. except Exception as e:
  143. return f"错误:{e}"
  144. def _normalize_todos(todos):
  145. if isinstance(todos, str):
  146. try:
  147. todo_list = json.loads(todos)
  148. except json.JSONDecodeError:
  149. try:
  150. todo_list = ast.literal_eval(todos)
  151. except (SyntaxError, ValueError):
  152. return None, "错误:todos 必须是列表或 JSON 数组字符串"
  153. if not isinstance(todos, list):
  154. return None, "错误:todos 必须是列表"
  155. for i, t in enumerate(todos):
  156. if not isinstance(t, dict):
  157. return None, f"错误:todos[{i}] 必须是对象"
  158. if "content" not in t or "status" not in t:
  159. return None, f"错误:todos[{i}] 缺少 'content' 或 'status'"
  160. if t["status"] not in ("pending", "in_progress", "completed"):
  161. return None, f"错误:todos[{i}] 包含无效状态 '{t['status']}'"
  162. return todo_list, None
  163. def run_todo_write(todos: list) -> str:
  164. global CURRENT_TODOS
  165. todo_list, error = _normalize_todos(todos)
  166. if error:
  167. return error
  168. CURRENT_TODOS = todo_list
  169. lines = ["\n\033[33m## 当前任务\033[0m"]
  170. for t in CURRENT_TODOS:
  171. icon = {"pending": " ", "in_progress": "\033[36m▸\033[0m", "completed": "\033[32m✓\033[0m"}[t["status"]]
  172. lines.append(f" [{icon}] {t['content']}")
  173. print("\n".join(lines))
  174. return f"已更新 {len(CURRENT_TODOS)} 个任务"
  175. def extract_text(content) -> str:
  176. if not isinstance(content, list):
  177. return str(content)
  178. return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text")
  179. # ═══════════════════════════════════════════════════════════
  180. # 来自 s06 (未改动): 子 Agent
  181. # ═══════════════════════════════════════════════════════════
  182. SUB_TOOLS = [
  183. {"name": "bash", "description": "运行一条 shell 命令。",
  184. "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
  185. {"name": "read_file", "description": "读取文件内容。",
  186. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
  187. {"name": "write_file", "description": "向文件写入内容。",
  188. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
  189. {"name": "edit_file", "description": "在文件中替换一次完全匹配的文本。",
  190. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
  191. {"name": "glob", "description": "查找匹配 glob 模式的文件。",
  192. "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
  193. ]
  194. SUB_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write,
  195. "edit_file": run_edit, "glob": run_glob}
  196. def spawn_subagent(description: str) -> str:
  197. print(f"\n\033[35m[子 Agent 已启动]\033[0m")
  198. messages = [{"role": "user", "content": description}]
  199. for _ in range(30):
  200. response = client.messages.create(model=MODEL, system=SUB_SYSTEM,
  201. messages=messages, tools=SUB_TOOLS, max_tokens=8000)
  202. messages.append({"role": "assistant", "content": response.content})
  203. if response.stop_reason != "tool_use":
  204. break
  205. results = []
  206. for block in response.content:
  207. if block.type == "tool_use":
  208. blocked = trigger_hooks("PreToolUse", block)
  209. if blocked:
  210. results.append({"type": "tool_result", "tool_use_id": block.id,
  211. "content": str(blocked)})
  212. continue
  213. handler = SUB_HANDLERS.get(block.name)
  214. output = handler(**block.input) if handler else f"未知工具:{block.name}"
  215. trigger_hooks("PostToolUse", block, output)
  216. print(f" \033[90m[sub] {block.name}: {str(output)[:100]}\033[0m")
  217. results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
  218. messages.append({"role": "user", "content": results})
  219. result = extract_text(messages[-1]["content"])
  220. if not result:
  221. for msg in reversed(messages):
  222. if msg["role"] == "assistant":
  223. result = extract_text(msg["content"])
  224. if result:
  225. break
  226. if not result:
  227. result = "子 Agent 已等待 30 轮仍未给出最终回答,已停止。"
  228. print(f"\033[35m[子 Agent 已完成]\033[0m")
  229. return result
  230. # ═══════════════════════════════════════════════════════════
  231. # 新增于 s07: load_skill — 运行时加载完整内容
  232. # ═══════════════════════════════════════════════════════════
  233. def load_skill(name: str) -> str:
  234. """通过注册表加载完整技能内容,避免路径穿越。"""
  235. skill = SKILL_REGISTRY.get(name)
  236. if not skill:
  237. return f"未找到技能:{name}"
  238. return skill["content"]
  239. # ═══════════════════════════════════════════════════════════
  240. # 工具注册表 — 来自 s02-s07 的全部工具,怎么省token 怎么降低模型幻觉
  241. # ═══════════════════════════════════════════════════════════
  242. TOOLS = [
  243. {"name": "bash", "description": "运行一条 shell 命令。",
  244. "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
  245. {"name": "read_file", "description": "读取文件内容。",
  246. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
  247. {"name": "write_file", "description": "向文件写入内容。",
  248. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
  249. {"name": "edit_file", "description": "在文件中替换一次完全匹配的文本。",
  250. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
  251. {"name": "glob", "description": "查找匹配 glob 模式的文件。",
  252. "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
  253. {"name": "todo_write", "description": "为当前编码会话创建并维护任务清单。",
  254. "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}},
  255. {"name": "task", "description": "启动一个子 Agent 处理复杂子任务。只返回最终结论。",
  256. "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
  257. # s07: 技能工具 (目录已在 SYSTEM 提示词中,此处加载完整内容)
  258. {"name": "load_skill", "description": "按名称加载某个技能的完整内容。",
  259. "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
  260. ]
  261. TOOL_HANDLERS = {
  262. "bash": run_bash, "read_file": run_read, "write_file": run_write,
  263. "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
  264. "task": spawn_subagent, "load_skill": load_skill,
  265. }
  266. # ═══════════════════════════════════════════════════════════
  267. # 来自 s04 (未改动): Hook 系统
  268. # ═══════════════════════════════════════════════════════════
  269. HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
  270. def register_hook(event: str, callback):
  271. HOOKS[event].append(callback)
  272. def trigger_hooks(event: str, *args):
  273. for callback in HOOKS[event]:
  274. result = callback(*args)
  275. if result is not None:
  276. return result
  277. return None
  278. DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
  279. def permission_hook(block):
  280. if block.name == "bash":
  281. for p in DENY_LIST:
  282. if p in block.input.get("command", ""):
  283. print(f"\n\033[31m⛔ 已拦截:'{p}'\033[0m")
  284. return "权限被拒绝"
  285. return None
  286. def log_hook(block):
  287. print(f"\033[90m[钩子] {block.name}\033[0m")
  288. return None
  289. def context_inject_hook(query: str):
  290. print(f"\033[90m[钩子] UserPromptSubmit: 工作目录:{WORKDIR}\033[0m")
  291. return None
  292. def summary_hook(messages: list):
  293. tool_count = sum(1 for m in messages
  294. for b in (m.get("content") if isinstance(m.get("content"), list) else [])
  295. if isinstance(b, dict) and b.get("type") == "tool_result")
  296. print(f"\033[90m[钩子] Stop:会话使用了 {tool_count} 次工具调用\033[0m")
  297. return None
  298. register_hook("UserPromptSubmit", context_inject_hook)
  299. register_hook("PreToolUse", permission_hook)
  300. register_hook("PreToolUse", log_hook)
  301. register_hook("Stop", summary_hook)
  302. # ═══════════════════════════════════════════════════════════
  303. # agent_loop — 与以下相同: s05-s06 + 催办提醒
  304. # ═══════════════════════════════════════════════════════════
  305. def agent_loop(messages: list):
  306. rounds_since_todo = 0
  307. while True:
  308. if rounds_since_todo >= 3 and messages:
  309. messages.append({"role": "user",
  310. "content": "<reminder>请更新你的待办事项。</reminder>"})
  311. rounds_since_todo = 0
  312. response = client.messages.create(
  313. model=MODEL, system=SYSTEM, messages=messages,
  314. tools=TOOLS, max_tokens=8000,
  315. )
  316. messages.append({"role": "assistant", "content": response.content})
  317. if response.stop_reason != "tool_use":
  318. force = trigger_hooks("Stop", messages)
  319. if force:
  320. messages.append({"role": "user", "content": force})
  321. continue
  322. return
  323. rounds_since_todo += 1
  324. results = []
  325. for block in response.content:
  326. if block.type != "tool_use":
  327. continue
  328. blocked = trigger_hooks("PreToolUse", block)
  329. if blocked:
  330. results.append({"type": "tool_result", "tool_use_id": block.id,
  331. "content": str(blocked)})
  332. continue
  333. handler = TOOL_HANDLERS.get(block.name)
  334. output = handler(**block.input) if handler else f"未知工具:{block.name}"
  335. trigger_hooks("PostToolUse", block, output)
  336. if block.name == "todo_write":
  337. rounds_since_todo = 0
  338. results.append({"type": "tool_result", "tool_use_id": block.id,
  339. "content": output})
  340. messages.append({"role": "user", "content": results})
  341. if __name__ == "__main__":
  342. print("s07: 技能加载 — 目录进 SYSTEM,内容按需加载")
  343. print("输入问题后按回车。输入 q 退出。\n")
  344. history = []
  345. while True:
  346. try:
  347. query = input("\033[36ms07 >> \033[0m")
  348. except (EOFError, KeyboardInterrupt):
  349. break
  350. if query.strip().lower() in ("q", "exit", ""):
  351. break
  352. trigger_hooks("UserPromptSubmit", query)
  353. history.append({"role": "user", "content": query})
  354. agent_loop(history)
  355. for block in history[-1]["content"]:
  356. if getattr(block, "type", None) == "text":
  357. print(block.text)
  358. print()