Add explicit source repo deploy tool
All checks were successful
build / build (push) Successful in 50s

This commit is contained in:
robert
2026-06-08 20:39:00 -03:00
parent 03f51fe27b
commit c5fdaba848
4 changed files with 132 additions and 3 deletions

View File

@@ -35,6 +35,9 @@ The single `build(name, prompt)` skill:
- `cp_deploy_tarball(name, version, public)` — POSTs the tarball - `cp_deploy_tarball(name, version, public)` — POSTs the tarball
to `/v1/agents/from-tarball` on the user's behalf using their to `/v1/agents/from-tarball` on the user's behalf using their
forwarded CP JWT forwarded CP JWT
- `cp_deploy_source_repo(name)` — POSTs the current managed source repo
to `/v1/agents/{name}/source/deploy` when source edits already live in
Gitea
2. Streams the graph's tool calls back to the dashboard as 2. Streams the graph's tool calls back to the dashboard as
`agent_progress` events (so the user watches the build happen in `agent_progress` events (so the user watches the build happen in
real time, not in silence). real time, not in silence).

View File

@@ -242,6 +242,11 @@ Your tools:
elsewhere; use force=True only after elsewhere; use force=True only after
the user explicitly accepts replacing the user explicitly accepts replacing
the current repo source. the current repo source.
- cp_deploy_source_repo(name) — deploy the current managed Gitea source
repo without uploading MinIO files. Use
only when source changes already live in
the platform repo; normal builder
workspaces should use cp_deploy_tarball.
- cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0", - cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0",
public=True, refresh_existing=False) public=True, refresh_existing=False)
— create/deploy a manifest-backed — create/deploy a manifest-backed
@@ -308,6 +313,8 @@ Discipline:
scaffold. scaffold.
- When deploying, return the URL the platform gave back to the user so - When deploying, return the URL the platform gave back to the user so
they can curl it / share it. they can curl it / share it.
- Source repo edits do not auto-deploy. If you edited the managed repo
directly, call cp_deploy_source_repo after the coherent edit set is done.
- If cp_deploy_tarball returns workspace_drift or workspace_untracked, - If cp_deploy_tarball returns workspace_drift or workspace_untracked,
use sync_agent_workspace_from_repo when the user wants the builder to use sync_agent_workspace_from_repo when the user wants the builder to
continue from the current repo. Do not use force=True unless the user continue from the current repo. Do not use force=True unless the user

View File

