Skip to content
Closed
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
35 changes: 35 additions & 0 deletions .github/workflows/check-playbook-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Check Playbooks documentation

on:
pull_request:
paths:
- 'content/develop/ai/context-engine/agent-playbook/**'
- 'build/agent_playbook_docs/**'
- '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.")
102 changes: 102 additions & 0 deletions build/agent_playbook_docs/examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Extract runnable examples from the documentation, their only editable source.

The guides share the quickstart connection block. MCP has its own complete client.
Validation scripts stay in a temporary directory. Downloadable walkthroughs are
generated beside their pages and checked against these same snippets.
"""

from pathlib import Path
import re

ROOT = Path(__file__).resolve().parents[2]
DOCS = ROOT / "content/develop/ai/context-engine/agent-playbook"
GUIDES = ("quickstart", "faqs", "skills", "proposals", "retrieval", "mcp", "limits")
DOWNLOADS = {
"quickstart": {"playbook-quickstart-delete"},
"skills": {"playbook-skill-withdraw", "playbook-skill-delete"},
}
TABS = re.compile(r'\{\{< multitabs id="([^"]+)"[^>]*>\}\}(.*?)\{\{< /multitabs >\}\}', re.S)
FENCE = re.compile(r"```(?:python|typescript|bash)\n(.*?)\n```", re.S)


def blocks(page):
result = []
seen = set()
for name, body in TABS.findall((DOCS / f"{page}.md").read_text()):
assert name not in seen, f"Duplicate tab group in {page}: {name}"
seen.add(name)
if name.endswith("-install") or name == "playbook-install":
continue
sections = body.split("-tab-sep-")
assert len(sections) == 3, name
snippets = []
for section in sections:
matches = FENCE.findall(section)
assert len(matches) == 1, name
snippets.append(matches[0])
result.append((name, snippets))
return result


def skill_package():
text = (DOCS / "skills.md").read_text()
return next(code for code in FENCE.findall(text) if code.startswith("mkdir -p refund-support-reply/"))


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(("py", "mts", "sh"), result))


def downloads():
result = {}
for page, omit in DOWNLOADS.items():
assert omit <= {name for name, _ in blocks(page)}, (page, omit)
for suffix, source in scripts(page, omit=omit).items():
marker = "//" if suffix == "mts" else "#"
header = f"{marker} Generated from {page}.md; edit the page, then run examples.py --refresh-downloads.\n"
result[DOCS / page / f"{page}.{suffix}"] = header + source
result[DOCS / "skills/prepare_skill.sh"] = (
"# Generated from skills.md; edit the page, then run examples.py --refresh-downloads.\n"
"set -euo pipefail\n\n" + skill_package() + "\n"
)
return result


def standalone_python(page, heading):
source = (DOCS / f"{page}.md").read_text().split(f"## {heading}\n", 1)[1].split("\n## ", 1)[0]
snippets = re.findall(r"```python\n(.*?)\n```", source, re.S)
assert len(snippets) == 1, (page, heading)
return snippets[0] + "\n"


if __name__ == "__main__":
import argparse

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output", type=Path, nargs="?")
parser.add_argument("--refresh-downloads", action="store_true",
help="Refresh the downloadable scripts from the displayed walkthroughs.")
args = parser.parse_args()
if args.refresh_downloads:
for path, source in downloads().items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source)
if args.output is None:
if not args.refresh_downloads:
parser.error("Provide an output directory or --refresh-downloads.")
raise SystemExit(0)
args.output.mkdir(parents=True, exist_ok=True)
for page in GUIDES:
for suffix, source in scripts(page).items():
(args.output / f"{page}_example.{suffix}").write_text(source)
for path, source in downloads().items():
(args.output / f"{path.stem}_download{path.suffix}").write_text(source)
(args.output / "async_search_example.py").write_text(standalone_python("quickstart", "Use Python asynchronously"))
185 changes: 185 additions & 0 deletions build/agent_playbook_docs/live_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Run displayed examples and downloads against a disposable local Playbooks stack.

Provision one playbook per case through the control plane and obtain scoped keys
from Identity Service. The stack must allow the selected embedding model.
MCP agent cases use a real model when OPENAI_API_KEY is set. Hosted MCP additionally
requires --hosted-url pointing to this stack through a public HTTPS endpoint.
"""

