diff --git a/README.md b/README.md index 005d3b4..2136be6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ The single `build(name, prompt)` skill: - `cp_deploy_tarball(name, version, public)` — POSTs the tarball to `/v1/agents/from-tarball` on the user's behalf using their 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 `agent_progress` events (so the user watches the build happen in real time, not in silence). diff --git a/agent_builder/builder.py b/agent_builder/builder.py index 35045e3..09ef34c 100644 --- a/agent_builder/builder.py +++ b/agent_builder/builder.py @@ -242,6 +242,11 @@ Your tools: elsewhere; use force=True only after the user explicitly accepts replacing 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", public=True, refresh_existing=False) — create/deploy a manifest-backed @@ -308,6 +313,8 @@ Discipline: scaffold. - When deploying, return the URL the platform gave back to the user so 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, 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 diff --git a/agent_builder/tools.py b/agent_builder/tools.py index 2494865..0ba2e58 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -3,6 +3,7 @@ - workspace file CRUD through the caller's scoped workspace grant - sandbox python (for `a2a card` / `a2a validate` round-trips) - 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) """ from __future__ import annotations @@ -661,8 +662,10 @@ def build_tools(ctx: ToolContext) -> list[Any]: f"{settings.sandbox_url}/v1/run_shell", headers=headers, json={ - "bucket": bucket, "script": script, - "image": settings.image, "memory_mib": 512, + "bucket": bucket, + "script": script, + "image": settings.image, + "memory_mib": 512, "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") 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 async def cp_compose_meta_agent( name: str, @@ -1121,7 +1176,7 @@ def build_tools(ctx: ToolContext) -> list[Any]: return [ init_agent_template, list_agent_files, write_agent_file, read_agent_file, 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, 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() if not entrypoint: 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_sys_dont_write_bytecode = sys.dont_write_bytecode os.environ["PYTHONDONTWRITEBYTECODE"] = "1" @@ -1483,6 +1540,10 @@ def _compile_agent_dsl_json(bundle: bytes) -> str: else: os.environ["PYTHONDONTWRITEBYTECODE"] = old_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() diff --git a/tests/test_template_init.py b/tests/test_template_init.py index 79cb620..461aff5 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -479,6 +479,64 @@ class TemplateInitTests(unittest.TestCase): self.assertEqual(result["error"], "sandbox_not_passed") 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 bundle_with_mtime(mtime: int) -> bytes: raw = io.BytesIO()