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:
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"
|
||||
Reference in New Issue
Block a user