Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/check-playbook-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Check Playbooks documentation

on:
pull_request:
paths:
- 'content/develop/ai/context-engine/agent-playbook/**'
- 'build/agent_playbook_docs/**'
- 'local_examples/iris/playbooks/**'
- 'build/components/example.py'
- 'build/local_examples.py'
- 'build/example-test-harness/clients.tsv'
- 'layouts/partials/process-markdown-content.html'
- 'layouts/_default/apireference.html'
- '.github/workflows/check-playbook-docs.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: build/agent_playbook_docs/requirements.txt
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: build/agent_playbook_docs/package-lock.json
- run: python -m pip install -r build/agent_playbook_docs/requirements.txt
- run: npm ci --prefix build/agent_playbook_docs
- run: python build/agent_playbook_docs/check_sdk_source.py
- run: python build/agent_playbook_docs/examples.py build/agent_playbook_docs/.generated
- run: build/agent_playbook_docs/node_modules/.bin/tsc -p build/agent_playbook_docs
- run: python -m pytest build/agent_playbook_docs --rootdir . -q
2 changes: 2 additions & 0 deletions build/agent_playbook_docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.generated/
!package-lock.json
71 changes: 71 additions & 0 deletions build/agent_playbook_docs/check_sdk_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Check the docs API against the locked iris-sdks public contract.

Without arguments, verify the vendored contract without network access.
With --sdk-repo, also verify the local SDK contract, example overlay, and version.
An upstream change requires reviewing the source lock and SDK pin together.
"""

import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path

import yaml


ROOT = Path(__file__).resolve().parents[2]
LOCK = Path(__file__).with_name("source-lock.json")
SPEC = ROOT / "content/develop/ai/context-engine/agent-playbook/api-reference/openapi.json"


def wire_contract(document):
result = deepcopy(document)
result["info"].pop("title", None)
result.pop("tags", None)
for methods in result["paths"].values():
for operation in methods.values():
if not isinstance(operation, dict) or "operationId" not in operation:
continue
operation.pop("tags", None)
operation.pop("summary", None)
return result


def digest(document):
content = json.dumps(wire_contract(document), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(content.encode()).hexdigest()


def check(sdk_repo=None):
lock = json.loads(LOCK.read_text())
expected = lock["data_plane_contract_sha256"]
if digest(json.loads(SPEC.read_text())) != expected:
raise ValueError("Docs contract differs from the locked SDK input. Regenerate and review it.")
if sdk_repo is not None:
upstream = json.loads((sdk_repo / lock["sdk_contract_path"]).read_text())
if digest(upstream) != expected:
raise ValueError("SDK contract changed. Review the API, examples, version pin, and source lock together.")
overlay = sdk_repo / lock["sdk_example_overlay_path"]
if hashlib.sha256(overlay.read_bytes()).hexdigest() != lock["sdk_example_overlay_sha256"]:
raise ValueError("SDK usage examples changed. Review the docs walkthroughs before updating the source lock.")
config = yaml.safe_load((sdk_repo / "playbook-python-sdk/.speakeasy/gen.yaml").read_text())
if config["python"]["version"] != lock["sdk_generator_version"]:
raise ValueError("SDK version changed. Review the docs dependency pin.")
typescript = json.loads((sdk_repo / "playbook-ts-sdk/package.json").read_text())
if f"{typescript['name']}@{typescript['version']}" != lock["typescript_package"]:
raise ValueError("TypeScript SDK version changed. Review the docs dependency pin.")
return lock


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sdk-repo", type=Path)
args = parser.parse_args()
try:
lock = check(args.sdk_repo)
except ValueError as error:
parser.exit(1, str(error) + "\n")
print(f"Docs match the locked SDK contract ({lock['python_package']}).")
if args.sdk_repo:
print("Local SDK contract, usage overlay, and generator version also match.")
138 changes: 138 additions & 0 deletions build/agent_playbook_docs/examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Read the standard TCE sources for Playbooks validation.

Site builds use build/local_examples.py and clients-example. This adapter exposes
those same processed source files and named steps to the API example checks.
"""

from functools import lru_cache
from pathlib import Path
import re
import shutil
import sys
from tempfile import TemporaryDirectory

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "build"))
from components.example import Example
from local_examples import get_example_id_from_file, get_language_from_extension

