"""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)}" # NOTE: the CP from-tarball endpoint names the file field # ``source`` (see apps/control-plane/control_plane/routes/agents.py). files = {"source": (f"{name}.tar.gz", bundle_bytes, "application/gzip")} data = { "name": name, "version": version, "entrypoint": entrypoint, "public": "true" if public else "false", "description": "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"