From 0b9e594bec1a457e2540dd1ccb873f55734dd83c Mon Sep 17 00:00:00 2001 From: robert Date: Sat, 23 May 2026 15:01:14 -0400 Subject: [PATCH] Teach agent builder packed frontends --- README.md | 6 +- agent_builder/builder.py | 40 ++++++- agent_builder/config.py | 10 +- .../skills/a2apack-agent-authoring/SKILL.md | 26 +++++ .../skills/agent-quality-review/SKILL.md | 24 ++++ agent_builder/tools.py | 108 +++++++++++++++--- deploy/20-deployment.yaml | 4 + requirements.txt | 2 +- tests/test_builder_prompt.py | 21 +++- tests/test_template_init.py | 46 ++++++++ 10 files changed, 265 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 777048a..23d788e 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,13 @@ The single `build(name, prompt)` skill: - `write_agent_skill(...)` — creates valid DeepAgents `skills//SKILL.md` bundles for generated agents, so rich behavior lives in progressive-disclosure skills instead of fake tools + - `init_agent_template(name, description, frontend="react"|"static"|"none")` + — starts from the current `a2a-pack` scaffold and can include packed + frontend templates under `frontend/` for app-like agents - `test_agent_in_sandbox(name)` — tarballs the workspace project, spins a microVM with the public `a2a-pack` wheel installed, runs - `a2a card` to verify the scaffold is valid + `a2a card` to verify the scaffold is valid, and prints frontend metadata + when a packed app is declared - `cp_deploy_tarball(name, version, public)` — POSTs the tarball to `/v1/agents/from-tarball` on the user's behalf using their forwarded CP JWT diff --git a/agent_builder/builder.py b/agent_builder/builder.py index b2749bc..c2be7dd 100644 --- a/agent_builder/builder.py +++ b/agent_builder/builder.py @@ -66,6 +66,31 @@ expose: public: true ``` +If the agent should ship with a browser UI, scaffold a packed frontend instead +of bolting on an external app. Call ``init_agent_template(..., frontend="react")`` +for a Vite/React app that can read the live Agent Card, signed-in session, and +skill schemas from the deployed agent. Use ``frontend="static"`` only for a +simple no-build HTML app. The manifest block is: + +```yaml +frontend: + path: frontend + build: npm run build + dist: dist + mount: /app + auth: inherit +``` + +Packed frontends are served from the same agent at ``/app``. They receive +generated runtime metadata from ``/app/config.json``, can call skills through +the generated endpoints, and can use the browser helper at +``/app/a2a-client.js``. Do not put platform secrets, provider keys, or private +runtime details in frontend source; the frontend should use the agent's public +skill schemas and inherited platform auth. For local development, run the +agent and then run the React dev server with ``A2A_DEV_AGENT_URL`` pointed at +the local agent. See ``https://docs.a2acloud.io/concepts/packed-frontends`` for +the deployment contract. + If the agent needs system binaries the Python-only base image doesn't ship (ffmpeg, imagemagick, poppler-utils, sqlite3, etc.), declare them under ``runtime.apt_packages`` and the platform stamps them into @@ -128,14 +153,18 @@ when you deploy through the control plane). Your tools: - - init_agent_template(name, description) + - init_agent_template(name, description, frontend="none") — initialize agents// from the installed a2a-pack `a2a init` template. + Use frontend="react" for a packed app, + frontend="static" for simple bundled HTML, + or frontend="none" for a headless agent. Use this FIRST for a new project, then edit the generated files. - list_agent_files(name) — see what's already at agents// - write_agent_file(name, path, content) — save agent.py / a2a.yaml / requirements.txt + / frontend/* files - read_agent_file(name, path) — re-read a file (for iteration) - write_agent_skill(name, skill_name, description, instructions, supporting_files_json="{}") @@ -146,7 +175,8 @@ Your tools: in agent.py. - test_agent_in_sandbox(name) — pip install + ``a2a card`` round-trip; check exit_code == 0 and the card JSON - looks right + looks right. If a frontend is declared, + inspect the printed ``a2a frontend info``. - cp_deploy_tarball(name, version="0.1.0", public=True, force=False) — ship it to the platform; returns the public URL. It refuses stale MinIO @@ -187,6 +217,12 @@ Discipline: generated files instead of inventing boilerplate from memory. - Ensure all three core files (agent.py, a2a.yaml, requirements.txt) exist before testing — partial scaffolds break ``a2a card``. + - When the user asks for a usable app, dashboard, charting UI, workflow UI, + or demo surface, prefer ``frontend="react"`` and customize + ``frontend/src/App.jsx`` plus ``frontend/src/a2a.js`` around the public + ``@skill`` schemas. Keep frontend calls routed through + ``/app/config.json`` or the generated client/endpoints instead of + hard-coding deployment URLs. - For non-trivial agents, create one or more DeepAgents skills under ``skills//`` and wire ``create_deep_agent(..., skills=[...])``. Do not replace LLM reasoning with fake deterministic tools that return diff --git a/agent_builder/config.py b/agent_builder/config.py index c124385..dcb2239 100644 --- a/agent_builder/config.py +++ b/agent_builder/config.py @@ -23,10 +23,14 @@ class Settings: def load_settings() -> Settings: return Settings( - sandbox_url=os.environ.get( - "SANDBOX_URL", "http://sandbox.sandbox.svc.cluster.local:8000" + sandbox_url=( + os.environ.get("A2A_SANDBOX_URL") + or os.environ.get("SANDBOX_URL", "http://sandbox.sandbox.svc.cluster.local:8000") + ), + sandbox_timeout_s=int( + os.environ.get("A2A_SANDBOX_TIMEOUT_S") + or os.environ.get("SANDBOX_TIMEOUT_S", "1200") ), - sandbox_timeout_s=int(os.environ.get("SANDBOX_TIMEOUT_S", "1200")), sandbox_token=os.environ.get("A2A_SANDBOX_TOKEN") or os.environ.get("SANDBOX_TOKEN"), deploy_wait_timeout_s=int(os.environ.get("DEPLOY_WAIT_TIMEOUT_S", "900")), litellm_url=os.environ.get( diff --git a/agent_builder/skills/a2apack-agent-authoring/SKILL.md b/agent_builder/skills/a2apack-agent-authoring/SKILL.md index 54866db..e0c8544 100644 --- a/agent_builder/skills/a2apack-agent-authoring/SKILL.md +++ b/agent_builder/skills/a2apack-agent-authoring/SKILL.md @@ -154,6 +154,32 @@ runtime: If it calls external hosts, declare `egress` on the class. If it needs secrets, use `required_secrets` and `ctx.secret("NAME")`; do not hard-code credentials. +## Packed Frontends + +If the user asks for a usable app, dashboard, workflow UI, or customer-facing +demo surface, scaffold a packed frontend instead of a separate web app. Use +`init_agent_template(name, description, frontend="react")` for the React/Vite +starter or `frontend="static"` for a no-build HTML bundle. + +The deployment manifest should keep the browser app under the agent source: + +```yaml +frontend: + path: frontend + build: npm run build + dist: dist + mount: /app + auth: inherit +``` + +At runtime the platform serves the app from `/app`, exposes generated config at +`/app/config.json`, exposes a helper client at `/app/a2a-client.js`, and uses +the agent's public `@skill` schemas for callable operations. Frontend code +should load the generated config/client, call the public skills, and rely on +inherited platform auth. Do not put control-plane tokens, provider keys, +secrets, internal stack details, or private execution assumptions in frontend +source. + ## A2A Skill vs DeepAgents Skill Use A2A `@skill` for caller-visible operations and billing boundaries. Use diff --git a/agent_builder/skills/agent-quality-review/SKILL.md b/agent_builder/skills/agent-quality-review/SKILL.md index baf9b80..6bc2251 100644 --- a/agent_builder/skills/agent-quality-review/SKILL.md +++ b/agent_builder/skills/agent-quality-review/SKILL.md @@ -16,6 +16,7 @@ Every generated project needs: - `requirements.txt` - Optional `skills//SKILL.md` bundles for nontrivial DeepAgents behavior +- Optional `frontend/` source when the agent ships a packed app Run `list_agent_files` and read the files that matter. If the project was deployed before, use `sync_agent_workspace_from_repo` before editing when the @@ -66,11 +67,34 @@ runtime: Use `runtime.apt_packages` only for Debian system binaries. Python packages belong in `requirements.txt`. +If a packed frontend is declared, verify the manifest and files line up: + +```yaml +frontend: + path: frontend + build: npm run build + dist: dist + mount: /app + auth: inherit +``` + +For React/Vite frontends, expect `frontend/package.json`, +`frontend/vite.config.js`, `frontend/src/App.jsx`, and `frontend/src/a2a.js`. +For static frontends, expect `frontend/dist/index.html`. The browser code must +not contain platform secrets, provider keys, hard-coded deployment URLs, or +private stack details. It should load `/app/config.json`, use +`/app/a2a-client.js` or generated config endpoints, and call only the public +`@skill` schemas. + ## Sandbox And Live Card Always run `test_agent_in_sandbox(name)` before deploy. Treat nonzero exit as source failure, then read stderr, edit, and rerun. +When a frontend is present, inspect the sandbox output's `a2a frontend info`. +For React apps, make sure `frontend/package.json` defines a build script and +the deployed build process can produce `frontend/dist/index.html`. + After `cp_deploy_tarball`, compare `live_skills[].input_schema` with the actual public `@skill` signatures. If a live schema is missing an argument or shows an old shape, refresh or redeploy until the live Agent Card matches the diff --git a/agent_builder/tools.py b/agent_builder/tools.py index 6564d6c..7e6d8f7 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -28,6 +28,9 @@ if TYPE_CHECKING: from .config import Settings +A2A_PACK_MIN_VERSION = "0.1.19" + + @dataclass(frozen=True) class ToolContext: bucket: str @@ -132,17 +135,25 @@ def build_tools(ctx: ToolContext) -> list[Any]: def init_agent_template( name: str, description: str = "A new A2A agent", + frontend: str = "none", ) -> str: """Initialize ``agents//`` from the installed a2a-pack template. Use this FIRST for a new project. It writes the same baseline files as ``a2a init`` for the SDK version installed in this runtime: - ``agent.py``, ``a2a.yaml``, and ``requirements.txt``. After this, - edit those files with ``read_agent_file`` + ``write_agent_file`` to - implement the user's requested behavior. + ``agent.py``, ``a2a.yaml``, and ``requirements.txt``. Set + ``frontend`` to ``"react"`` for a Vite packed app, ``"static"`` for a + minimal bundled HTML app, or ``"none"`` for a headless agent. After + this, edit those files with ``read_agent_file`` + ``write_agent_file`` + to implement the user's requested behavior. """ try: - files = _render_a2a_init_template(name, description=description) + frontend_kind = _normalize_frontend_kind(frontend) + files = _render_a2a_init_template( + name, + description=description, + frontend=frontend_kind, + ) prefix = _agent_prefix(name) except (OSError, RuntimeError, ValueError) as exc: return json.dumps({"error": str(exc)}) @@ -156,7 +167,17 @@ def build_tools(ctx: ToolContext) -> list[Any]: ContentType="text/plain; charset=utf-8", ) written.append({"path": key, "size": len(content)}) - return json.dumps({"ok": True, "agent": name, "files": written}) + return json.dumps({ + "ok": True, + "agent": name, + "frontend": frontend_kind, + "files": written, + "docs": ( + "https://docs.a2acloud.io/concepts/packed-frontends" + if frontend_kind != "none" + else "https://docs.a2acloud.io/quickstart" + ), + }) @tool def list_agent_files(name: str) -> str: @@ -186,7 +207,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: """Write a file under ``agents//`` in the user's workspace. Use this for ``agent.py``, ``a2a.yaml``, ``requirements.txt``, and - any auxiliary modules the agent needs. + any auxiliary modules or packed frontend files the agent needs. """ try: prefix = _agent_prefix(name) @@ -277,7 +298,8 @@ def build_tools(ctx: ToolContext) -> list[Any]: async def test_agent_in_sandbox(name: str) -> str: """Spin a microVM, ``pip install a2a-pack`` + the agent's own requirements.txt, then run ``a2a card`` to verify the scaffold - produces a valid Card. Returns stdout/stderr. + produces a valid Card. When a packed frontend is declared, it also + prints ``a2a frontend info`` metadata. Returns stdout/stderr. """ try: prefix = _agent_prefix(name) @@ -294,7 +316,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: b64 = base64.b64encode(bundle_bytes).decode("ascii") script = ( "set -e\n" - "pip install --quiet a2a-pack >/dev/null\n" + f"pip install --quiet 'a2a-pack>={A2A_PACK_MIN_VERSION}' >/dev/null\n" "mkdir -p /tmp/agent\n" f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n" "cd /tmp/agent\n" @@ -304,6 +326,10 @@ def build_tools(ctx: ToolContext) -> list[Any]: "fi\n" "echo '--- card ---'\n" "a2a card --project .\n" + "if grep -q '^frontend:' a2a.yaml 2>/dev/null; then\n" + " echo '--- frontend ---'\n" + " a2a frontend info --project . || true\n" + "fi\n" ) headers = ( {"authorization": f"Bearer {settings.sandbox_token}"} @@ -911,12 +937,64 @@ def _class_name(slug: str) -> str: return "".join(p.capitalize() for p in re.split(r"[-_]+", slug) if p) or "MyAgent" +_FRONTEND_TEMPLATE_FILES: dict[str, dict[str, str]] = { + "static": { + "frontend/dist/index.html": "frontend/static-index.html.tmpl", + }, + "react": { + "frontend/package.json": "frontend/react-package.json.tmpl", + "frontend/index.html": "frontend/react-index.html.tmpl", + "frontend/vite.config.js": "frontend/react-vite.config.js.tmpl", + "frontend/src/main.jsx": "frontend/react-main.jsx.tmpl", + "frontend/src/App.jsx": "frontend/react-app.jsx.tmpl", + "frontend/src/a2a.js": "frontend/react-a2a.js.tmpl", + "frontend/src/style.css": "frontend/react-style.css.tmpl", + }, +} + + +def _normalize_frontend_kind(frontend: str | None) -> str: + kind = (frontend or "none").strip().lower() + if kind in {"", "no", "off", "false"}: + kind = "none" + if kind not in {"none", "static", "react"}: + raise ValueError("frontend must be one of: none, static, react") + return kind + + +def _frontend_block(frontend: str) -> str: + kind = _normalize_frontend_kind(frontend) + if kind == "none": + return "" + build = "\n build: npm run build" if kind == "react" else "" + return ( + "frontend:\n" + " path: frontend" + f"{build}\n" + " dist: dist\n" + " mount: /app\n" + " auth: inherit" + ) + + +def _render_frontend_files(frontend: str, *, name: str) -> dict[str, str]: + kind = _normalize_frontend_kind(frontend) + if kind == "none": + return {} + return { + path: _render_sdk_template(template, name=name) + for path, template in _FRONTEND_TEMPLATE_FILES[kind].items() + } + + def _render_a2a_init_template( name: str, *, description: str = "A new A2A agent", + frontend: str = "none", ) -> dict[str, str]: _validate_name(name) + frontend_kind = _normalize_frontend_kind(frontend) class_name = _class_name(name) files = { "agent.py": _render_sdk_template( @@ -929,13 +1007,16 @@ def _render_a2a_init_template( "a2a.yaml.tmpl", name=name, class_name=class_name, + frontend_block=_frontend_block(frontend_kind), ), "requirements.txt": _render_sdk_template("requirements.txt.tmpl"), } + files.update(_render_frontend_files(frontend_kind, name=name)) return files def _render_sdk_template(template: str, /, **vars: str) -> str: + parts = template.split("/") local_templates = ( Path(__file__).resolve().parents[2] / "a2a" @@ -944,13 +1025,12 @@ def _render_sdk_template(template: str, /, **vars: str) -> str: / "templates" ) if local_templates.exists(): - text = local_templates.joinpath(template).read_text(encoding="utf-8") + text = local_templates.joinpath(*parts).read_text(encoding="utf-8") else: - text = ( - resources.files("a2a_pack.cli.templates") - .joinpath(template) - .read_text(encoding="utf-8") - ) + resource = resources.files("a2a_pack.cli.templates") + for part in parts: + resource = resource.joinpath(part) + text = resource.read_text(encoding="utf-8") for key, value in vars.items(): text = text.replace("{{ " + key + " }}", value) return text diff --git a/deploy/20-deployment.yaml b/deploy/20-deployment.yaml index 89b2306..096e105 100644 --- a/deploy/20-deployment.yaml +++ b/deploy/20-deployment.yaml @@ -32,8 +32,12 @@ spec: value: registry.a2acloud.io/agents/agent-builder:9b70bec52ed3a734ce4c53ed1ec5016e8591de32 - name: SANDBOX_URL value: http://sandbox.sandbox.svc.cluster.local:8000 + - name: A2A_SANDBOX_URL + value: http://sandbox.sandbox.svc.cluster.local:8000 - name: SANDBOX_TIMEOUT_S value: "1200" + - name: A2A_SANDBOX_TIMEOUT_S + value: "1200" - name: DEPLOY_WAIT_TIMEOUT_S value: "900" - name: A2A_PLATFORM_SECRET diff --git a/requirements.txt b/requirements.txt index 419dc5d..9471aec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -a2a-pack>=0.1.18 +a2a-pack>=0.1.19 httpx>=0.27 boto3>=1.34 deepagents>=0.5.0 diff --git a/tests/test_builder_prompt.py b/tests/test_builder_prompt.py index 4f7c56e..34ba36d 100644 --- a/tests/test_builder_prompt.py +++ b/tests/test_builder_prompt.py @@ -9,6 +9,7 @@ from agent_builder.builder import ( _builder_skill_files, _with_builder_skills, ) +from agent_builder.tools import A2A_PACK_MIN_VERSION from deepagents.backends import StateBackend @@ -28,9 +29,18 @@ class BuilderPromptTests(unittest.TestCase): "agent_builder/config.py" ).read_text() - self.assertIn('sandbox_timeout_s=int(os.environ.get("SANDBOX_TIMEOUT_S", "1200"))', config_source) + self.assertIn('os.environ.get("A2A_SANDBOX_TIMEOUT_S")', config_source) + self.assertIn('os.environ.get("SANDBOX_TIMEOUT_S", "1200")', config_source) self.assertIn('deploy_wait_timeout_s=int(os.environ.get("DEPLOY_WAIT_TIMEOUT_S", "900"))', config_source) + def test_builder_requires_frontend_capable_a2a_pack(self) -> None: + root = Path(__file__).resolve().parents[1] + requirements = root.joinpath("requirements.txt").read_text() + tools_source = root.joinpath("agent_builder/tools.py").read_text() + + self.assertIn(f"a2a-pack>={A2A_PACK_MIN_VERSION}", requirements) + self.assertIn("a2a-pack>={A2A_PACK_MIN_VERSION}", tools_source) + def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None: self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT) self.assertIn("resources = Resources", SYSTEM_PROMPT) @@ -50,6 +60,15 @@ class BuilderPromptTests(unittest.TestCase): self.assertIn("outputs/rootfs-captures/...", SYSTEM_PROMPT) self.assertIn("not workspace-mounted or rootfs-captured", SYSTEM_PROMPT) + def test_prompt_knows_packed_frontend_contract(self) -> None: + self.assertIn('init_agent_template(..., frontend="react")', SYSTEM_PROMPT) + self.assertIn("frontend:", SYSTEM_PROMPT) + self.assertIn("mount: /app", SYSTEM_PROMPT) + self.assertIn("/app/config.json", SYSTEM_PROMPT) + self.assertIn("/app/a2a-client.js", SYSTEM_PROMPT) + self.assertIn("https://docs.a2acloud.io/concepts/packed-frontends", SYSTEM_PROMPT) + self.assertIn("frontend/src/App.jsx", SYSTEM_PROMPT) + def test_prompt_and_backend_load_packaged_builder_skills(self) -> None: expected = { "deepagent-agent-design", diff --git a/tests/test_template_init.py b/tests/test_template_init.py index 7c07688..574dbfa 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -41,12 +41,58 @@ class TemplateInitTests(unittest.TestCase): self.assertIn("create_deep_agent", files["agent.py"]) self.assertIn("name: research-agent", files["a2a.yaml"]) self.assertIn("entrypoint: agent:ResearchAgent", files["a2a.yaml"]) + self.assertNotIn("{{ frontend_block }}", files["a2a.yaml"]) + self.assertNotIn("frontend:", files["a2a.yaml"]) self.assertIn("deepagents>=0.5.0", files["requirements.txt"]) + def test_render_a2a_init_template_can_include_react_frontend(self) -> None: + files = _render_a2a_init_template( + "chart-agent", + description="Chart helper", + frontend="react", + ) + + self.assertTrue( + { + "agent.py", + "a2a.yaml", + "requirements.txt", + "frontend/package.json", + "frontend/index.html", + "frontend/vite.config.js", + "frontend/src/main.jsx", + "frontend/src/App.jsx", + "frontend/src/a2a.js", + "frontend/src/style.css", + }.issubset(files) + ) + self.assertIn("frontend:", files["a2a.yaml"]) + self.assertIn("build: npm run build", files["a2a.yaml"]) + self.assertIn("mount: /app", files["a2a.yaml"]) + self.assertIn("auth: inherit", files["a2a.yaml"]) + self.assertIn("Skill runner", files["frontend/src/App.jsx"]) + self.assertIn("callSkill", files["frontend/src/App.jsx"]) + self.assertIn("CONFIG_URL", files["frontend/src/a2a.js"]) + self.assertIn('"/invoke": agent', files["frontend/vite.config.js"]) + + def test_render_a2a_init_template_can_include_static_frontend(self) -> None: + files = _render_a2a_init_template("status-agent", frontend="static") + + self.assertIn("frontend/dist/index.html", files) + self.assertIn("frontend:", files["a2a.yaml"]) + self.assertIn("dist: dist", files["a2a.yaml"]) + self.assertIn("mount: /app", files["a2a.yaml"]) + self.assertNotIn("build: npm run build", files["a2a.yaml"]) + self.assertIn("./a2a-client.js", files["frontend/dist/index.html"]) + def test_render_a2a_init_template_rejects_invalid_names(self) -> None: with self.assertRaises(ValueError): _render_a2a_init_template("Bad Name") + def test_render_a2a_init_template_rejects_invalid_frontend(self) -> None: + with self.assertRaises(ValueError): + _render_a2a_init_template("demo-agent", frontend="vue") + def test_agent_projects_can_diverge_from_template(self) -> None: files = _render_a2a_init_template("regex-explainer") files["agent.py"] = "from a2a_pack import A2AAgent\n"