Require sandbox pass before builder deploy
All checks were successful
build / build (push) Successful in 12s
All checks were successful
build / build (push) Successful in 12s
This commit is contained in:
2
a2a.yaml
2
a2a.yaml
@@ -1,5 +1,5 @@
|
|||||||
name: agent-builder
|
name: agent-builder
|
||||||
version: 0.1.5
|
version: 0.1.6
|
||||||
entrypoint: agent:AgentBuilder
|
entrypoint: agent:AgentBuilder
|
||||||
description: Generate, test, and deploy new a2a agents from natural language.
|
description: Generate, test, and deploy new a2a agents from natural language.
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
2
agent.py
2
agent.py
@@ -49,7 +49,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
"Writes the project into the user's workspace, validates it in a "
|
"Writes the project into the user's workspace, validates it in a "
|
||||||
"sandbox, then ships it via the control plane."
|
"sandbox, then ships it via the control plane."
|
||||||
)
|
)
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
|
|
||||||
config_model = BuilderConfig
|
config_model = BuilderConfig
|
||||||
auth_model = NoAuth
|
auth_model = NoAuth
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import ast
|
import ast
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -465,6 +466,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
return json.dumps({
|
return json.dumps({
|
||||||
"error": "no files in agents/" + name + "/ — write some first",
|
"error": "no files in agents/" + name + "/ — write some first",
|
||||||
})
|
})
|
||||||
|
source_hash = _source_bundle_hash(bundle_bytes)
|
||||||
b64 = base64.b64encode(bundle_bytes).decode("ascii")
|
b64 = base64.b64encode(bundle_bytes).decode("ascii")
|
||||||
script = (
|
script = (
|
||||||
"set -e\n"
|
"set -e\n"
|
||||||
@@ -650,6 +652,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
}
|
}
|
||||||
if sanitized_fields:
|
if sanitized_fields:
|
||||||
result["sanitized_agent_card_fields"] = list(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)
|
return json.dumps(result)
|
||||||
|
|
||||||
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
|
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
|
||||||
@@ -673,6 +676,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
}
|
}
|
||||||
if sanitized_fields:
|
if sanitized_fields:
|
||||||
result["sanitized_agent_card_fields"] = list(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)
|
return json.dumps(result)
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -716,11 +720,20 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
|
"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:
|
try:
|
||||||
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
builder_state = _read_builder_state(store, name)
|
|
||||||
drift = _deployment_drift_error(
|
drift = _deployment_drift_error(
|
||||||
name,
|
name,
|
||||||
latest_deploy,
|
latest_deploy,
|
||||||
@@ -733,10 +746,6 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if force:
|
if force:
|
||||||
base_head = None
|
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.
|
# Read the entrypoint from a2a.yaml so we can pass it verbatim.
|
||||||
entrypoint = _read_entrypoint(bundle_bytes) or f"agent:{_class_name(name)}"
|
entrypoint = _read_entrypoint(bundle_bytes) or f"agent:{_class_name(name)}"
|
||||||
try:
|
try:
|
||||||
@@ -1268,6 +1277,55 @@ def _write_builder_state(
|
|||||||
store.put(_builder_state_key(name), body, "application/json")
|
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]:
|
def _parse_manifest_json(raw: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw or "{}")
|
parsed = json.loads(raw or "{}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -12,6 +13,7 @@ from agent_builder.builder import _workspace_backend_with_grep_compat
|
|||||||
from agent_builder.tools import (
|
from agent_builder.tools import (
|
||||||
_BUILDER_STATE_FILE,
|
_BUILDER_STATE_FILE,
|
||||||
_BUILDER_INTERNAL_PREFIX,
|
_BUILDER_INTERNAL_PREFIX,
|
||||||
|
_WorkspaceObjectStore,
|
||||||
_parse_manifest_json,
|
_parse_manifest_json,
|
||||||
_deploy_poll_url,
|
_deploy_poll_url,
|
||||||
_deployment_drift_error,
|
_deployment_drift_error,
|
||||||
@@ -380,6 +382,18 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
for rel, content in files.items():
|
for rel, content in files.items():
|
||||||
workspace.write_bytes(prefix + rel, content.encode("utf-8"))
|
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(
|
tools = build_tools(
|
||||||
ToolContext(
|
ToolContext(
|
||||||
bucket="bucket",
|
bucket="bucket",
|
||||||
@@ -429,6 +443,41 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
self.assertEqual(dsl["entrypoint"]["module"], "agent")
|
self.assertEqual(dsl["entrypoint"]["module"], "agent")
|
||||||
self.assertEqual(dsl["entrypoint"]["class_name"], "DemoAgent")
|
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:
|
def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None:
|
||||||
workspace = _FakeWorkspace()
|
workspace = _FakeWorkspace()
|
||||||
tools = build_tools(
|
tools = build_tools(
|
||||||
|
|||||||
Reference in New Issue
Block a user