mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
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.
371 lines
13 KiB
Plaintext
371 lines
13 KiB
Plaintext
{
|
||
"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
|
||
}
|