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