@@ -3,6 +3,7 @@
- workspace file CRUD through the caller's scoped workspace grant - workspace file CRUD through the caller's scoped workspace grant
- sandbox python (for `a2a card` / `a2a validate` round-trips) - sandbox python (for `a2a card` / `a2a validate` round-trips)
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf) - cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
- cp_deploy_source_repo (call /v1/agents/{name}/source/deploy)
- cp_compose_meta_agent (call /v1/agents/compose with a manifest) - cp_compose_meta_agent (call /v1/agents/compose with a manifest)
""" """
from __future__ import annotations from __future__ import annotations
@@ -661,8 +662,10 @@ def build_tools(ctx: ToolContext) -> list[Any]:
f"{settings.sandbox_url}/v1/run_shell", f"{settings.sandbox_url}/v1/run_shell",
headers=headers, headers=headers,
json={ json={
"bucket": bucket, "script": script, "bucket": bucket,
"image": settings.image, "memory_mib": 512, "script": script,
"image": settings.image,
"memory_mib": 512,
"timeout_seconds": settings.sandbox_timeout_s, "timeout_seconds": settings.sandbox_timeout_s,
}, },
) )
@@ -838,6 +841,58 @@ def build_tools(ctx: ToolContext) -> list[Any]:
out["last_seen_version"] = live_card.get("version") out["last_seen_version"] = live_card.get("version")
return json.dumps(out) return json.dumps(out)
@tool
async def cp_deploy_source_repo(name: str) -> str:
"""Deploy the current managed source repo for ``name``.
Use this when source changes already live in the platform-managed
Gitea repo. Do not use it for normal MinIO builder workspaces; for
those, run ``test_agent_in_sandbox`` and then ``cp_deploy_tarball``.
Returns JSON from the control plane, including ``deploy_id`` and a
serialized ``deployment`` when one is queued.
"""
try:
_validate_name(name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
if not ctx.cp_jwt:
return json.dumps({
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(
f"{settings.cp_url}/v1/agents/{name}/source/deploy",
headers={"authorization": f"bearer {ctx.cp_jwt}"},
)
except httpx.HTTPError as exc:
return json.dumps({"error": f"cp unreachable: {exc}"})
if r.status_code >= 400:
return json.dumps({"error": f"cp {r.status_code}", "detail": r.text[:1000]})
payload = r.json()
body = payload if isinstance(payload, dict) else {}
deployment = body.get("deployment") if isinstance(body, dict) else None
url = None
if isinstance(deployment, dict):
url = deployment.get("agent_url")
out = {
"ok": True,
"name": body.get("agent_name") or name,
"status": body.get("status") or (deployment or {}).get("status"),
"url": url or _canonical_agent_url(name),
"head_sha": body.get("source_sha"),
"deployment_id": body.get("deploy_id"),
"deployment": deployment or {},
"already_deployed": bool(body.get("already_deployed")),
"skipped": bool(body.get("skipped")),
}
if body.get("summary"):
out["summary"] = body.get("summary")
if body.get("reason"):
out["reason"] = body.get("reason")
return json.dumps(out)
@tool @tool
async def cp_compose_meta_agent( async def cp_compose_meta_agent(
name: str, name: str,
@@ -1121,7 +1176,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return [ return [
init_agent_template, list_agent_files, write_agent_file, read_agent_file, init_agent_template, list_agent_files, write_agent_file, read_agent_file,
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball, write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball,
cp_compose_meta_agent, cp_refresh_agent, cp_deploy_source_repo, cp_compose_meta_agent, cp_refresh_agent,
sync_agent_workspace_from_repo, sync_agent_workspace_from_repo,
list_a2a_pack, read_a2a_pack, list_a2a_pack, read_a2a_pack,
] ]
@@ -1457,6 +1512,8 @@ def _compile_agent_dsl_json(bundle: bytes) -> str:
entrypoint = str(cfg.get("entrypoint") or "").strip() entrypoint = str(cfg.get("entrypoint") or "").strip()
if not entrypoint: if not entrypoint:
raise ValueError("a2a.yaml entrypoint is required") raise ValueError("a2a.yaml entrypoint is required")
entrypoint_module = entrypoint.split(":", 1)[0].strip()
old_entrypoint_module = sys.modules.get(entrypoint_module)
old_dont_write_bytecode = os.environ.get("PYTHONDONTWRITEBYTECODE") old_dont_write_bytecode = os.environ.get("PYTHONDONTWRITEBYTECODE")
old_sys_dont_write_bytecode = sys.dont_write_bytecode old_sys_dont_write_bytecode = sys.dont_write_bytecode
os.environ["PYTHONDONTWRITEBYTECODE"] = "1" os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
@@ -1483,6 +1540,10 @@ def _compile_agent_dsl_json(bundle: bytes) -> str:
else: else:
os.environ["PYTHONDONTWRITEBYTECODE"] = old_dont_write_bytecode os.environ["PYTHONDONTWRITEBYTECODE"] = old_dont_write_bytecode
sys.dont_write_bytecode = old_sys_dont_write_bytecode sys.dont_write_bytecode = old_sys_dont_write_bytecode
if old_entrypoint_module is None:
sys.modules.pop(entrypoint_module, None)
else:
sys.modules[entrypoint_module] = old_entrypoint_module
return dsl.model_dump_json() return dsl.model_dump_json()

View File

@@ -479,6 +479,64 @@ class TemplateInitTests(unittest.TestCase):
self.assertEqual(result["error"], "sandbox_not_passed") self.assertEqual(result["error"], "sandbox_not_passed")
self.assertEqual(_FakeAsyncClient.posts, []) self.assertEqual(_FakeAsyncClient.posts, [])
def test_cp_deploy_source_repo_posts_manual_source_deploy(self) -> None:
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(deploy_wait_timeout_s=0),
workspace=_FakeWorkspace(),
cp_jwt="jwt-user",
)
)
deploy = _tool_by_name(tools, "cp_deploy_source_repo")
posts: list[dict[str, object]] = []
class _Response:
status_code = 200
text = "{}"
def json(self) -> dict[str, object]:
return {
"summary": "Queued source deployment for demo-agent",
"agent_name": "demo-agent",
"deploy_id": "dpl_source",
"source_sha": "c" * 40,
"deployment": {
"status": "building",
"agent_url": "https://demo-agent.a2acloud.io",
},
}
class _Client:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
async def __aenter__(self) -> "_Client":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def post(self, url: str, *, headers: dict[str, str]) -> _Response:
posts.append({"url": url, "headers": headers})
return _Response()
with patch("agent_builder.tools.httpx.AsyncClient", _Client):
result = json.loads(
asyncio.run(deploy.ainvoke({"name": "demo-agent"}))
)
self.assertTrue(result["ok"])
self.assertEqual(result["deployment_id"], "dpl_source")
self.assertEqual(result["head_sha"], "c" * 40)
self.assertEqual(result["status"], "building")
self.assertEqual(posts, [
{
"url": "http://cp.test/v1/agents/demo-agent/source/deploy",
"headers": {"authorization": "bearer jwt-user"},
}
])
def test_source_bundle_hash_ignores_archive_metadata(self) -> None: def test_source_bundle_hash_ignores_archive_metadata(self) -> None:
def bundle_with_mtime(mtime: int) -> bytes: def bundle_with_mtime(mtime: int) -> bytes:
raw = io.BytesIO() raw = io.BytesIO()