Files
OpenShell/examples/multi-agent-notepad/notebook.ipynb
Max Dubrinsky e07b4a2a7c feat(python-sdk): add provider/policy/upload helpers and demo notebook
Extend python/openshell/ with the verbs needed to express the
multi-agent-notepad flow entirely in Python:

- ProviderClient (create/get/list/delete); credentials_from_env
  resolves env var names to mirror the CLI's --credential flag.
- policy_from_yaml() loads the CLI's YAML format into a SandboxPolicy
  proto. Lazy-imports PyYAML, added to runtime deps.
- SandboxClient.upload() gzip-tars the source and pipes the tarball
  over ExecSandbox(stdin=...) to 'tar xzf -'. No paramiko dependency.
- SandboxClient.create() gains friendly kwargs (name, image, providers,
  policy, environment, gpu, labels) alongside the existing spec= path.

architecture/python-sdk.md documents the design principle (mirror
CLI verbs, not raw protos), the public surface, and what's out of
scope (image build, gateway bootstrap, draft policy, port-forward,
RelayStream).

examples/multi-agent-notepad/notebook.ipynb is a Python equivalent of
demo.sh: env validation, policy render, provider creation, parallel
worker sandboxes via ThreadPoolExecutor, synthesis sandbox, cleanup.

13 new unit tests; all 23 sandbox tests pass; ruff/ty/markdownlint clean.
2026-05-11 20:50:34 -04:00

