ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
实验 2-6:使用 Agent Skills 从论文生成演示文稿(自建同构 Skills 机制)
本 demo 复现《深入理解 AI Agent》第二章「Agent Skills / 渐进式披露」一节的思想。
由于 Anthropic key 无效,这里用 OpenAIgpt-5.6-luna+ 一套自建的、与 Anthropic
Skills 同构的机制来演示,核心是「渐进式披露(Progressive Disclosure)」:
第一层(元数据):Agent 启动时的 system prompt 里只放各 Skill 的 name +
description(薄目录,数百 token),并不含具体流程。
第二层(核心流程):当任务需要时,Agent 主动用 read_skill 工具加载完整 SKILL.md。
第三层(细则):Agent 可再用 read_skill_file 读取 reference.md / 脚本源码。
然后 Agent 用捆绑脚本 scripts/generate_pptx.py(经 run_skill_script 工具)用
python-pptx 生成真实的 .pptx,并读回校验页数与每页标题。
运行:
export OPENAI_API_KEY=your-openai-api-key
python demo.py
"""
import argparse
import json
import os
import sys
from pathlib import Path
from openai import OpenAI
from pptx import Presentation
# 从同目录 .env 读取 OPENAI_API_KEY(若安装了 python-dotenv
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent / ".env")
except ImportError:
pass
# ---------------------------------------------------------------------------
# 路径与配置
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent
SKILLS_DIR = ROOT / "skills"
PAPER_PATH = ROOT / "papers" / "sample_paper.md"
OUTPUT_DIR = ROOT / "output"
MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.6-luna")
def log(msg: str) -> None:
print(msg, flush=True)
# ---------------------------------------------------------------------------
# 第一层:启动时扫描 skills/ 目录,只读取每个 SKILL.md 的 frontmatter
# name + description),拼成薄目录注入 system prompt。这一步刻意「只看目录」。
# ---------------------------------------------------------------------------
def parse_frontmatter(skill_md: str) -> dict:
"""从 SKILL.md 顶部的 --- YAML frontmatter --- 中解析 name / description。"""
meta = {}
if not skill_md.startswith("---"):
return meta
end = skill_md.find("---", 3)
if end == -1:
return meta
for line in skill_md[3:end].splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
return meta
def scan_skill_catalog() -> dict:
"""返回 {skill_name: {"description":..., "dir": Path}},只含元数据。"""
catalog = {}
for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")):
meta = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
name = meta.get("name") or skill_md.parent.name
catalog[name] = {
"description": meta.get("description", ""),
"dir": skill_md.parent,
}
return catalog
def build_system_prompt(catalog: dict) -> str:
lines = [
"你是一个能使用 Agent Skills 的助手。你并不预先知道每个 Skill 的详细流程,",
"只在下方看到一份「薄目录」——每个 Skill 的 name 与 description(路由条件)。",
"",
"当任务需要某个 Skill 时,你必须:",
" 1) 先用 read_skill(name) 加载它的完整 SKILL.md(第二层:核心流程);",
" 2) 如需实现/样式细节,再用 read_skill_file(name, path) 读取子文档或脚本(第三层);",
" 3) 按 SKILL.md 的约定,用 run_skill_script 调用捆绑脚本完成任务。",
"不要在没有 read_skill 的情况下臆测某个 Skill 的调用方式。",
"",
"== 已安装的 Skills(薄目录,仅元数据)==",
]
for name, info in catalog.items():
lines.append(f"- {name}: {info['description']}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# 工具实现:read_skill / read_skill_file / run_skill_script
# 这些是「渐进式披露」的通道——第二、三层内容只有被调用时才进入上下文。
# ---------------------------------------------------------------------------
def tool_read_skill(catalog: dict, name: str) -> str:
info = catalog.get(name)
if not info:
return f"[error] 未找到 Skill: {name}"
content = (info["dir"] / "SKILL.md").read_text(encoding="utf-8")
log(f"\n >>> [渐进式披露·第二层] Agent 调用 read_skill('{name}')"
f"加载完整 SKILL.md{len(content)} 字符)")
return content
def tool_read_skill_file(catalog: dict, name: str, rel_path: str) -> str:
info = catalog.get(name)
if not info:
return f"[error] 未找到 Skill: {name}"
target = (info["dir"] / rel_path).resolve()
# 防目录穿越:必须落在该 skill 目录内
if not str(target).startswith(str(info["dir"].resolve())):
return f"[error] 非法路径: {rel_path}"
if not target.exists():
return f"[error] 文件不存在: {rel_path}"
content = target.read_text(encoding="utf-8")
log(f" >>> [渐进式披露·第三层] Agent 调用 read_skill_file('{name}', '{rel_path}')"
f"加载子文档({len(content)} 字符)")
return content
def tool_run_skill_script(catalog: dict, name: str, script: str, payload: str,
out_path: Path) -> str:
info = catalog.get(name)
if not info:
return f"[error] 未找到 Skill: {name}"
scripts_dir = (info["dir"] / "scripts").resolve()
script_path = (scripts_dir / script).resolve()
# 防目录穿越:脚本会被直接执行,必须落在该 skill 的 scripts 目录内
if not script_path.is_relative_to(scripts_dir):
return f"[error] 非法脚本路径: {script}"
if not script_path.exists():
return f"[error] 脚本不存在: {script}"
# 动态载入捆绑脚本(它就是 Skill 的一部分)
import importlib.util
spec = importlib.util.spec_from_file_location("bundled_generator", script_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
try:
data = json.loads(payload) if isinstance(payload, str) else payload
except json.JSONDecodeError as e:
return f"[error] payload 不是合法 JSON: {e}"
log(f" >>> [执行捆绑脚本] run_skill_script('{name}', '{script}') "
f"生成 {out_path.name} ...")
result = module.build_presentation(data, str(out_path))
return json.dumps(result, ensure_ascii=False)
TOOLS = [
{
"type": "function",
"function": {
"name": "read_skill",
"description": "加载指定 Skill 的完整 SKILL.md(核心流程,渐进式披露第二层)。",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string", "description": "Skill 名称"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "read_skill_file",
"description": "读取某 Skill 目录内的子文档或脚本源码(细则,渐进式披露第三层)。",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"path": {"type": "string", "description": "相对 skill 目录的路径,如 reference.md 或 scripts/generate_pptx.py"},
},
"required": ["name", "path"],
},
},
},
{
"type": "function",
"function": {
"name": "run_skill_script",
"description": "执行某 Skill 捆绑的脚本以完成实际产出(如生成 pptx)。",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"script": {"type": "string", "description": "脚本文件名,如 generate_pptx.py"},
"payload": {"type": "string", "description": "传给脚本的 JSON 字符串(大纲)"},
},
"required": ["name", "script", "payload"],
},
},
},
]
def dispatch(catalog: dict, name: str, args: dict, out_path: Path) -> str:
# 模型给的 arguments 可能缺字段(或根本不是合法 JSON,被上层回退成 {})。
# 缺参时返回 [error] 让 Agent 在下一轮自我纠正,而不是抛 KeyError 中断 loop。
required = {
"read_skill": ["name"],
"read_skill_file": ["name", "path"],
"run_skill_script": ["name", "script", "payload"],
}
if name not in required:
return f"[error] 未知工具: {name}"
missing = [k for k in required[name] if k not in args]
if missing:
return f"[error] 工具 {name} 缺少参数: {', '.join(missing)}"
if name == "read_skill":
return tool_read_skill(catalog, args["name"])
if name == "read_skill_file":
return tool_read_skill_file(catalog, args["name"], args["path"])
return tool_run_skill_script(catalog, args["name"], args["script"],
args["payload"], out_path)
# ---------------------------------------------------------------------------
# 主流程:agentic loop
# ---------------------------------------------------------------------------
def run_agent(paper_path: Path, model: str, out_path: Path,
max_turns: int = 8) -> Path | None:
# OPENAI_API_KEY 存在则官方直连;否则回退 OPENROUTER_API_KEY
# gpt-* 模型名会被映射为 openai/…)。两者皆无则给出清晰错误。
from agentbook.providers import resolve_backend
if not os.environ.get("OPENAI_API_KEY") and not os.environ.get("OPENROUTER_API_KEY"):
log("错误:未设置 OPENAI_API_KEY,也未设置 OPENROUTER_API_KEY(通用回退)。")
log("请 export OPENAI_API_KEY=your-openai-api-key 或 export OPENROUTER_API_KEY=your-openrouter-api-key")
log("(无 key 时可用 --offline 走内置大纲、确定性地复现三层渐进式披露并生成 pptx。)")
sys.exit(1)
# 端点与 key 的对应关系由 agentbook 的 provider 注册表统一维护,
# OPENAI_BASE_URL 覆盖也在其中处理。
backend = resolve_backend("openai", model=model)
model = backend.model
# timeout + 自动重试:单次网络/SSL 抖动不至于让整个 agentic loop 崩溃
client = OpenAI(
api_key=backend.api_key, base_url=backend.base_url, timeout=60.0, max_retries=3
)
catalog = scan_skill_catalog()
system_prompt = build_system_prompt(catalog)
log("=" * 72)
log("【第一层·元数据】Agent 启动时只看到这份薄 Skill 目录(system prompt):")
log("-" * 72)
log(system_prompt)
log("-" * 72)
log(f"(薄目录约 {len(system_prompt)} 字符 / 数百 token;各 Skill 的详细流程此刻并不在上下文中)")
log("=" * 72)
paper = paper_path.read_text(encoding="utf-8")
user_task = (
"请把下面这篇论文做成一份 8-12 页的演示文稿(含标题页、目录页、问题背景、"
"方法概述、关键结果、局限性、小结页),总页数务必落在 8-12 页。"
"先判断该用哪个 Skill,再严格按其 SKILL.md 的页序与约束操作。\n\n"
"=== 论文全文 ===\n" + paper
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_task},
]
log("\n【任务下发】要求 Agent 从论文生成演示文稿。观察它如何按需渐进式披露:\n")
final_result = None
for turn in range(1, max_turns + 1):
resp = client.chat.completions.create(
model=model,
messages=messages,
tools=TOOLS,
temperature=0.2,
)
msg = resp.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
log(f"\n【Agent 第 {turn} 轮·结束语】\n{msg.content}")
break
for tc in msg.tool_calls:
fn = tc.function.name
try:
args = json.loads(tc.function.arguments or "{}")
except json.JSONDecodeError:
args = {}
log(f"\n[Agent 第 {turn} 轮] 调用工具 -> {fn}({', '.join(f'{k}={_short(v)}' for k, v in args.items())})")
result = dispatch(catalog, fn, args, out_path)
if fn == "run_skill_script" and not result.startswith("[error]"):
final_result = json.loads(result)
log(f" >>> 生成结果:{result}")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
if final_result:
return Path(final_result["path"])
return None
# ---------------------------------------------------------------------------
# 离线复现:无 OpenAI key 时,用内置大纲(papers/sample_outline.json)确定性地
# 走完与在线完全相同的三层渐进式披露与工具通道(read_skill / read_skill_file /
# run_skill_script),从而在没有任何 API 访问权限时也能真实生成并校验 pptx。
# 唯一区别是「用哪个 Skill、大纲写什么」由预置脚本给定,而非模型即时决策。
# ---------------------------------------------------------------------------
OUTLINE_PATH = ROOT / "papers" / "sample_outline.json"
def run_offline(out_path: Path) -> Path | None:
catalog = scan_skill_catalog()
system_prompt = build_system_prompt(catalog)
log("=" * 72)
log("【离线模式】不调用 OpenAI,用内置大纲确定性地复现三层渐进式披露。")
log("【第一层·元数据】启动时只看到这份薄 Skill 目录(system prompt):")
log("-" * 72)
log(system_prompt)
log("-" * 72)
log(f"(薄目录约 {len(system_prompt)} 字符;各 Skill 的详细流程此刻并不在上下文中)")
log("=" * 72)
if not OUTLINE_PATH.exists():
log(f"错误:内置大纲不存在:{OUTLINE_PATH}")
return None
# 与在线 agentic loop 相同的工具通道,只是调用序列由脚本给定
log("\n【离线回放】按 SKILL.md 约定,逐层加载并调用捆绑脚本:")
dispatch(catalog, "read_skill", {"name": "pptx"}, out_path)
dispatch(catalog, "read_skill_file",
{"name": "pptx", "path": "reference.md"}, out_path)
payload = OUTLINE_PATH.read_text(encoding="utf-8")
result = dispatch(catalog, "run_skill_script",
{"name": "pptx", "script": "generate_pptx.py", "payload": payload},
out_path)
if result.startswith("[error]"):
log(f" >>> 生成失败:{result}")
return None
log(f" >>> 生成结果:{result}")
return Path(json.loads(result)["path"])
def _short(v, n=48):
s = str(v).replace("\n", " ")
return s if len(s) <= n else s[:n] + ""
# ---------------------------------------------------------------------------
# 校验:用 python-pptx 重新打开生成的文件,读回页数与每页标题,证明是有效 pptx。
# ---------------------------------------------------------------------------
def verify_pptx(path: Path) -> None:
log("\n" + "=" * 72)
log("【校验】用 python-pptx 重新打开生成的文件,读回页数与每页标题:")
log("-" * 72)
prs = Presentation(str(path))
slides = list(prs.slides)
log(f"文件: {path}")
log(f"总页数: {len(slides)}")
for i, slide in enumerate(slides, 1):
first_text = "(空)"
for shp in slide.shapes:
if shp.has_text_frame and shp.text_frame.text.strip():
first_text = shp.text_frame.text.strip().splitlines()[0]
break
log(f"{i:>2} 页标题: {first_text}")
log("-" * 72)
log(f"校验通过:这是一个可被 python-pptx / PowerPoint 打开的有效 .pptx{len(slides)} 页)。")
log("=" * 72)
def parse_args():
p = argparse.ArgumentParser(
description="实验 2-6:用 Agent Skills 的「渐进式披露」从论文生成演示文稿。"
"Agent 启动只看到薄 Skill 目录,按需逐层加载 pptx Skill 的流程与脚本,"
"最后用 python-pptx 生成并校验 output/presentation.pptx。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("--paper", default=str(PAPER_PATH),
help="输入论文/大纲(markdown)路径,默认 papers/sample_paper.md。")
p.add_argument("--output", "-o", default=str(OUTPUT_DIR / "presentation.pptx"),
help="输出 .pptx 路径,默认 output/presentation.pptx。")
p.add_argument("--model", default=MODEL,
help="OpenAI 模型名,默认取环境变量 OPENAI_MODEL,否则 gpt-5.6-luna。")
p.add_argument("--max-turns", type=int, default=8,
help="agentic loop 的最大轮数,默认 8。")
p.add_argument("--offline", action="store_true",
help="离线演示:不调用 OpenAI,用内置大纲(papers/sample_outline.json"
"确定性地走完三层渐进式披露并生成 pptx(无需 API key,可复现)。")
return p.parse_args()
def main():
args = parse_args()
paper_path = Path(args.paper)
out_path = Path(args.output)
out_path.parent.mkdir(parents=True, exist_ok=True)
if args.offline:
pptx_path = run_offline(out_path)
else:
if not paper_path.exists():
log(f"错误:论文文件不存在:{paper_path}")
sys.exit(1)
pptx_path = run_agent(paper_path, args.model, out_path, args.max_turns)
if pptx_path and pptx_path.exists():
verify_pptx(pptx_path)
else:
log("\n未生成 pptx。请检查上面的日志。")
sys.exit(2)
if __name__ == "__main__":
main()