commit 87eedb47a407f98a57ea313b25dfabf66a72e0f0 Author: robert Date: Mon May 11 21:26:52 2026 -0300 initial: deepagents-driven agent generator + deployer diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..6f72d7a --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,38 @@ +name: build +on: + push: + branches: [main] + paths-ignore: + - 'deploy/**' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: checkout from in-cluster gitea + run: | + set -e + git clone --depth=1 \ + "http://gitea_admin:${{ secrets.ADMIN_PW }}@gitea-http.gitea.svc.cluster.local:3000/gitea_admin/agent-builder.git" . + git checkout "$GITHUB_SHA" + + - name: build image + run: | + IMG=registry.a2acloud.io/agents/agent-builder + docker build -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" . + docker push "$IMG:$GITHUB_SHA" + docker push "$IMG:latest" + + - name: bump deploy manifest + run: | + IMG=registry.a2acloud.io/agents/agent-builder + sed -i "s|image: $IMG:.*|image: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml + git config user.email "ci@a2a.local" + git config user.name "ci" + git add deploy/20-deployment.yaml + if git diff --staged --quiet; then + echo "no manifest changes" + else + git commit -m "ci: bump image to $GITHUB_SHA" + git push "http://gitea_admin:${{ secrets.ADMIN_PW }}@gitea-http.gitea.svc.cluster.local:3000/gitea_admin/agent-builder.git" HEAD:main + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f23588a --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea22d08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM registry.a2acloud.io/a2a/a2a-pack-base:latest + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +ENV A2A_ENTRYPOINT=agent:AgentBuilder +ENV PORT=8000 +EXPOSE 8000 + +CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9f8f23 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# agent-builder + +The platform's own meta-agent. Generates, tests, and deploys new +a2a-pack agents from a natural-language description. + +## What it does + +The single `build(name, prompt)` skill: + +1. Builds an inner `deepagents` LangGraph configured with five tools: + - `list_agent_files(name)` / `write_agent_file(name, path, content)` + / `read_agent_file(name, path)` — boto3 → MinIO, scoped to + `agents//` in the caller's workspace + - `test_agent_in_sandbox(name)` — tarballs the workspace project, + spins a microVM with the public `a2a-pack` wheel installed, runs + `a2a card` to verify the scaffold is valid + - `cp_deploy_tarball(name, version, public)` — POSTs the tarball + to `/v1/agents/from-tarball` on the user's behalf using their + forwarded CP JWT +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). +3. Returns the live URL when the new agent finishes deploying. + +## What it needs forwarded + +Three platform-managed bits land on the inner skill via `RunContext`: + +| Field | Source | Required? | +|---|---|---| +| `ctx.workspace.bucket` | grant minted by the orchestrator | yes | +| `ctx.llm` | caller's LLM creds (Card declares `llm_provisioning=caller_provided`) | yes — the LLM bill goes to the caller | +| `ctx.cp_jwt` | caller's CP JWT (Card declares `wants_cp_jwt=True`) | yes — used by `cp_deploy_tarball` | + +The user opts into all three when they pick `agent-builder` from the +marketplace — the dashboard already surfaces these on the agent card. + +## Pricing + +`$0.10 / call`, plus your LLM cost via your own provider. The deployed +agent is yours forever; subsequent invocations of YOUR agent run on +their own pricing (whatever you set when generating it). + +## How to call it + +In the dashboard chat (Workspace tab): + +``` +use agent-builder.build to make a new agent named "csv-sanitizer" that takes a CSV path, strips whitespace from every column, deduplicates rows, and writes the cleaned file back next to the original +``` + +The orchestrator will discover agent-builder, hand off with the +required forwards, you'll watch the inner deepagents scaffold + test + +deploy in real time, then get a live URL. + +## Local dev + +```bash +cd apps/agent-builder +python3 -m venv .venv +.venv/bin/pip install -e ../a2a -r requirements.txt +.venv/bin/a2a card # see what the Card looks like +.venv/bin/a2a validate +``` + +You can't run the full skill locally without a workspace bucket + CP +JWT — those come from the platform at invoke time. diff --git a/a2a.yaml b/a2a.yaml new file mode 100644 index 0000000..7f5e4e0 --- /dev/null +++ b/a2a.yaml @@ -0,0 +1,6 @@ +name: agent-builder +version: 0.1.0 +entrypoint: agent:AgentBuilder +description: Generate, test, and deploy new a2a agents from natural language. +expose: + public: true diff --git a/agent.py b/agent.py new file mode 100644 index 0000000..81a47db --- /dev/null +++ b/agent.py @@ -0,0 +1,219 @@ +"""agent-builder — generate + deploy new agents from natural language. + +Inner loop is a deepagents graph (see agent_builder/builder.py). The +outer A2AAgent surface is a single skill ``build`` that takes a +description, writes a scaffolded project to the user's workspace at +``agents//``, tests it in a sandbox, deploys it through the +control plane, and reports back the live URL. + +This agent only works when invoked through the platform orchestrator, +because it needs three things forwarded from the caller: + + 1. ``ctx.workspace.bucket`` — the user's MinIO bucket + 2. ``ctx.llm`` — caller's LLM creds (this is an expensive skill; + the user pays for inference directly via their own provider) + 3. ``ctx.cp_jwt`` — the user's CP JWT, so cp_deploy_tarball can + POST to /v1/agents/from-tarball as the user. +""" +from __future__ import annotations + +import json +import re +from typing import Any + +from pydantic import BaseModel + +from a2a_pack import ( + A2AAgent, LLMProvisioning, NoAuth, Pricing, RunContext, skill, +) + +from agent_builder import BuilderContext, build_agent_builder + + +class BuilderConfig(BaseModel): + pass + + +_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*") + + +class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]): + name = "agent-builder" + description = ( + "Generate, test, and deploy new a2a-pack agents from natural language. " + "Writes the project into the user's workspace, validates it in a " + "sandbox, then ships it via the control plane." + ) + version = "0.1.0" + + config_model = BuilderConfig + auth_model = NoAuth + tools_used = ("deepagents", "langgraph", "a2a-pack", "litellm", "microsandbox") + llm_provisioning = LLMProvisioning.CALLER_PROVIDED + wants_cp_jwt = True + pricing = Pricing( + price_per_call_usd=0.10, + caller_pays_llm=True, + notes="LLM cost passes through; deployed agent is yours forever.", + ) + + @skill( + description=( + "Generate, test, and deploy a new a2a agent. Pass a kebab-case " + "name and a natural-language description of what the agent " + "should do. Returns the live URL when ready." + ), + tags=["builder", "scaffold", "deepagents", "meta"], + ) + async def build( + self, + ctx: RunContext[NoAuth], + name: str, + prompt: str, + public: bool = True, + version: str = "0.1.0", + ) -> dict: + bucket = getattr(ctx.workspace, "bucket", None) + if not bucket: + return {"error": "no workspace grant; refusing to run"} + cp_jwt = ctx.cp_jwt + if not cp_jwt: + return { + "error": "no CP JWT forwarded — caller must run me through " + "the platform orchestrator so deploy can act on your behalf", + } + if not _is_valid_slug(name): + return {"error": f"invalid name {name!r} — use kebab-case, 2-63 chars"} + + await ctx.emit_progress(f"building agent {name!r} in bucket {bucket}") + creds = ctx.llm + await ctx.emit_progress( + f"llm: {creds.model} via {creds.source} ({creds.base_url})" + ) + + graph = build_agent_builder(BuilderContext( + bucket=bucket, cp_jwt=cp_jwt, + llm_base_url=creds.base_url, + llm_api_key=creds.api_key, + llm_model=creds.model, + )) + + user_msg = ( + f"Build an a2a-pack agent named ``{name}``.\n\n" + f"The agent should: {prompt}\n\n" + f"After writing agent.py + a2a.yaml + requirements.txt, run " + f"test_agent_in_sandbox to verify it loads cleanly, then " + f"cp_deploy_tarball(name={name!r}, version={version!r}, " + f"public={public}). Report the URL the platform gives back." + ) + + final_state: dict[str, Any] = {} + deployed_url: str | None = None + last_reply: str = "" + try: + async for event in graph.astream_events( + {"messages": [{"role": "user", "content": user_msg}]}, + version="v2", + ): + kind = event.get("event") + tname = event.get("name") or "" + data = event.get("data") or {} + if kind == "on_tool_start": + raw = data.get("input") or {} + if isinstance(raw, dict) and set(raw.keys()) == {"input"}: + raw = raw["input"] + await ctx.emit_progress(f"→ {tname}({_one_line(raw)})") + elif kind == "on_tool_end": + summary = _summarize(tname, data.get("output")) + await ctx.emit_progress(f" {tname} ← {summary}") + # Capture URL from any tool output that mentions one. + deployed_url = deployed_url or _find_url(data.get("output")) + elif kind == "on_chain_end" and not event.get("parent_ids"): + out = data.get("output") + if isinstance(out, dict): + final_state = out + except Exception as exc: # noqa: BLE001 + return {"error": f"build graph failed: {type(exc).__name__}: {exc}"} + + for msg in final_state.get("messages") or []: + content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None) + if isinstance(content, str) and content.strip(): + last_reply = content + deployed_url = deployed_url or _find_url(last_reply) + + if not deployed_url: + return { + "ok": False, + "name": name, + "reply": last_reply[:2000], + "warning": "build graph finished but no live URL was found; " + "check progress events for errors.", + } + + await ctx.emit_progress(f"deployed: {deployed_url}") + return { + "ok": True, + "name": name, + "version": version, + "url": deployed_url, + "workspace_dir": f"agents/{name}/", + "reply": last_reply[:2000], + } + + +def _is_valid_slug(name: str) -> bool: + return bool(re.match(r"^[a-z][a-z0-9-]{1,62}$", name)) + + +def _one_line(args: Any, limit: int = 120) -> str: + if isinstance(args, dict): + bits = [] + for k, v in args.items(): + s = v if isinstance(v, str) else json.dumps(v) + if len(s) > 60: + s = s[:60] + "…" + bits.append(f"{k}={s}") + return ", ".join(bits)[:limit] + return (json.dumps(args) if not isinstance(args, str) else args)[:limit] + + +def _summarize(name: str, output: Any) -> str: + if output is None: + return "ok" + if isinstance(output, str): + try: + parsed = json.loads(output) + except (json.JSONDecodeError, ValueError): + return output[:120] + ("…" if len(output) > 120 else "") + return _summarize(name, parsed) + if isinstance(output, dict): + if "error" in output: + return f"error: {str(output['error'])[:120]}" + if name == "write_agent_file": + return f"wrote {output.get('path')}" + if name == "list_agent_files": + return f"{len(output.get('files', []))} files" + if name == "test_agent_in_sandbox": + ec = output.get("exit_code") + return f"exit={ec}" + if name == "cp_deploy_tarball": + url = output.get("url") or "" + return f"shipped → {url}" if url else "shipped" + return "ok" + return str(output)[:120] + + +def _find_url(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, dict): + for k in ("url", "live_url"): + v = value.get(k) + if isinstance(v, str) and v.startswith("http"): + return v + # JSON-stringified outputs from tools. + return _find_url(json.dumps(value)) + if isinstance(value, str): + m = _URL_RE.search(value) + return m.group(0) if m else None + return None diff --git a/agent_builder/__init__.py b/agent_builder/__init__.py new file mode 100644 index 0000000..870ce75 --- /dev/null +++ b/agent_builder/__init__.py @@ -0,0 +1,6 @@ +"""Internal deepagents-based agent generator + deployer.""" +from __future__ import annotations + +from .builder import BuilderContext, build_agent_builder + +__all__ = ["BuilderContext", "build_agent_builder"] diff --git a/agent_builder/builder.py b/agent_builder/builder.py new file mode 100644 index 0000000..ec0248a --- /dev/null +++ b/agent_builder/builder.py @@ -0,0 +1,114 @@ +"""Build the inner deepagents graph that writes + tests + deploys a new agent.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from deepagents import create_deep_agent +from langchain_openai import ChatOpenAI + +from .config import Settings, load_settings +from .tools import ToolContext, build_tools + + +@dataclass(frozen=True) +class BuilderContext: + bucket: str + cp_jwt: str | None = None + settings: Settings | None = None + # Optional caller-provided LLM creds (when the outer platform Card + # declares llm_provisioning=caller_provided). Falls back to settings. + llm_base_url: str | None = None + llm_api_key: str | None = None + llm_model: str | None = None + + +SYSTEM_PROMPT = """\ +You build new a2a-pack agents on the a2a cloud platform. + +Given a user description, you write a complete agent project under the +user's workspace at ``agents//`` and then deploy it through the +control plane. + +What an a2a-pack agent looks like: + +```python +from pydantic import BaseModel +from a2a_pack import A2AAgent, NoAuth, RunContext, skill + + +class Config(BaseModel): + pass + + +class (A2AAgent[Config, NoAuth]): + name = "" + description = "..." + version = "0.1.0" + config_model = Config + auth_model = NoAuth + + @skill(description="...", tags=["..."]) + async def (self, ctx: RunContext[NoAuth], ...) -> dict: + await ctx.emit_progress("...") + return {"ok": True, "...": "..."} +``` + +You ALSO need an ``a2a.yaml`` like: + +```yaml +name: +version: 0.1.0 +entrypoint: agent: +description: +expose: + public: true +``` + +And a ``requirements.txt`` listing any extra deps beyond a2a-pack +itself (pandas, httpx, etc. — the base image ships a2a-pack already +when you deploy through the control plane). + +Your tools: + + - list_agent_files(name) — see what's already at agents// + - write_agent_file(name, path, content) + — save agent.py / a2a.yaml / requirements.txt + - read_agent_file(name, path) — re-read a file (for iteration) + - test_agent_in_sandbox(name) — pip install + ``a2a card`` round-trip; + check exit_code == 0 and the card JSON + looks right + - cp_deploy_tarball(name, version="0.1.0", public=True) + — ship it to the platform; returns the + public URL + +Discipline: + + - Pick a kebab-case slug for ``name`` (e.g. ``research-agent``, + ``csv-sanitizer``). Class name is PascalCase from the slug. + - Write ALL THREE files (agent.py, a2a.yaml, requirements.txt) before + testing — partial scaffolds break ``a2a card``. + - Always run test_agent_in_sandbox before deploying. If exit_code != 0, + read stderr, edit the offending file, retest. Do NOT deploy a broken + scaffold. + - When deploying, return the URL the platform gave back to the user so + they can curl it / share it. + - Don't fabricate functionality the user didn't ask for. One or two + well-scoped @skill methods beats a kitchen sink. +""" + + +def build_agent_builder(ctx: BuilderContext) -> Any: + settings = ctx.settings or load_settings() + tools = build_tools(ToolContext( + bucket=ctx.bucket, settings=settings, cp_jwt=ctx.cp_jwt, + )) + model = ChatOpenAI( + model=ctx.llm_model or settings.litellm_model, + base_url=ctx.llm_base_url or (settings.litellm_url + "/v1"), + api_key=ctx.llm_api_key or settings.litellm_key, + temperature=0.0, + ) + return create_deep_agent( + model=model, tools=tools, system_prompt=SYSTEM_PROMPT, + ) diff --git a/agent_builder/config.py b/agent_builder/config.py new file mode 100644 index 0000000..3660303 --- /dev/null +++ b/agent_builder/config.py @@ -0,0 +1,43 @@ +"""Runtime config — env-driven, matches the rest of the cluster.""" +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + sandbox_url: str + sandbox_timeout_s: int + litellm_url: str + litellm_key: str + litellm_model: str + cp_url: str + minio_endpoint: str + minio_access_key: str + minio_secret_key: str + image: str + + +def load_settings() -> Settings: + return Settings( + sandbox_url=os.environ.get( + "SANDBOX_URL", "http://sandbox.sandbox.svc.cluster.local:8000" + ), + sandbox_timeout_s=int(os.environ.get("SANDBOX_TIMEOUT_S", "240")), + litellm_url=os.environ.get( + "A2A_LITELLM_URL", "http://litellm.llm.svc.cluster.local:4000" + ), + litellm_key=os.environ.get("A2A_LITELLM_KEY", "sk-microcash-local"), + litellm_model=os.environ.get("A2A_LITELLM_MODEL", "gpt-5.5"), + cp_url=os.environ.get( + "A2A_CP_URL", "http://control-plane.control-plane.svc.cluster.local" + ), + minio_endpoint=os.environ.get( + "A2A_MINIO_ENDPOINT", + "http://microcash-infra-minio.microcash-infra.svc.cluster.local:9000", + ), + minio_access_key=os.environ.get("A2A_MINIO_ACCESS_KEY", "minioadmin"), + minio_secret_key=os.environ.get("A2A_MINIO_SECRET_KEY", "minioadmin"), + image=os.environ.get("AGENT_BUILDER_IMAGE", "python:3.11-slim"), + ) diff --git a/agent_builder/tools.py b/agent_builder/tools.py new file mode 100644 index 0000000..4fd2681 --- /dev/null +++ b/agent_builder/tools.py @@ -0,0 +1,272 @@ +"""LangChain tools the inner deepagents loop uses. + + - workspace file CRUD (boto3 → MinIO, scoped to the user's bucket) + - sandbox python (for `a2a card` / `a2a validate` round-trips) + - cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf) +""" +from __future__ import annotations + +import base64 +import io +import json +import re +import tarfile +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import boto3 +import httpx +from botocore.config import Config as _BotoConfig +from botocore.exceptions import ClientError +from langchain_core.tools import tool + +if TYPE_CHECKING: + from .config import Settings + + +@dataclass(frozen=True) +class ToolContext: + bucket: str + settings: "Settings" + cp_jwt: str | None = None + + +def _s3(ctx: ToolContext) -> Any: + return boto3.client( + "s3", + endpoint_url=ctx.settings.minio_endpoint, + aws_access_key_id=ctx.settings.minio_access_key, + aws_secret_access_key=ctx.settings.minio_secret_key, + region_name="us-east-1", + config=_BotoConfig(s3={"addressing_style": "path"}), + ) + + +def _ensure_bucket(s3: Any, bucket: str) -> None: + try: + s3.head_bucket(Bucket=bucket) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code") + if code in {"404", "NoSuchBucket", "NotFound"}: + s3.create_bucket(Bucket=bucket) + elif code != "403": + raise + + +_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$") + + +def _validate_name(name: str) -> None: + if not _AGENT_NAME_RE.match(name): + raise ValueError( + f"invalid agent name {name!r}: must match {_AGENT_NAME_RE.pattern}" + ) + + +def _agent_prefix(name: str) -> str: + _validate_name(name) + return f"agents/{name}/" + + +def build_tools(ctx: ToolContext) -> list[Any]: + bucket = ctx.bucket + settings = ctx.settings + s3 = _s3(ctx) + _ensure_bucket(s3, bucket) + + @tool + def list_agent_files(name: str) -> str: + """List every file under ``agents//`` in the user's workspace. + + Use this before edits to see what you already wrote / what's left. + """ + try: + prefix = _agent_prefix(name) + except ValueError as exc: + return json.dumps({"error": str(exc)}) + out: list[dict[str, Any]] = [] + paginator = s3.get_paginator("list_objects_v2") + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents") or (): + out.append({ + "path": obj["Key"][len(prefix):], + "size": int(obj.get("Size") or 0), + }) + return json.dumps({"agent": name, "files": out}) + + @tool + def write_agent_file(name: str, path: str, content: str) -> str: + """Write a file under ``agents//`` in the user's workspace. + + Use this for ``agent.py``, ``a2a.yaml``, ``requirements.txt``, and + any auxiliary modules the agent needs. + """ + try: + prefix = _agent_prefix(name) + except ValueError as exc: + return json.dumps({"error": str(exc)}) + key = prefix + path.lstrip("/") + s3.put_object( + Bucket=bucket, Key=key, Body=content.encode("utf-8"), + ContentType="text/plain; charset=utf-8", + ) + return json.dumps({"ok": True, "path": key, "size": len(content)}) + + @tool + def read_agent_file(name: str, path: str) -> str: + """Read a single file from the scaffolded agent project.""" + try: + prefix = _agent_prefix(name) + except ValueError as exc: + return json.dumps({"error": str(exc)}) + key = prefix + path.lstrip("/") + try: + obj = s3.get_object(Bucket=bucket, Key=key) + except ClientError as exc: + return json.dumps({"error": f"read failed: {exc}"}) + body = obj["Body"].read().decode("utf-8", errors="replace") + return json.dumps({"path": key, "content": body[:200_000]}) + + @tool + async def test_agent_in_sandbox(name: str) -> str: + """Spin a microVM, ``pip install a2a-pack`` + the agent's own + requirements.txt, then run ``a2a card`` to verify the scaffold + produces a valid Card. Returns stdout/stderr. + """ + try: + prefix = _agent_prefix(name) + except ValueError as exc: + return json.dumps({"error": str(exc)}) + + # Bundle the workspace files into the sandbox via base64 tar so + # the sandbox doesn't need MinIO read access for this skill. + bundle_bytes = _tarball_workspace_dir(s3, bucket, prefix) + if not bundle_bytes: + return json.dumps({ + "error": "no files in agents/" + name + "/ — write some first", + }) + b64 = base64.b64encode(bundle_bytes).decode("ascii") + script = ( + "set -e\n" + "pip install --quiet a2a-pack >/dev/null\n" + "mkdir -p /tmp/agent\n" + f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n" + "cd /tmp/agent\n" + "ls -la\n" + "if [ -f requirements.txt ]; then\n" + " pip install --quiet -r requirements.txt >/dev/null || true\n" + "fi\n" + "echo '--- card ---'\n" + "a2a card --project .\n" + ) + async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c: + r = await c.post( + f"{settings.sandbox_url}/v1/run_shell", + json={ + "bucket": bucket, "script": script, + "image": settings.image, "memory_mib": 512, + "timeout_seconds": settings.sandbox_timeout_s, + }, + ) + if r.status_code >= 400: + return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]}) + out = r.json() + return json.dumps({ + "exit_code": out.get("exit_code"), + "stdout": (out.get("stdout") or "")[:4000], + "stderr": (out.get("stderr") or "")[:4000], + }) + + @tool + async def cp_deploy_tarball(name: str, version: str = "0.1.0", public: bool = True) -> str: + """Tar the agent at ``agents//`` and POST it to the control + plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf. + + Requires the orchestrator to have forwarded the user's CP JWT + (the platform does this automatically when this agent's Card + declared ``wants_cp_jwt=True``). + """ + try: + prefix = _agent_prefix(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", + }) + + bundle_bytes = _tarball_workspace_dir(s3, bucket, 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)}" + + files = {"tarball": (f"{name}.tar.gz", bundle_bytes, "application/gzip")} + data = { + "name": name, "version": version, + "entrypoint": entrypoint, "public": "true" if public else "false", + "description": f"Built by agent-builder.", + } + try: + async with httpx.AsyncClient(timeout=60.0) as c: + r = await c.post( + f"{settings.cp_url}/v1/agents/from-tarball", + headers={"authorization": f"bearer {ctx.cp_jwt}"}, + files=files, data=data, + ) + 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]}) + body = r.json() + return json.dumps({ + "ok": True, "name": body.get("name"), "version": body.get("version"), + "status": body.get("status"), "url": body.get("url"), + }) + + return [ + list_agent_files, write_agent_file, read_agent_file, + test_agent_in_sandbox, cp_deploy_tarball, + ] + + +# --- helpers (not tools) --- + + +def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes: + """Pull every object under ``prefix`` from MinIO and produce a + gzipped tarball whose entries are relative to ``prefix``.""" + paginator = s3.get_paginator("list_objects_v2") + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + for page in paginator.paginate(Bucket=bucket, Prefix=prefix): + for obj in page.get("Contents") or (): + key = obj["Key"] + rel = key[len(prefix):] + if not rel: + continue + body = s3.get_object(Bucket=bucket, Key=key)["Body"].read() + info = tarfile.TarInfo(name=rel) + info.size = len(body) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(body)) + return buf.getvalue() if buf.getbuffer().nbytes > 0 else b"" + + +def _read_entrypoint(bundle: bytes) -> str | None: + try: + with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf: + for member in tf.getmembers(): + if member.name.endswith("a2a.yaml") and tf.extractfile(member): + text = tf.extractfile(member).read().decode("utf-8", "replace") # type: ignore[union-attr] + for line in text.splitlines(): + if line.lstrip().startswith("entrypoint:"): + return line.split(":", 1)[1].strip().strip('"\'') + except Exception: # noqa: BLE001 + pass + return None + + +def _class_name(slug: str) -> str: + return "".join(p.capitalize() for p in re.split(r"[-_]+", slug) if p) or "MyAgent" diff --git a/deploy/20-deployment.yaml b/deploy/20-deployment.yaml new file mode 100644 index 0000000..a33224b --- /dev/null +++ b/deploy/20-deployment.yaml @@ -0,0 +1,93 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agent-builder + namespace: agents + labels: + app: agent-builder + a2a/managed-by: control-plane +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: agent-builder + template: + metadata: + labels: + app: agent-builder + spec: + containers: + - name: agent + image: registry.a2acloud.io/agents/agent-builder:placeholder + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: SANDBOX_URL + value: http://sandbox.sandbox.svc.cluster.local:8000 + - name: A2A_LITELLM_URL + value: http://litellm.llm.svc.cluster.local:4000 + - name: A2A_LITELLM_KEY + value: sk-microcash-local + - name: A2A_LITELLM_MODEL + value: gpt-5.5 + - name: A2A_CP_URL + value: http://control-plane.control-plane.svc.cluster.local + - name: A2A_MINIO_ENDPOINT + value: http://microcash-infra-minio.microcash-infra.svc.cluster.local:9000 + - name: A2A_MINIO_ACCESS_KEY + value: minioadmin + - name: A2A_MINIO_SECRET_KEY + value: minioadmin + readinessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 8 + periodSeconds: 5 + livenessProbe: + httpGet: {path: /healthz, port: 8000} + initialDelaySeconds: 25 + periodSeconds: 15 + resources: + requests: {cpu: 200m, memory: 512Mi} + limits: {cpu: 1, memory: 1Gi} +--- +apiVersion: v1 +kind: Service +metadata: + name: agent-builder + namespace: agents +spec: + type: ClusterIP + selector: + app: agent-builder + ports: + - name: http + port: 80 + targetPort: 8000 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: agent-builder + namespace: agents + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure +spec: + tls: + - hosts: [agent-builder.a2acloud.io] + secretName: agent-builder-a2acloud-io-tls + rules: + - host: agent-builder.a2acloud.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: agent-builder + port: + number: 80 diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..10f3c8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +httpx>=0.27 +boto3>=1.34 +deepagents>=0.5.0 +langchain>=0.3 +langchain-openai>=0.2 +langgraph>=0.6