initial: deepagents-driven agent generator + deployer
All checks were successful
build / build (push) Successful in 31s

This commit is contained in:
robert
2026-05-11 21:26:52 -03:00
commit 87eedb47a4
13 changed files with 883 additions and 0 deletions

View File

@@ -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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
__pycache__/
*.pyc
.pytest_cache/
.venv/

14
Dockerfile Normal file
View File

@@ -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

67
README.md Normal file
View File

@@ -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/<name>/` 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.

6
a2a.yaml Normal file
View File

@@ -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

219
agent.py Normal file
View File

@@ -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/<name>/``, 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

View File

@@ -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"]

114
agent_builder/builder.py Normal file
View File

@@ -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/<name>/`` 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 <Name>Config(BaseModel):
pass
class <Name>(A2AAgent[<Name>Config, NoAuth]):
name = "<slug>"
description = "..."
version = "0.1.0"
config_model = <Name>Config
auth_model = NoAuth
@skill(description="...", tags=["..."])
async def <skill>(self, ctx: RunContext[NoAuth], ...) -> dict:
await ctx.emit_progress("...")
return {"ok": True, "...": "..."}
```
You ALSO need an ``a2a.yaml`` like:
```yaml
name: <slug>
version: 0.1.0
entrypoint: agent:<Name>
description: <one line>
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/<name>/
- 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,
)

43
agent_builder/config.py Normal file
View File

@@ -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"),
)

272
agent_builder/tools.py Normal file
View File

@@ -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/<name>/`` 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/<name>/<path>`` 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/<name>/`` 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"

93
deploy/20-deployment.yaml Normal file
View File

@@ -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

0
public/.gitkeep Normal file
View File

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
httpx>=0.27
boto3>=1.34
deepagents>=0.5.0
langchain>=0.3
langchain-openai>=0.2
langgraph>=0.6