diff --git a/a2a.yaml b/a2a.yaml index d5948ec..bf6c4ae 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -1,5 +1,5 @@ name: agent-builder -version: 0.1.5 +version: 0.1.6 entrypoint: agent:AgentBuilder description: Generate, test, and deploy new a2a agents from natural language. expose: diff --git a/agent.py b/agent.py index ac828b5..84ee846 100644 --- a/agent.py +++ b/agent.py @@ -49,7 +49,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]): "Writes the project into the user's workspace, validates it in a " "sandbox, then ships it via the control plane." ) - version = "0.1.5" + version = "0.1.6" config_model = BuilderConfig auth_model = NoAuth diff --git a/agent_builder/tools.py b/agent_builder/tools.py index 8183ebb..36fcda5 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import ast import base64 +import hashlib import io import json import logging @@ -465,6 +466,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: return json.dumps({ "error": "no files in agents/" + name + "/ — write some first", }) + source_hash = _source_bundle_hash(bundle_bytes) b64 = base64.b64encode(bundle_bytes).decode("ascii") script = ( "set -e\n" @@ -650,6 +652,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: } if sanitized_fields: result["sanitized_agent_card_fields"] = list(sanitized_fields) + _record_sandbox_result(store, name, source_hash=source_hash, result=result) return json.dumps(result) headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token) @@ -673,6 +676,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: } if sanitized_fields: result["sanitized_agent_card_fields"] = list(sanitized_fields) + _record_sandbox_result(store, name, source_hash=source_hash, result=result) return json.dumps(result) @tool @@ -716,11 +720,20 @@ def build_tools(ctx: ToolContext) -> list[Any]: "error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True", }) + builder_state = _read_builder_state(store, name) + sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix) + bundle_bytes = _tarball_workspace_dir(store, prefix) + if not bundle_bytes: + return json.dumps({"error": f"no files at agents/{name}/"}) + source_hash = _source_bundle_hash(bundle_bytes) + sandbox_error = _sandbox_gate_error(builder_state, source_hash=source_hash) + if sandbox_error is not None: + return json.dumps(sandbox_error) + try: latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name) except RuntimeError as exc: return json.dumps({"error": str(exc)}) - builder_state = _read_builder_state(store, name) drift = _deployment_drift_error( name, latest_deploy, @@ -733,10 +746,6 @@ def build_tools(ctx: ToolContext) -> list[Any]: if force: base_head = None - sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix) - bundle_bytes = _tarball_workspace_dir(store, prefix) - if not bundle_bytes: - 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: @@ -1268,6 +1277,55 @@ def _write_builder_state( store.put(_builder_state_key(name), body, "application/json") +def _source_bundle_hash(bundle: bytes) -> str: + return hashlib.sha256(bundle).hexdigest() + + +def _record_sandbox_result( + store: _ObjectStore, + name: str, + *, + source_hash: str, + result: dict[str, Any], +) -> None: + state = _read_builder_state(store, name) + state["last_sandbox"] = { + "source_hash": source_hash, + "exit_code": result.get("exit_code"), + "updated_at": int(time.time()), + } + _write_builder_state(store, name, state) + + +def _sandbox_gate_error( + builder_state: dict[str, Any], + *, + source_hash: str, +) -> dict[str, Any] | None: + sandbox = builder_state.get("last_sandbox") + if not isinstance(sandbox, dict): + return { + "ok": False, + "error": "sandbox_not_passed", + "message": "Run test_agent_in_sandbox for the current source before deploying.", + } + if sandbox.get("source_hash") != source_hash: + return { + "ok": False, + "error": "sandbox_not_passed", + "message": "Source changed after the last sandbox pass; rerun test_agent_in_sandbox.", + "last_exit_code": sandbox.get("exit_code"), + } + if sandbox.get("exit_code") != 0: + return { + "ok": False, + "error": "sandbox_not_passed", + "message": "Last sandbox run failed; fix the source and rerun test_agent_in_sandbox.", + "last_exit_code": sandbox.get("exit_code"), + } + return None + + def _parse_manifest_json(raw: str) -> dict[str, Any]: try: parsed = json.loads(raw or "{}") diff --git a/tests/test_template_init.py b/tests/test_template_init.py index e6d341a..7b5ed8f 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import hashlib import io import json import tarfile @@ -12,6 +13,7 @@ from agent_builder.builder import _workspace_backend_with_grep_compat from agent_builder.tools import ( _BUILDER_STATE_FILE, _BUILDER_INTERNAL_PREFIX, + _WorkspaceObjectStore, _parse_manifest_json, _deploy_poll_url, _deployment_drift_error, @@ -380,6 +382,18 @@ class TemplateInitTests(unittest.TestCase): } for rel, content in files.items(): workspace.write_bytes(prefix + rel, content.encode("utf-8")) + source_hash = hashlib.sha256( + _tarball_workspace_dir(_WorkspaceObjectStore(workspace), prefix) + ).hexdigest() + workspace.write_bytes( + prefix + _BUILDER_STATE_FILE, + json.dumps({ + "last_sandbox": { + "source_hash": source_hash, + "exit_code": 0, + } + }).encode("utf-8"), + ) tools = build_tools( ToolContext( bucket="bucket", @@ -429,6 +443,41 @@ class TemplateInitTests(unittest.TestCase): self.assertEqual(dsl["entrypoint"]["module"], "agent") self.assertEqual(dsl["entrypoint"]["class_name"], "DemoAgent") + def test_cp_deploy_tarball_refuses_without_current_sandbox_pass(self) -> None: + workspace = _FakeWorkspace() + prefix = "agents/demo-agent/" + workspace.write_bytes(prefix + "agent.py", b"print('ok')\n") + workspace.write_bytes( + prefix + "a2a.yaml", + b"name: demo-agent\nversion: 0.1.0\nentrypoint: agent:DemoAgent\n", + ) + workspace.write_bytes(prefix + "requirements.txt", b"") + 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") + + _FakeAsyncClient.posts = [] + result = json.loads( + asyncio.run( + deploy.ainvoke( + { + "name": "demo-agent", + "version": "0.1.0", + "public": False, + } + ) + ) + ) + + self.assertEqual(result["error"], "sandbox_not_passed") + self.assertEqual(_FakeAsyncClient.posts, []) + def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None: workspace = _FakeWorkspace() tools = build_tools(