initial: deepagents-driven agent generator + deployer
All checks were successful
build / build (push) Successful in 31s
All checks were successful
build / build (push) Successful in 31s
This commit is contained in:
38
.gitea/workflows/build.yml
Normal file
38
.gitea/workflows/build.yml
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
67
README.md
Normal 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
6
a2a.yaml
Normal 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
219
agent.py
Normal 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
|
||||||
6
agent_builder/__init__.py
Normal file
6
agent_builder/__init__.py
Normal 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
114
agent_builder/builder.py
Normal 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
43
agent_builder/config.py
Normal 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
272
agent_builder/tools.py
Normal 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
93
deploy/20-deployment.yaml
Normal 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
0
public/.gitkeep
Normal file
6
requirements.txt
Normal file
6
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user