diff --git a/agent_builder/tools.py b/agent_builder/tools.py index c39130b..068f12c 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -14,6 +14,7 @@ import io import json import re import tarfile +import tempfile import time from dataclasses import dataclass from importlib import resources @@ -530,6 +531,10 @@ def build_tools(ctx: ToolContext) -> list[Any]: return json.dumps({"error": f"no files at agents/{name}/"}) # Read the entrypoint from a2a.yaml so we can pass it verbatim. entrypoint = _read_entrypoint(bundle_bytes) or f"agent:{_class_name(name)}" + try: + agent_dsl_json = _compile_agent_dsl_json(bundle_bytes) + except Exception as exc: # noqa: BLE001 + return json.dumps({"error": f"agent_dsl compile failed: {exc}"}) # NOTE: the CP from-tarball endpoint names the file field # ``source`` (see apps/control-plane/control_plane/routes/agents.py). @@ -538,6 +543,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: "name": name, "version": version, "entrypoint": entrypoint, "public": "true" if public else "false", "description": "Built by agent-builder.", + "agent_dsl": agent_dsl_json, } if isinstance(base_head, str) and base_head: data["base_head_sha"] = base_head @@ -1146,6 +1152,20 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]: return out +def _compile_agent_dsl_json(bundle: bytes) -> str: + from a2a_pack.cli.main import _compile_project_dsl + + files = _files_from_tarball(bundle) + with tempfile.TemporaryDirectory(prefix="agent-builder-dsl-") as tmp: + root = Path(tmp) + for rel, body in files.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + _cfg, dsl = _compile_project_dsl(root) + return dsl.model_dump_json() + + def _replace_workspace_files( store_or_s3: Any, bucket_or_prefix: str, diff --git a/tests/test_template_init.py b/tests/test_template_init.py index 856abf7..9b0d138 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -272,6 +272,89 @@ class TemplateInitTests(unittest.TestCase): self.assertIn("manifest_json must be a JSON object", result["error"]) + def test_cp_deploy_tarball_posts_agent_dsl(self) -> None: + workspace = _FakeWorkspace() + prefix = "agents/demo-agent/" + files = { + "agent.py": "\n".join( + [ + "from a2a_pack import A2AAgent, NoAuth, RunContext, skill", + "", + "class DemoAgent(A2AAgent[None, NoAuth]):", + ' name = "demo-agent"', + ' description = "Demo agent"', + ' version = "0.1.0"', + " auth_model = NoAuth", + "", + ' @skill(description="Echo text")', + " async def echo(self, ctx: RunContext[NoAuth], text: str) -> dict:", + " return {'ok': True, 'text': text}", + "", + ] + ), + "a2a.yaml": "\n".join( + [ + "name: demo-agent", + "version: 0.1.0", + "entrypoint: agent:DemoAgent", + "expose:", + " public: false", + "", + ] + ), + "requirements.txt": "", + } + for rel, content in files.items(): + workspace.write_bytes(prefix + rel, content.encode("utf-8")) + tools = build_tools( + ToolContext( + bucket="bucket", + settings=_settings(deploy_wait_timeout_s=0), + workspace=workspace, + cp_jwt="jwt-user", + ) + ) + deploy = _tool_by_name(tools, "cp_deploy_tarball") + + async def _not_live(*args: object, **kwargs: object): + return False, {} + + async def _no_latest(*args: object, **kwargs: object): + return None + + _FakeAsyncClient.posts = [] + with ( + patch("agent_builder.tools.httpx.AsyncClient", _FakeAsyncClient), + patch("agent_builder.tools._wait_for_live_card", _not_live), + patch("agent_builder.tools._latest_cp_deployment", _no_latest), + ): + result = json.loads( + asyncio.run( + deploy.ainvoke( + { + "name": "demo-agent", + "version": "0.1.0", + "public": False, + } + ) + ) + ) + + self.assertFalse(result["ok"]) + self.assertEqual(len(_FakeAsyncClient.posts), 1) + post = _FakeAsyncClient.posts[0] + self.assertEqual(post["url"], "http://cp.test/v1/agents/from-tarball") + self.assertEqual(post["headers"]["authorization"], "bearer jwt-user") + data = post["data"] + self.assertEqual(data["name"], "demo-agent") + self.assertEqual(data["entrypoint"], "agent:DemoAgent") + self.assertEqual(data["public"], "false") + dsl = json.loads(data["agent_dsl"]) + self.assertEqual(dsl["name"], "demo-agent") + self.assertEqual(dsl["version"], "0.1.0") + self.assertEqual(dsl["entrypoint"]["module"], "agent") + self.assertEqual(dsl["entrypoint"]["class_name"], "DemoAgent") + def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None: workspace = _FakeWorkspace() tools = build_tools( @@ -497,14 +580,23 @@ class _FakeAsyncClient: url: str, *, headers: dict[str, str], - json: dict[str, object], + json: dict[str, object] | None = None, + data: dict[str, object] | None = None, + files: dict[str, object] | None = None, ) -> _FakeResponse: - self.posts.append({"url": url, "headers": headers, "json": json}) + self.posts.append({ + "url": url, + "headers": headers, + "json": json, + "data": data, + "files": files, + }) + payload = json or data or {} return _FakeResponse( 201, { - "name": json["name"], - "version": json["version"], + "name": payload["name"], + "version": payload["version"], "status": "building", "expected_url": "https://report-meta.a2acloud.io", "deployment_id": "dpl_123",