Add explicit source repo deploy tool
All checks were successful
build / build (push) Successful in 50s
All checks were successful
build / build (push) Successful in 50s
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user