DOCS = ROOT / "content/develop/ai/context-engine/agent-playbook"
SOURCES = ROOT / "local_examples/iris/playbooks"
GUIDES = ("quickstart", "faqs", "skills", "proposals", "retrieval", "mcp", "limits")
AGENT_GUIDES = ("openai_agent", "langgraph_agent")
PAGE_NAMES = {"faqs": "manage-faqs", "skills": "manage-skills"}
CLIENTS = {}
for row in (ROOT / "build/example-test-harness/clients.tsv").read_text().splitlines():
if row.startswith("iris-"):
fields = row.split("\t")
CLIENTS[fields[6].rsplit(".", 1)[1]] = fields[1]
assert set(CLIENTS) == {"py", "mts", "sh"}
CALLS = re.compile(r'\{\{< clients-example\s+([^>]*?)/>\}\}', re.S)
ATTRS = re.compile(r'(\w+)="([^"]*)"')
FENCE = re.compile(r"```(?:python|typescript|bash)\n(.*?)\n```", re.S)


def source_path(page, suffix, action=None):
name = f"{page}_{action}" if action else "mcp_client" if page == "mcp" else page
return SOURCES / page / f"{name}.{suffix}"


@lru_cache(maxsize=128)
def _processed(path, mtime):
with TemporaryDirectory(prefix="playbook-tce-") as directory:
target = Path(directory) / path.name
shutil.copy2(path, target)
example = Example(get_language_from_extension(path.name), str(target))
assert example.named_steps, f"No named steps in {path}"
return target.read_text(), example.named_steps


def processed(path):
return _processed(path, path.stat().st_mtime_ns)


def bundle(set_id):
result = {}
for path in SOURCES.rglob("*"):
suffix = path.suffix.lstrip(".")
if suffix in CLIENTS and get_example_id_from_file(str(path)) == set_id:
assert suffix not in result, f"Duplicate {suffix} source in {set_id}"
result[suffix] = path
assert result, f"Unknown Playbook example set: {set_id}"
return result


def step(path, name):
source, steps = processed(path)
assert name in steps, f"Missing {name} in {path}"
start, end = map(int, steps[name].split("-"))
return "\n".join(source.splitlines()[start - 1:end])


def calls(page):
source = (DOCS / f"{PAGE_NAMES.get(page, page)}.md").read_text()
return [dict(ATTRS.findall(body)) for body in CALLS.findall(source)]


def blocks(page):
result = []
seen = set()
for attrs in calls(page):
if len(attrs.get("lang_filter", "").split(",")) != 3:
continue
name = attrs["step"]
assert name not in seen, f"Duplicate step in {page}: {name}"
seen.add(name)
files = bundle(attrs["set"])
result.append((name, [step(files[suffix], name) for suffix in CLIENTS]))
return result


def skill_package():
return step(source_path("skills", "sh"), "playbook-skill-package")


def scripts(page, omit=()):
selected = [block for block in blocks(page) if block[0] not in omit]
if page not in ("quickstart", "mcp"):
setup = next(block for block in blocks("quickstart") if block[0] == "playbook-connect")
selected.insert(0, setup)
result = ["\n\n".join(block[1][i] for block in selected) + "\n" for i in range(3)]
if page == "skills":
result[2] = skill_package() + "\n\n" + result[2]
result[2] = "set -euo pipefail\n\n" + result[2]
return dict(zip(CLIENTS, result))


def full_examples():
return {source_path(page, suffix): processed(source_path(page, suffix))[0]
for page in ("quickstart", "skills") for suffix in CLIENTS}


def agent_scripts():
return {(page, suffix): processed(source_path(page, suffix))[0]
for page in AGENT_GUIDES for suffix in ("py", "mts")}


def standalone_python(page, heading):
assert (page, heading) == ("quickstart", "Use Python asynchronously")
return processed(SOURCES / "async/async_example.py")[0]


if __name__ == "__main__":
import argparse

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path)
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
for path in args.output.glob("*_download.*"):
path.unlink()
for page in GUIDES:
for suffix, source in scripts(page).items():
(args.output / f"{page}_example.{suffix}").write_text(source)
for path, source in full_examples().items():
(args.output / f"{path.stem}_copy{path.suffix}").write_text(source)
for (page, suffix), source in agent_scripts().items():
(args.output / f"{page}_example.{suffix}").write_text(source)
(args.output / "async_search_example.py").write_text(standalone_python("quickstart", "Use Python asynchronously"))
Loading
Loading