import argparse
import base64
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
from urllib.error import HTTPError
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
from uuid import uuid4

from redis_agent_playbook import Playbook

from examples import GUIDES, downloads, scripts, skill_package, standalone_python

HERE = Path(__file__).resolve().parent


def request(method, url, token, body=None):
data = None if body is None else json.dumps(body).encode()
req = Request(url, data=data, method=method, headers={
"Authorization": f"Bearer {token}", "Content-Type": "application/json",
})
with urlopen(req, timeout=60) as response:
data = response.read()
return json.loads(data) if data else None


def create_key(ids_url, control_token, playbook_id, actions):
for attempt in range(10):
try:
result = request("POST", ids_url + "/v1/api-keys", control_token, {
"name": "docs-validation-" + uuid4().hex[:12],
"grants": [{"product": "playbook", "resourceType": "playbook",
"resourceId": playbook_id, "actions": actions}],
})
return result["token"]
except HTTPError as error:
if error.code not in (404, 409, 503) or attempt == 9:
raise
time.sleep(1)


def seed(client, directory, kind):
if kind == "faq":
draft = client.create_faq(content={
"attributes": {"locale": "en-US", "topic": "refund"},
"payload": {"faq": {
"question": "How do I check the status of a refund?",
"answer": "Open the order in your order history and select Refund status. If no refund is listed, contact support with your order number.",
}},
})
client.publish_faq(faq_id=draft.entry.entry_id, version=draft.version.version)
else:
package = base64.b64encode((directory / "refund-support-reply.zip").read_bytes()).decode()
draft = client.create_skill(package_base64=package, content={
"attributes": {"locale": "en-US", "topic": "refund"},
})
client.publish_skill(skill_id=draft.entry.entry_id, version=draft.version.version)
return draft.entry.entry_id


def cases():
result = {}
for guide in GUIDES:
for suffix, source in scripts(guide).items():
result[f"{guide}.{suffix}"] = (guide, suffix, source)
for path, source in downloads().items():
if path.name != "prepare_skill.sh":
result[f"{path.stem}_download{path.suffix}"] = (path.stem, path.suffix[1:], source)
result["async_search.py"] = ("async_search", "py", standalone_python("quickstart", "Use Python asynchronously"))
return result


