跳转至

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_filereference.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_MODELgpt-5.6-luna OpenAI 模型名
--max-turns 8 agentic loop 的最大轮数
--offline 离线演示,不调用 OpenAI(见下)

离线模式(无需 API key,可复现)

没有 OpenAI key 时,用 --offline 即可跑通同一套三层渐进式披露:它读取内置大纲 papers/sample_outline.json,走与在线完全相同的工具通道 (read_skillread_skill_filerun_skill_script)确定性地生成并校验 pptx。 唯一区别是「用哪个 Skill、大纲写什么」由预置文件给定,而非模型即时决策——因此它 适合作为可复现的教学演示与冒烟测试。

python demo.py --offline                       # 生成 output/presentation.pptx,全程无网络
python demo.py --offline -o output/deck.pptx   # 指定输出路径

捆绑脚本本身也可脱离 Agent 单独运行,直接把大纲 JSON 落地为 pptx:

python skills/pptx/scripts/generate_pptx.py papers/sample_outline.json output/deck.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 能力体系的实用范式"
      ]
    }
  ]
}