371 lines
13 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Multi-agent notepad — Python notebook\n",
"\n",
"Reproduces `demo.sh` end-to-end against a running OpenShell gateway, without shelling out to the `openshell` CLI. Every step uses the Python SDK at `python/openshell/`.\n",
"\n",
"**Prerequisites**\n",
"\n",
"- A running OpenShell gateway (`openshell gateway start` or already deployed).\n",
"- A local Codex sign-in (`codex login`); the notebook reads tokens from `~/.codex/auth.json`.\n",
"- `DEMO_GITHUB_OWNER`, `DEMO_GITHUB_REPO`, `DEMO_GITHUB_TOKEN` exported in the environment.\n",
"- The `openshell` Python package installed (from the repo root: `uv pip install .`).\n",
"\n",
"Optional knobs (env vars, with defaults): `DEMO_TOPIC`, `DEMO_AGENT_COUNT` (1–8), `DEMO_BRANCH`, `DEMO_RUN_ID`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"import os\n",
"import shutil\n",
"import tempfile\n",
"import time\n",
"from concurrent.futures import ThreadPoolExecutor, as_completed\n",
"from pathlib import Path\n",
"\n",
"import grpc\n",
"\n",
"from openshell import SandboxClient, SandboxError, policy_from_yaml\n",
"\n",
"# Resolve the demo directory whether the notebook runs from its own folder or repo root.\n",
"_candidates = [Path.cwd(), Path.cwd() / \"examples\" / \"multi-agent-notepad\"]\n",
"SCRIPT_DIR = next((c for c in _candidates if (c / \"runner.sh\").exists()), None)\n",
"if SCRIPT_DIR is None:\n",
" raise SystemExit(\"Could not locate runner.sh; run from examples/multi-agent-notepad/ or the repo root.\")\n",
"\n",
"POLICY_TEMPLATE = SCRIPT_DIR / \"policy.template.yaml\"\n",
"PROMPTS_DIR = SCRIPT_DIR / \"prompts\"\n",
"RUNNER_SH = SCRIPT_DIR / \"runner.sh\"\n",
"\n",
"DEMO_TOPIC = os.environ.get(\"DEMO_TOPIC\", \"How should teams evaluate sandboxed coding agents?\")\n",
"DEMO_AGENT_COUNT = int(os.environ.get(\"DEMO_AGENT_COUNT\", \"3\"))\n",
"DEMO_BRANCH = os.environ.get(\"DEMO_BRANCH\", \"main\")\n",
"DEMO_RUN_ID = os.environ.get(\"DEMO_RUN_ID\", time.strftime(\"%Y%m%d-%H%M%S\"))\n",
"\n",
"if not 1 <= DEMO_AGENT_COUNT <= 8:\n",
" raise SystemExit(\"DEMO_AGENT_COUNT must be between 1 and 8\")\n",
"\n",
"CODEX_PROVIDER = f\"codex-oauth-{DEMO_RUN_ID}\"\n",
"GITHUB_PROVIDER = f\"github-memory-{DEMO_RUN_ID}\"\n",
"\n",
"WORKER_SLICES = [\n",
" \"Adoption criteria: what makes a sandboxed coding-agent workflow trustworthy enough to try?\",\n",
" \"Operational risks: what can go wrong when many autonomous agents run at once?\",\n",
" \"Security controls: which controls make the biggest difference for credential and network safety?\",\n",
" \"Developer experience: where does the workflow need to feel simple for users to adopt it?\",\n",
" \"Measurement: what signals show whether the agents produced useful work?\",\n",
" \"Scaling: what changes when the team runs dozens of agents instead of five?\",\n",
" \"Governance: what review and approval steps should remain human-controlled?\",\n",
" \"Repository hygiene: what makes the shared markdown notepad easy to review and clean up?\",\n",
"]\n",
"\n",
"print(f\"Run id: {DEMO_RUN_ID}\")\n",
"print(f\"Topic: {DEMO_TOPIC}\")\n",
"print(f\"Agent count: {DEMO_AGENT_COUNT}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Validate the environment and connect to the gateway\n",
"\n",
"Mirrors `validate_env` in `demo.sh`: load Codex tokens, check GitHub vars, and confirm the gateway is reachable."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"github_owner = os.environ[\"DEMO_GITHUB_OWNER\"]\n",
"github_repo = os.environ[\"DEMO_GITHUB_REPO\"]\n",
"_ = os.environ[\"DEMO_GITHUB_TOKEN\"] # checked here so we fail fast\n",
"\n",
"codex_auth_path = Path.home() / \".codex\" / \"auth.json\"\n",
"if not codex_auth_path.exists():\n",
" raise SystemExit(\"missing local Codex sign-in; run: codex login\")\n",
"\n",
"tokens = json.loads(codex_auth_path.read_text())[\"tokens\"]\n",
"for key in (\"access_token\", \"refresh_token\", \"account_id\"):\n",
" if not tokens.get(key):\n",
" raise SystemExit(f\"local Codex sign-in is missing {key}; run: codex login\")\n",
"\n",
"os.environ[\"CODEX_AUTH_ACCESS_TOKEN\"] = tokens[\"access_token\"]\n",
"os.environ[\"CODEX_AUTH_REFRESH_TOKEN\"] = tokens[\"refresh_token\"]\n",
"os.environ[\"CODEX_AUTH_ACCOUNT_ID\"] = tokens[\"account_id\"]\n",
"\n",
"client = SandboxClient.from_active_cluster()\n",
"health = client.health()\n",
"print(f\"Gateway reachable. Server version: {health.version!r}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Render the policy template into a `SandboxPolicy`\n",
"\n",
"The CLI uses `sed` to substitute `__OWNER__`, `__REPO__`, and `__RUN_ID__`; we do the same in Python and feed the result through `policy_from_yaml`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"rendered = (\n",
" POLICY_TEMPLATE.read_text()\n",
" .replace(\"__OWNER__\", github_owner)\n",
" .replace(\"__REPO__\", github_repo)\n",
" .replace(\"__RUN_ID__\", DEMO_RUN_ID)\n",
")\n",
"\n",
"policy = policy_from_yaml(rendered)\n",
"print(f\"Loaded policy v{policy.version} with rule sets: {sorted(policy.network_policies.keys())}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Create provider records\n",
"\n",
"Provider records hold credentials by env-var name. The OpenShell sandbox supervisor injects a placeholder for each credential and the proxy substitutes the real value at the network boundary, so the agent never sees raw tokens."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def safe_provider_delete(name: str) -> None:\n",
" try:\n",
" client.providers.delete(name)\n",
" except grpc.RpcError as exc:\n",
" if exc.code() != grpc.StatusCode.NOT_FOUND:\n",
" raise\n",
"\n",
"for stale in (CODEX_PROVIDER, GITHUB_PROVIDER):\n",
" safe_provider_delete(stale)\n",
"\n",
"client.providers.create(\n",
" name=CODEX_PROVIDER,\n",
" provider_type=\"generic\",\n",
" credentials_from_env=[\n",
" \"CODEX_AUTH_ACCESS_TOKEN\",\n",
" \"CODEX_AUTH_REFRESH_TOKEN\",\n",
" \"CODEX_AUTH_ACCOUNT_ID\",\n",
" ],\n",
")\n",
"client.providers.create(\n",
" name=GITHUB_PROVIDER,\n",
" provider_type=\"generic\",\n",
" credentials_from_env=[\"DEMO_GITHUB_TOKEN\"],\n",
")\n",
"print(f\"Created providers: {CODEX_PROVIDER}, {GITHUB_PROVIDER}\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Stage the payload directory\n",
"\n",
"We mirror `demo.sh`'s layout: `payload/demo-runner.sh` and `payload/prompts/*.md`. The whole `payload/` directory is uploaded into `/sandbox` inside each worker, so the entrypoint runs `bash /sandbox/payload/demo-runner.sh ...`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"work_dir = Path(tempfile.mkdtemp(prefix=\"openshell-notebook-\"))\n",
"payload_dir = work_dir / \"payload\"\n",
"(payload_dir / \"prompts\").mkdir(parents=True)\n",
"\n",
"shutil.copy(RUNNER_SH, payload_dir / \"demo-runner.sh\")\n",
"(payload_dir / \"demo-runner.sh\").chmod(0o755)\n",
"for prompt in PROMPTS_DIR.glob(\"*.md\"):\n",
" shutil.copy(prompt, payload_dir / \"prompts\" / prompt.name)\n",
"\n",
"print(f\"Payload staged at {payload_dir}\")\n",
"print(\" files:\", sorted(p.relative_to(payload_dir).as_posix() for p in payload_dir.rglob(\"*\") if p.is_file()))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Launch worker sandboxes in parallel\n",
"\n",
"Each worker gets its own isolated sandbox. The bash demo uses `&` + `wait`; we use `ThreadPoolExecutor` for the same fan-out — gRPC sync calls release the GIL, so each thread waits independently."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def run_worker(index: int) -> str:\n",
" name = f\"codex-gh-{DEMO_RUN_ID}-a{index}\"\n",
" slice_text = WORKER_SLICES[(index - 1) % len(WORKER_SLICES)]\n",
" sandbox = client.create(\n",
" name=name,\n",
" image=\"base\",\n",
" providers=[CODEX_PROVIDER, GITHUB_PROVIDER],\n",
" policy=policy,\n",
" )\n",
" client.wait_ready(name)\n",
" client.upload(sandbox.id, payload_dir, \"/sandbox\")\n",
" result = client.exec(\n",
" sandbox.id,\n",
" [\n",
" \"bash\",\n",
" \"/sandbox/payload/demo-runner.sh\",\n",
" \"worker\",\n",
" github_owner,\n",
" github_repo,\n",
" DEMO_BRANCH,\n",
" DEMO_RUN_ID,\n",
" str(index),\n",
" str(DEMO_AGENT_COUNT),\n",
" DEMO_TOPIC,\n",
" slice_text,\n",
" ],\n",
" )\n",
" if result.exit_code != 0:\n",
" raise RuntimeError(\n",
" f\"worker {index} failed (exit {result.exit_code}); stderr tail:\\n{result.stderr[-1000:]}\"\n",
" )\n",
" return name\n",
"\n",
"\n",
"worker_names: list[str] = []\n",
"with ThreadPoolExecutor(max_workers=DEMO_AGENT_COUNT) as pool:\n",
" futures = {pool.submit(run_worker, i): i for i in range(1, DEMO_AGENT_COUNT + 1)}\n",
" for fut in as_completed(futures):\n",
" idx = futures[fut]\n",
" try:\n",
" worker_names.append(fut.result())\n",
" print(f\" worker {idx} complete\")\n",
" except Exception as exc:\n",
" print(f\" worker {idx} FAILED: {exc}\")\n",
"\n",
"if len(worker_names) != DEMO_AGENT_COUNT:\n",
" raise SystemExit(f\"only {len(worker_names)}/{DEMO_AGENT_COUNT} workers completed\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Run the synthesis sandbox\n",
"\n",
"Reads every worker's note from GitHub and writes the consolidated summary."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"synth_name = f\"codex-gh-{DEMO_RUN_ID}-summary\"\n",
"synth = client.create(\n",
" name=synth_name,\n",
" image=\"base\",\n",
" providers=[CODEX_PROVIDER, GITHUB_PROVIDER],\n",
" policy=policy,\n",
")\n",
"client.wait_ready(synth_name)\n",
"client.upload(synth.id, payload_dir, \"/sandbox\")\n",
"result = client.exec(\n",
" synth.id,\n",
" [\n",
" \"bash\",\n",
" \"/sandbox/payload/demo-runner.sh\",\n",
" \"synthesis\",\n",
" github_owner,\n",
" github_repo,\n",
" DEMO_BRANCH,\n",
" DEMO_RUN_ID,\n",
" \"0\",\n",
" str(DEMO_AGENT_COUNT),\n",
" DEMO_TOPIC,\n",
" ],\n",
")\n",
"if result.exit_code != 0:\n",
" raise SandboxError(f\"synthesis failed (exit {result.exit_code}); stderr:\\n{result.stderr[-1500:]}\")\n",
"print(\"synthesis complete\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Print URLs and clean up\n",
"\n",
"Deletes worker + synthesis sandboxes and the demo's provider records, mirroring the `cleanup` trap in `demo.sh`."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def safe_sandbox_delete(name: str) -> None:\n",
" try:\n",
" client.delete(name)\n",
" except grpc.RpcError as exc:\n",
" if exc.code() != grpc.StatusCode.NOT_FOUND:\n",
" print(f\" delete {name} failed: {exc.code()}\")\n",
"\n",
"for i in range(1, DEMO_AGENT_COUNT + 1):\n",
" safe_sandbox_delete(f\"codex-gh-{DEMO_RUN_ID}-a{i}\")\n",
"safe_sandbox_delete(synth_name)\n",
"\n",
"for prov in (CODEX_PROVIDER, GITHUB_PROVIDER):\n",
" safe_provider_delete(prov)\n",
"\n",
"shutil.rmtree(work_dir, ignore_errors=True)\n",
"client.close()\n",
"\n",
"base_url = f\"https://github.com/{github_owner}/{github_repo}/tree/{DEMO_BRANCH}/runs/{DEMO_RUN_ID}\"\n",
"print(f\"\\nDone. Shared agent notepad: {base_url}\")\n",
"print(f\" summary: {base_url}/summary.md\")\n",
"for i in range(1, DEMO_AGENT_COUNT + 1):\n",
" print(f\" agent {i}: {base_url}/notes/agent-{i}.md\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}