def run_case(name, case, args, log_dir):
guide, suffix, source = case
created = request("POST", args.cp_url + "/v1/playbooks", args.admin_token, {
"name": "docs-validation-" + uuid4().hex[:12],
"embedding": {"provider": args.embedding_provider, "model": args.embedding_model,
"dimensions": args.embedding_dimensions},
})
playbook_id = created["playbookId"]
author_key = reader_key = ""
try:
author_key = create_key(args.ids_url, args.control_token, playbook_id, ["read", "write", "publish", "propose"])
with tempfile.TemporaryDirectory(prefix="playbook-live-") as directory:
directory = Path(directory)
(directory / "node_modules").symlink_to(HERE / "node_modules", target_is_directory=True)
preparation = subprocess.run(["bash", "-euc", skill_package()], cwd=directory,
text=True, capture_output=True, timeout=30)
if preparation.returncode:
raise RuntimeError("Skill package preparation failed")
env = {**os.environ, "PLAYBOOK_URL": args.dp_url, "PLAYBOOK_ID": playbook_id,
"PLAYBOOK_API_KEY": author_key, "OPENAI_AGENTS_DISABLE_TRACING": "1"}
with Playbook(args.dp_url, playbook_id=playbook_id, api_key=author_key, retry_config=None) as client:
if guide in ("retrieval", "limits", "async_search"):
env["PLAYBOOK_FAQ_ID"] = seed(client, directory, "faq")
if guide in ("retrieval", "mcp"):
env["PLAYBOOK_SKILL_ID"] = seed(client, directory, "skill")
if guide in ("retrieval", "limits", "async_search", "mcp"):
reader_key = create_key(args.ids_url, args.control_token, playbook_id, ["read"])
env["PLAYBOOK_API_KEY"] = reader_key
if guide == "mcp" and suffix == "sh":
env["PLAYBOOK_URL"] = args.hosted_url.rstrip("/")
# A file named mcp.py shadows the installed MCP package.
path = directory / ("mcp_example.py" if name == "mcp.py" else name)
path.write_text(source)
command = {
"py": [sys.executable, str(path)],
"mts": [str(HERE / "node_modules/.bin/tsx"), str(path)],
"sh": ["bash", str(path)],
}[suffix]
completed = subprocess.run(command, env=env, cwd=directory, text=True,
capture_output=True, timeout=180)
output = completed.stdout + completed.stderr
for secret in (author_key, reader_key, os.environ.get("OPENAI_API_KEY")):
if secret:
output = output.replace(secret, "<redacted>")
(log_dir / (name + ".log")).write_text(output)
if completed.returncode:
raise RuntimeError(f"Exit {completed.returncode}; see {name}.log")
if name.startswith("quickstart_download"):
entries = client.list_faqs().faqs
if not any(entry.latest_version == 2 and entry.publication.version == 2 for entry in entries if entry.publication):
raise RuntimeError("Quickstart download did not retain its updated publication")
if name.startswith("skills_download"):
if not any(entry.publication for entry in client.list_skills().skills):
raise RuntimeError("Skill download did not retain a publication")
if guide == "mcp" and ("Processing" not in completed.stdout or "load_skill" not in completed.stdout or "fetch_resource" not in completed.stdout):
raise RuntimeError("Agent did not load the skill/template and preserve the refund stage")
finally:
request("DELETE", args.cp_url + "/v1/playbooks/" + playbook_id, args.admin_token)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cp-url", default="http://127.0.0.1:9200")
parser.add_argument("--dp-url", default="http://127.0.0.1:9201")
parser.add_argument("--ids-url", default="http://127.0.0.1:9202")
parser.add_argument("--admin-token", default=os.environ.get("PLAYBOOK_ADMIN_TOKEN", "local-admin"))
parser.add_argument("--control-token", default=os.environ.get("PLAYBOOK_IDS_CONTROL_TOKEN", "local-control"))
parser.add_argument("--embedding-provider", default="noop")
parser.add_argument("--embedding-model", default="playbook-local")
parser.add_argument("--embedding-dimensions", type=int, default=384)
parser.add_argument("--hosted-url", help="Public HTTPS base URL forwarding to the local data plane")
parser.add_argument("--case", action="append", choices=cases())
parser.add_argument("--log-dir", type=Path, required=True)
args = parser.parse_args()
if args.hosted_url and urlsplit(args.hosted_url).scheme != "https":
parser.error("--hosted-url must use HTTPS")
args.log_dir.mkdir(parents=True, exist_ok=True)
results = []
for name, case in cases().items():
if args.case and name not in args.case:
continue
if name == "mcp.sh" and not args.hosted_url:
results.append({"case": name, "status": "skipped", "reason": "--hosted-url is unset"})
continue
if case[0] == "mcp" and not os.environ.get("OPENAI_API_KEY"):
results.append({"case": name, "status": "skipped", "reason": "OPENAI_API_KEY is unset"})
continue
try:
run_case(name, case, args, args.log_dir)
result = {"case": name, "status": "passed"}
except Exception as error:
result = {"case": name, "status": "failed", "reason": str(error)}
results.append(result)
print(json.dumps(result), flush=True)
(args.log_dir / "results.json").write_text(json.dumps(results, indent=2) + "\n")
return int(any(result["status"] == "failed" for result in results))


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading