agent-skills-ppt¶
第2章 · 上下文工程 · 配套项目
chapter2/agent-skills-ppt
项目说明¶
实验 2-6:使用 Agent Skills 从论文生成演示文稿¶
配套《深入理解 AI Agent》第二章「动态提示词与 Agent Skills」一节的实验 2-6(★★)。
目的¶
验证书中的核心命题:Agent 通过「渐进式披露(Progressive Disclosure)」按需加载 专业领域 Skill,即可完成复杂任务,而无需把所有知识一次性塞进系统提示词。
本 demo 让一个 Agent 从一篇(自带的)精简论文生成一份 8-12 页的 PowerPoint。
Agent 启动时只看到一份薄 Skill 目录,当它识别出任务需要 pptx Skill 后,
才逐层加载该 Skill 的完整流程、子文档与捆绑脚本,最后用 python-pptx 生成真实
的 .pptx 文件。
与 Anthropic PPTX Skill 的关系¶
书中原实验跑在 Claude Code + Anthropic 官方 PPTX Skill 上。由于当前环境的 Anthropic key 无效,本项目自建了一套同构的 Skills 机制来复现同样的思想, 而非调用 Anthropic:
| 维度 | Anthropic PPTX Skill(书中) | 本项目(自建同构版) |
|---|---|---|
| 运行时 | Claude Code | Python + OpenAI SDK(gpt-5.6-luna) |
| 第一层·元数据 | 启动注入所有 Skill 的 name+description | scan_skill_catalog() 只读 frontmatter 拼进 system prompt |
| 第二层·核心流程 | Skill 工具加载完整 SKILL.md |
read_skill 工具加载 skills/pptx/SKILL.md |
| 第三层·细则 | 引用 html2pptx.md / reference.md |
read_skill_file 读 reference.md / 脚本源码 |
| 捆绑脚本 | scripts/thumbnail.py 等 |
scripts/generate_pptx.py(python-pptx 生成器) |
机制一一对应,只是把「Claude 内置的 Skill 加载器」换成了几个显式的读取/执行工具, 从而在没有 Anthropic 访问权限时,依然能真实演示渐进式披露的三层加载过程。
说明:本项目主用 OpenAI(默认模型 gpt-5.6-luna)。通用回退:未设置
OPENAI_API_KEY时,只要配置了OPENROUTER_API_KEY,会自动改走 OpenRouter (gpt-*映射为openai/…)。设置了OPENAI_API_KEY时行为完全不变。
渐进式披露的三层结构¶
skills/
└── pptx/
├── SKILL.md # 第一层:顶部 YAML frontmatter(name+description) —— 只有它进 system prompt
│ # 第二层:正文核心流程 —— read_skill 时才加载
├── reference.md # 第三层:版式/配色/技术细则 —— read_skill_file 时才加载
└── scripts/
└── generate_pptx.py # 捆绑可执行脚本 —— run_skill_script 时才执行
- 第一层(元数据):Agent 启动时,
system prompt里只有各 Skill 的name + description(约数百 token)。此刻它并不知道怎么做 PPT。 - 第二层(核心流程):Agent 判断任务需要
pptx,调用read_skill("pptx")把完整SKILL.md作为 tool result 载入上下文,得到页序规划与脚本调用约定。 - 第三层(细则):如需实现/样式细节,Agent 再用
read_skill_file("pptx", "reference.md")或读取脚本源码。 - 执行:Agent 组织好幻灯片大纲 JSON,通过
run_skill_script调用捆绑的generate_pptx.py,用 python-pptx 落地为output/presentation.pptx。
运行¶
pip install -r requirements.txt
cp env.example .env # 或直接 export
export OPENAI_API_KEY=sk-... # 默认模型 gpt-5.6-luna,可用 OPENAI_MODEL 覆盖
python demo.py
python demo.py --paper papers/your_paper.md # 换一篇论文/大纲
python demo.py -o output/deck.pptx --model gpt-5.6-luna # 指定输出路径 / 模型
python demo.py --help # 查看全部参数
一条命令 python demo.py 即可跑通:真实调用 OpenAI,打印渐进式披露的每一步,
生成 output/presentation.pptx,并用 python-pptx 重新打开该文件读回页数与每页标题
作为校验。
命令行参数¶
| 参数 | 默认值 | 说明 |
|---|---|---|
--paper |
papers/sample_paper.md |
输入论文/大纲(markdown)路径 |
--output / -o |
output/presentation.pptx |
输出 .pptx 路径 |
--model |
OPENAI_MODEL 或 gpt-5.6-luna |
OpenAI 模型名 |
--max-turns |
8 |
agentic loop 的最大轮数 |
--offline |
关 | 离线演示,不调用 OpenAI(见下) |
离线模式(无需 API key,可复现)¶
没有 OpenAI key 时,用 --offline 即可跑通同一套三层渐进式披露:它读取内置大纲
papers/sample_outline.json,走与在线完全相同的工具通道
(read_skill → read_skill_file → run_skill_script)确定性地生成并校验 pptx。
唯一区别是「用哪个 Skill、大纲写什么」由预置文件给定,而非模型即时决策——因此它
适合作为可复现的教学演示与冒烟测试。
python demo.py --offline # 生成 output/presentation.pptx,全程无网络
python demo.py --offline -o output/deck.pptx # 指定输出路径
捆绑脚本本身也可脱离 Agent 单独运行,直接把大纲 JSON 落地为 pptx:
真实运行输出(节选)¶
【第一层·元数据】Agent 启动时只看到这份薄 Skill 目录(system prompt):
== 已安装的 Skills(薄目录,仅元数据)==
- pptx: 从论文...生成 PowerPoint...Use when...Don't use when...
[Agent 第 1 轮] 调用工具 -> read_skill(name=pptx)
>>> [渐进式披露·第二层] 加载完整 SKILL.md(1150 字符)
[Agent 第 2 轮] 调用工具 -> read_skill_file(name=pptx, path=scripts/generate_pptx.py)
>>> [渐进式披露·第三层] 加载子文档(4270 字符)
[Agent 第 3 轮] 调用工具 -> run_skill_script(name=pptx, script=generate_pptx.py, ...)
>>> 生成 presentation.pptx ...
【校验】用 python-pptx 重新打开生成的文件,读回页数与每页标题:
总页数: 9
第 1 页标题: 精简论文:渐进式披露式 Agent Skills 对上下文效率的影响
第 2 页标题: 目录
第 3 页标题: 研究背景与问题
第 4 页标题: 方法概述(总体思路)
...
第 9 页标题: 小结
校验通过:这是一个可被 python-pptx / PowerPoint 打开的有效 .pptx(9 页)。
(页数/标题由模型即时规划,每次运行可能略有差异,但均落在 8-12 页区间。)
文件说明¶
| 文件 | 作用 |
|---|---|
demo.py |
主程序:扫描薄目录 → agentic loop → 渐进式披露 → 生成并校验 pptx |
skills/pptx/SKILL.md |
pptx Skill:frontmatter(元数据)+ 核心流程 |
skills/pptx/reference.md |
第三层细则:版式/配色/python-pptx 技术点 |
skills/pptx/scripts/generate_pptx.py |
捆绑生成器,用 python-pptx 从大纲生成 .pptx |
papers/sample_paper.md |
自带的精简论文/大纲(在线模式输入) |
papers/sample_outline.json |
内置幻灯片大纲(离线模式输入,同时是 payload schema 的范例) |
output/presentation.pptx |
生成的演示文稿(输出,运行后产生) |
换一篇论文¶
把 papers/sample_paper.md 替换为你自己的论文/大纲(markdown),或直接
python demo.py --paper 你的论文.md 指定路径即可。
源代码¶
demo.py¶
#!/usr/bin/env python3
"""
实验 2-6:使用 Agent Skills 从论文生成演示文稿(自建同构 Skills 机制)
本 demo 复现《深入理解 AI Agent》第二章「Agent Skills / 渐进式披露」一节的思想。
由于 Anthropic key 无效,这里用 OpenAI(gpt-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=sk-...
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}"
script_path = (info["dir"] / "scripts" / script).resolve()
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 openrouter_fallback import resolve_llm
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=sk-... 或 export OPENROUTER_API_KEY=sk-or-...")
log("(无 key 时可用 --offline 走内置大纲、确定性地复现三层渐进式披露并生成 pptx。)")
sys.exit(1)
api_key, base_url, model = resolve_llm(
model=model,
primary_keys=("OPENAI_API_KEY",),
primary_base_url=os.getenv("OPENAI_BASE_URL") or None,
)
# timeout + 自动重试:单次网络/SSL 抖动不至于让整个 agentic loop 崩溃
client = OpenAI(api_key=api_key, base_url=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()
openrouter_fallback.py¶
"""Universal OpenRouter fallback helper (Chapter 2 experiments).
Goal: every experiment keeps working when the direct provider key is missing
but ``OPENROUTER_API_KEY`` is present. Default behavior is fully preserved:
* If a primary provider key is present -> use the primary provider unchanged.
* Else if ``OPENROUTER_API_KEY`` is present -> route through OpenRouter
(base_url=https://openrouter.ai/api/v1) and translate the model id.
* Else raise a clear error listing every accepted key.
Model translation (only applied when the OpenRouter fallback is active):
* ids already containing "/" are passed through unchanged;
* gpt-* / o1* / o3* / o4* / chatgpt* -> "openai/<id>";
* claude-* -> "anthropic/claude-opus-4.8";
* kimi-* -> "moonshotai/kimi-k2.6";
* anything else -> passed through unchanged.
"""
import os
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def is_openrouter_key(api_key):
"""OpenRouter keys reliably start with ``sk-or-``."""
return bool(api_key) and api_key.startswith("sk-or-")
def map_model_to_openrouter(model):
"""Translate a bare provider model id into an OpenRouter-qualified id."""
if not model or "/" in model:
return model
low = model.lower()
if low.startswith(("gpt-", "gpt5", "o1", "o3", "o4", "chatgpt")):
return "openai/" + model
if low.startswith("claude"):
return "anthropic/claude-opus-4.8"
if low.startswith("kimi"):
return "moonshotai/kimi-k2.6"
return model
def resolve_llm(model=None, primary_keys=("OPENAI_API_KEY",), primary_base_url=None):
"""Resolve ``(api_key, base_url, model)`` honoring the primary->OpenRouter fallback.
Args:
model: requested model id (may be remapped when the fallback activates).
primary_keys: env var names checked in order for the primary provider.
primary_base_url: base_url used when a primary key is found
(``None`` means the OpenAI SDK default / official endpoint).
"""
or_key = os.getenv("OPENROUTER_API_KEY")
# gpt-5.x (incl. gpt-5.6*) needs OpenAI org-verification on the direct API;
# when an OpenRouter key is present, prefer routing these ids through it.
if or_key and model and model.lower().startswith("gpt-5"):
return or_key, OPENROUTER_BASE_URL, map_model_to_openrouter(model)
for env in primary_keys:
key = os.getenv(env)
if key:
return key, primary_base_url, model
if or_key:
return or_key, OPENROUTER_BASE_URL, map_model_to_openrouter(model)
accepted = ", ".join(list(primary_keys) + ["OPENROUTER_API_KEY"])
raise RuntimeError(
"No LLM API key found. Set one of the primary keys or the universal "
"fallback. Accepted keys: " + accepted + ". See env.example."
)
skills/pptx/scripts/generate_pptx.py¶
"""
pptx Skill 捆绑的可执行脚本:使用 python-pptx 从结构化大纲生成真实的 .pptx 文件。
这是 Agent Skills「渐进式披露」中第三层(细则 / 捆绑工具)的一部分:
Agent 在读取 SKILL.md 后,得知需要通过 run_skill_script 工具调用本脚本,
并按约定的 JSON schema 传入幻灯片大纲。本脚本负责把大纲落地为 PowerPoint。
payload JSON schema(由 SKILL.md 向 Agent 说明):
{
"title": "演示文稿主标题(字符串)",
"subtitle": "副标题,通常是作者/来源(字符串,可选)",
"slides": [
{"title": "页标题", "bullets": ["要点1", "要点2", ...]},
...
]
}
既可作为库被 import(build_presentation),也可作为 CLI 直接运行:
python generate_pptx.py outline.json output/deck.pptx
"""
import json
import sys
from pathlib import Path
from pptx import Presentation
from pptx.util import Pt, Inches
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
# 一套简单的品牌配色,作为设计起点(对应 SKILL.md 提到的「模板 / 设计起点」)
ACCENT = RGBColor(0x1F, 0x4E, 0x79) # 深蓝
DARK = RGBColor(0x22, 0x22, 0x22) # 近黑正文
LIGHT = RGBColor(0xF2, 0xF5, 0xFA) # 浅色背景条
def _set_slide_bg(slide, rgb):
"""给整页填充一个纯色背景。"""
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = rgb
def _add_title_slide(prs, title, subtitle):
slide = prs.slides.add_slide(prs.slide_layouts[6]) # 6 = 纯空白版式
_set_slide_bg(slide, ACCENT)
# 主标题
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), Inches(8.4), Inches(2.0))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
run = p.add_run()
run.text = title
run.font.size = Pt(40)
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 副标题
if subtitle:
sbox = slide.shapes.add_textbox(Inches(0.8), Inches(4.3), Inches(8.4), Inches(1.0))
stf = sbox.text_frame
stf.word_wrap = True
sp = stf.paragraphs[0]
sp.alignment = PP_ALIGN.CENTER
srun = sp.add_run()
srun.text = subtitle
srun.font.size = Pt(20)
srun.font.color.rgb = RGBColor(0xD5, 0xDE, 0xEB)
def _add_content_slide(prs, title, bullets):
slide = prs.slides.add_slide(prs.slide_layouts[6])
_set_slide_bg(slide, RGBColor(0xFF, 0xFF, 0xFF))
# 顶部标题色条
bar = slide.shapes.add_shape(
1, # MSO_SHAPE.RECTANGLE
Inches(0), Inches(0), Inches(10), Inches(1.1),
)
bar.fill.solid()
bar.fill.fore_color.rgb = ACCENT
bar.line.fill.background()
tf = bar.text_frame
tf.word_wrap = True
tf.margin_left = Inches(0.5)
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
run = p.add_run()
run.text = title
run.font.size = Pt(26)
run.font.bold = True
run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)
# 正文要点
body = slide.shapes.add_textbox(Inches(0.7), Inches(1.5), Inches(8.6), Inches(5.2))
btf = body.text_frame
btf.word_wrap = True
for i, bullet in enumerate(bullets):
para = btf.paragraphs[0] if i == 0 else btf.add_paragraph()
para.space_after = Pt(10)
r = para.add_run()
r.text = "• " + str(bullet)
r.font.size = Pt(18)
r.font.color.rgb = DARK
def build_presentation(payload: dict, out_path: str) -> dict:
"""从大纲 payload 构建 pptx,返回 {path, num_slides, titles} 供校验。"""
title = payload.get("title", "Untitled Presentation")
subtitle = payload.get("subtitle", "")
slides = payload.get("slides", [])
if not slides:
raise ValueError("payload.slides 为空,至少需要一页内容")
prs = Presentation()
prs.slide_width = Inches(10)
prs.slide_height = Inches(7.5)
titles = []
# 标题页
_add_title_slide(prs, title, subtitle)
titles.append(title)
# 内容页
for s in slides:
s_title = s.get("title", "")
bullets = s.get("bullets")
if bullets is None:
bullets = []
_add_content_slide(prs, s_title, bullets)
titles.append(s_title)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(out))
return {"path": str(out), "num_slides": len(list(prs.slides)), "titles": titles}
if __name__ == "__main__":
if len(sys.argv) != 3:
print("用法: python generate_pptx.py <outline.json> <output.pptx>", file=sys.stderr)
sys.exit(1)
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
result = build_presentation(payload, sys.argv[2])
print(json.dumps(result, ensure_ascii=False, indent=2))
skills/pptx/scripts/test_null_bullets.py¶
import tempfile
from pathlib import Path
from generate_pptx import build_presentation
def test_null_bullets_like_omit():
out = Path(tempfile.mkdtemp()) / "out.pptx"
result = build_presentation(
{
"title": "Demo",
"slides": [{"title": "Slide", "bullets": None}],
},
str(out),
)
assert out.exists()
assert result["num_slides"] == 2
assert "Slide" in result["titles"]
def test_missing_bullets_still_works():
out = Path(tempfile.mkdtemp()) / "out.pptx"
result = build_presentation(
{
"title": "Demo",
"slides": [{"title": "Slide"}],
},
str(out),
)
assert out.exists()
assert result["num_slides"] == 2
test_dispatch.py¶
#!/usr/bin/env python3
"""Regression tests for dispatch() in demo.py.
Bug: the agentic loop parses tool-call arguments with a JSONDecodeError
fallback to {} and then calls dispatch() with no try/except. dispatch()
used to do args["name"] / args["payload"] etc., so any malformed or
incomplete LLM tool call crashed the whole run with KeyError. Fixed to
return "[error] ..." strings that the agent can recover from.
"""
from pathlib import Path
from demo import dispatch, scan_skill_catalog
OUT = Path("/tmp/test_dispatch_out.pptx")
def test_missing_name_returns_error_not_keyerror():
catalog = scan_skill_catalog()
# {} is exactly what the JSONDecodeError fallback in run_agent produces
result = dispatch(catalog, "read_skill", {}, OUT)
assert result.startswith("[error]")
assert "name" in result
def test_missing_payload_returns_error_not_keyerror():
catalog = scan_skill_catalog()
result = dispatch(catalog, "run_skill_script",
{"name": "pptx", "script": "generate_pptx.py"}, OUT)
assert result.startswith("[error]")
assert "payload" in result
def test_unknown_tool_still_returns_error():
catalog = scan_skill_catalog()
result = dispatch(catalog, "no_such_tool", {}, OUT)
assert result.startswith("[error]")
def test_valid_read_skill_still_works():
catalog = scan_skill_catalog()
result = dispatch(catalog, "read_skill", {"name": "pptx"}, OUT)
assert not result.startswith("[error]")
assert len(result) > 0
papers/sample_outline.json¶
{
"title": "渐进式披露式 Agent Skills 对上下文效率的影响",
"subtitle": "示例作者团队 · 示例数据(对应 papers/sample_paper.md)",
"slides": [
{
"title": "目录",
"bullets": [
"研究背景与问题",
"方法概述:三层渐进式披露",
"关键结果:上下文与缓存",
"局限性与讨论",
"小结"
]
},
{
"title": "研究背景与问题",
"bullets": [
"Agent 支持的任务越多,单一系统提示词越线性膨胀",
"长提示词带来 token 成本、注意力稀释、缓存前缀失效三重代价",
"核心矛盾:让 Agent「知道自己有哪些能力」又不长期占用上下文"
]
},
{
"title": "方法概述(总体思路)",
"bullets": [
"先给 Agent 一份薄目录,需要时再加载完整 Skill",
"第一层:启动只注入各 Skill 的 name + description(数百 token)",
"第二层:任务触发时加载完整 SKILL.md 作为 tool result"
]
},
{
"title": "方法概述(关键机制)",
"bullets": [
"第三层:按需读取 reference.md、脚本源码等子文档",
"description 应写成「路由条件」而非「功能介绍」",
"捆绑可执行脚本,把知识升级为可落地的能力"
]
},
{
"title": "关键结果(效率指标)",
"bullets": [
"常驻上下文从数千 token 降到目录级的数百 token",
"工具数量恒定、前缀稳定,KV Cache 命中率显著提升"
]
},
{
"title": "关键结果(效果对比)",
"bullets": [
"需要专业 Skill 的任务上,成功率与「全量注入」基线持平",
"加入反例(Don't use when)明显提升路由准确率",
"减少不相关任务上的误触发"
]
},
{
"title": "局限性与讨论",
"bullets": [
"触发依赖模型的「元认知」,判断失误会漏加载 Skill",
"第三方 Skill 是新的提示注入面,加载前需审查其内容"
]
},
{
"title": "小结",
"bullets": [
"渐进式披露把「一次性塞满」变为「按需加载」",
"几乎不损失任务成功率,同时大幅降低常驻上下文",
"是构建可扩展 Agent 能力体系的实用范式"
]
}
]
}