"""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 asyncio import base64 import io import json import re import tarfile import time from dataclasses import dataclass from importlib import resources from pathlib import Path 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 A2A_PACK_MIN_VERSION = "0.1.25" @dataclass(frozen=True) class ToolContext: bucket: str settings: "Settings" cp_jwt: str | None = None async def _wait_for_live_card( url: str | None, expected_version: str | None, timeout_s: float = 900.0, ) -> tuple[bool, dict[str, Any]]: """Poll ``{url}/.well-known/agent-card`` until ``version`` matches ``expected_version`` or ``timeout_s`` elapses. Returns ``(matched, last_seen_card)``. ``last_seen_card`` is ``{}`` if the endpoint never responded with a parseable card. Backoff: 2s → 1.5x per try, capped at 10s. Tolerates pod-not-ready (any HTTPError or 4xx/5xx) — just keeps polling. """ if not url or not expected_version: return False, {} deadline = time.monotonic() + timeout_s last_card: dict[str, Any] = {} interval = 2.0 while time.monotonic() < deadline: try: async with httpx.AsyncClient(timeout=5.0) as c: r = await c.get(f"{url.rstrip('/')}/.well-known/agent-card") if r.status_code == 200: card = r.json() or {} if isinstance(card, dict): last_card = card if card.get("version") == expected_version: return True, card except (httpx.HTTPError, ValueError): pass await asyncio.sleep(interval) interval = min(interval * 1.5, 10.0) return False, last_card 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}$") _SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") _BUILDER_STATE_FILE = ".a2a-builder-state.json" _BUILDER_INTERNAL_PREFIX = ".agent-builder/" 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 _validate_skill_name(name: str) -> None: if not _SKILL_NAME_RE.match(name) or "--" in name: raise ValueError( "invalid skill name " f"{name!r}: use lowercase alphanumeric kebab-case, max 64 chars" ) def _agent_prefix(name: str) -> str: _validate_name(name) return f"agents/{name}/" def _builder_state_key(name: str) -> str: return _agent_prefix(name) + _BUILDER_STATE_FILE def build_tools(ctx: ToolContext) -> list[Any]: bucket = ctx.bucket settings = ctx.settings s3 = _s3(ctx) _ensure_bucket(s3, bucket) @tool def init_agent_template( name: str, description: str = "A new A2A agent", frontend: str = "none", ) -> str: """Initialize ``agents//`` from the installed a2a-pack template. Use this FIRST for a new project. It writes the same baseline files as ``a2a init`` for the SDK version installed in this runtime: ``agent.py``, ``a2a.yaml``, and ``requirements.txt``. Set ``frontend`` to ``"react"`` for a Vite packed app, ``"static"`` for a minimal bundled HTML app, or ``"none"`` for a headless agent. After this, edit those files with ``read_agent_file`` + ``write_agent_file`` to implement the user's requested behavior. """ try: frontend_kind = _normalize_frontend_kind(frontend) files = _render_a2a_init_template( name, description=description, frontend=frontend_kind, ) prefix = _agent_prefix(name) except (OSError, RuntimeError, ValueError) as exc: return json.dumps({"error": str(exc)}) written: list[dict[str, Any]] = [] for path, content in files.items(): key = prefix + path s3.put_object( Bucket=bucket, Key=key, Body=content.encode("utf-8"), ContentType="text/plain; charset=utf-8", ) written.append({"path": key, "size": len(content)}) return json.dumps({ "ok": True, "agent": name, "frontend": frontend_kind, "files": written, "docs": ( "https://docs.a2acloud.io/concepts/packed-frontends" if frontend_kind != "none" else "https://docs.a2acloud.io/quickstart" ), }) @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 (): rel = obj["Key"][len(prefix):] if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): continue out.append({ "path": rel, "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 or packed frontend files the agent needs. """ try: prefix = _agent_prefix(name) except ValueError as exc: return json.dumps({"error": str(exc)}) rel = path.lstrip("/") if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): return json.dumps({"error": f"{rel} is managed by agent-builder"}) key = prefix + rel 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)}) rel = path.lstrip("/") if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): return json.dumps({"error": f"{rel} is managed by agent-builder"}) key = prefix + rel 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 def write_agent_skill( name: str, skill_name: str, description: str, instructions: str, supporting_files_json: str = "{}", ) -> str: """Create a DeepAgents skill bundle in ``agents//skills/``. Use this for generated agents that should rely on DeepAgents' progressive-disclosure skills instead of fake one-off tools. ``supporting_files_json`` is a JSON object mapping relative paths inside the skill directory to text content, for example ``{"references/schema.md": "..."}``. After writing skills, update ``agent.py`` so it seeds packaged ``skills/`` files into ``ctx.workspace_backend()`` and passes ``skills=[...]`` to ``create_deep_agent``. """ try: prefix = _agent_prefix(name) _validate_skill_name(skill_name) files = _parse_supporting_skill_files(supporting_files_json) skill_md = _render_skill_md(skill_name, description, instructions) except ValueError as exc: return json.dumps({"error": str(exc)}) base = prefix + f"skills/{skill_name}/" written: list[dict[str, Any]] = [] payloads = {"SKILL.md": skill_md, **files} for rel, content in payloads.items(): key = base + rel encoded = content.encode("utf-8") s3.put_object( Bucket=bucket, Key=key, Body=encoded, ContentType="text/plain; charset=utf-8", ) written.append({"path": key, "size": len(encoded)}) return json.dumps({ "ok": True, "agent": name, "skill": skill_name, "source_path": f"skills/{skill_name}/", "files": written, "wire_agent_py": ( "Seed project skills into ctx.workspace_backend() and pass " "skills=[RUNTIME_SKILLS_ROOT] to create_deep_agent." ), }) @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. When a packed frontend is declared, it also prints ``a2a frontend info`` metadata. 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" f"pip install --quiet 'a2a-pack>={A2A_PACK_MIN_VERSION}' >/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" "if grep -q '^frontend:' a2a.yaml 2>/dev/null; then\n" " echo '--- frontend ---'\n" " a2a frontend info --project . || true\n" "fi\n" ) headers = ( {"authorization": f"Bearer {settings.sandbox_token}"} if settings.sandbox_token else None ) async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c: r = await c.post( f"{settings.sandbox_url}/v1/run_shell", headers=headers, 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, force: bool = False, ) -> str: """Tar the agent at ``agents//`` and POST it to the control plane's ``/v1/agents/from-tarball`` endpoint on the user's behalf, then BLOCK until the live ``.well-known/agent-card`` reports the new version (or 180s timeout). Returns JSON with ``ok``, ``live``, ``url``, ``version``, ``live_version``, and ``live_skills[{name, input_schema}]``. When ``live=true`` the agent is callable; when ``live=false`` the deploy POST succeeded but the pod never came up with the new version — caller should treat this as a failure, not success. Compare ``live_skills[].input_schema`` against the code you wrote to confirm the advertised schema matches the params the function actually accepts. Mismatches cause downstream ``agent 400`` from ``call_agent``. ``force`` defaults to false so a stale MinIO workspace cannot silently overwrite source that was deployed from a user's computer or another tool. Only set ``force=True`` after the user explicitly accepts replacing the current managed repo contents. 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", }) try: latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name) except RuntimeError as exc: return json.dumps({"error": str(exc)}) builder_state = _read_builder_state(s3, bucket, name) drift = _deployment_drift_error( name, latest_deploy, builder_state, force=force, ) if drift is not None: return json.dumps(drift) base_head = builder_state.get("repo_head_sha") if force: base_head = None 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.", } if isinstance(base_head, str) and base_head: data["base_head_sha"] = base_head 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() name_ = body.get("name") version_ = body.get("version") url_ = body.get("url") head_sha = body.get("head_sha") if isinstance(head_sha, str) and head_sha: _write_builder_state( s3, bucket, name, { "agent": name, "repo_head_sha": head_sha, "deployment_id": body.get("deployment_id"), "version": version_, "updated_at": int(time.time()), "source": "agent-builder", }, ) # CP returns immediately with status="building". The agent isn't # actually callable until the pod rolls + serves the new card. # Poll /.well-known/agent-card until the live card reports the # version we just deployed (or timeout). Surface the live skills # so the LLM can verify the input_schema actually matches the # code it wrote — catches the schema/code skew that causes # downstream 400s on call_agent. live, live_card = await _wait_for_live_card( url_, version_, timeout_s=settings.deploy_wait_timeout_s, ) out: dict[str, Any] = { "ok": live, "name": name_, "version": version_, "status": body.get("status"), "url": url_, "head_sha": head_sha, "deployment_id": body.get("deployment_id"), "workspace_base_head_sha": base_head, "live": live, } if live: out["live_version"] = live_card.get("version") out["live_skills"] = [ { "name": s.get("name"), "input_schema": s.get("input_schema") or {}, } for s in (live_card.get("skills") or []) ] else: out["error"] = ( "deploy did not become live within timeout — pod may still" " be rolling or build failed. Call cp_deploy_tarball again" " or inspect via discover_agent before declaring done." ) if live_card: out["last_seen_version"] = live_card.get("version") return json.dumps(out) @tool async def cp_refresh_agent(name: str) -> str: """Force the control plane to re-fetch the agent's live ``/.well-known/agent-card`` and update its stored copy. Use this when the pod was redeployed out-of-band (CI bump, manual rollout) and the CP's cached card is stale — symptoms are stale ``input_schema`` in ``list_my_agents`` / ``discover_agent`` and 4xx from ``call_agent`` on what should be a valid skill call. ``cp_deploy_tarball`` already does this automatically by polling the live card, so you only need ``cp_refresh_agent`` when you did NOT deploy through this tool. Returns JSON: the fresh ``AgentDetailOut`` (with ``card`` + ``skills`` + ``input_schema``), or ``{error, ...}``. """ if not ctx.cp_jwt: return json.dumps({ "error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True", }) try: async with httpx.AsyncClient(timeout=15.0) as c: r = await c.get( f"{settings.cp_url}/v1/agents/{name}", headers={"authorization": f"bearer {ctx.cp_jwt}"}, ) 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() or {} return json.dumps({ "ok": True, "name": body.get("name"), "version": body.get("version"), "status": body.get("status"), "url": body.get("url"), "card": body.get("card") or {}, }) @tool async def sync_agent_workspace_from_repo(name: str) -> str: """Replace ``agents//`` in MinIO with the current managed repo source. Use this before editing an existing deployed agent, or after ``cp_deploy_tarball`` returns ``workspace_untracked`` / ``workspace_drift``. It downloads the owner's current managed repo through the control plane, deletes the old MinIO files for that agent, writes the repo source files, and records the repo head as the deploy base marker. This intentionally overwrites the builder workspace copy so it matches the repo. """ 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", }) try: async with httpx.AsyncClient(timeout=60.0) as c: r = await c.get( f"{settings.cp_url}/v1/agents/{name}/source", headers={"authorization": f"bearer {ctx.cp_jwt}"}, ) 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]}) head_sha = r.headers.get("x-a2a-repo-head-sha") try: files = _files_from_tarball(r.content) except ValueError as exc: return json.dumps({"error": f"invalid source tarball: {exc}"}) if not files: return json.dumps({"error": "managed repo source export was empty"}) written = _replace_workspace_files(s3, bucket, prefix, files) _write_builder_state( s3, bucket, name, { "agent": name, "repo_head_sha": head_sha, "file_count": len(written), "updated_at": int(time.time()), "source": "repo-sync", }, ) return json.dumps({ "ok": True, "agent": name, "repo_head_sha": head_sha, "files": written, }) @tool def list_a2a_pack(subdir: str = "") -> str: """List ``.py`` files under the installed ``a2a_pack`` package. Use this when you need to recall what the SDK actually exposes — what decorators exist, what ``RunContext`` methods are available, how ``WorkspaceClient`` is shaped, what the ``AgentRuntime`` fields are, etc. The agent code you scaffold runs on this exact package, so reading it is the authoritative reference (the system-prompt examples are intentionally abridged). ``subdir`` is an optional path segment relative to the package root (``""``, ``"serve"``, ``"mcp"`` …). Returns JSON: ``{root, files: [{path, size}]}``. """ try: import a2a_pack import os as _os base = _os.path.dirname(_os.path.abspath(a2a_pack.__file__)) except Exception as exc: # noqa: BLE001 return json.dumps({"error": f"a2a_pack not importable: {exc}"}) if subdir.startswith("/") or ".." in subdir.split("/"): return json.dumps({"error": "subdir must be relative; '..' disallowed"}) root = _os.path.join(base, subdir) if subdir else base if not _os.path.commonpath([base, _os.path.abspath(root)]) == base: return json.dumps({"error": "subdir escapes package root"}) if not _os.path.isdir(root): return json.dumps({"error": f"not a directory: {subdir}"}) out: list[dict[str, Any]] = [] for dirpath, _, filenames in _os.walk(root): for fname in filenames: if not fname.endswith(".py"): continue full = _os.path.join(dirpath, fname) rel = _os.path.relpath(full, base) try: size = _os.path.getsize(full) except OSError: continue out.append({"path": rel, "size": size}) out.sort(key=lambda r: r["path"]) return json.dumps({"root": base, "files": out}) @tool def read_a2a_pack(path: str) -> str: """Read a file from the installed ``a2a_pack`` package. Path is relative to the package root, e.g. ``agent.py``, ``context.py``, ``runtime.py``, ``workspace.py``, ``card.py``, ``mcp/server.py``. Returns JSON: ``{path, content}``. Capped at 64 KiB; reach for ``list_a2a_pack`` first if you need to chase a deeper file. """ try: import a2a_pack import os as _os base = _os.path.dirname(_os.path.abspath(a2a_pack.__file__)) except Exception as exc: # noqa: BLE001 return json.dumps({"error": f"a2a_pack not importable: {exc}"}) if path.startswith("/") or ".." in path.split("/"): return json.dumps({"error": "path must be relative; '..' disallowed"}) full = _os.path.abspath(_os.path.join(base, path)) if _os.path.commonpath([base, full]) != base: return json.dumps({"error": "path escapes package root"}) if not _os.path.isfile(full): return json.dumps({"error": f"not a file: {path}"}) try: with open(full, "r", encoding="utf-8") as fh: content = fh.read(64 * 1024 + 1) except OSError as exc: return json.dumps({"error": str(exc)}) truncated = len(content) > 64 * 1024 if truncated: content = content[: 64 * 1024] return json.dumps({ "path": path, "content": content, "truncated": truncated, }) return [ init_agent_template, list_agent_files, write_agent_file, read_agent_file, write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent, sync_agent_workspace_from_repo, list_a2a_pack, read_a2a_pack, ] # --- 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() added = False 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 if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): 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)) added = True return buf.getvalue() if added else b"" def _read_builder_state(s3: Any, bucket: str, name: str) -> dict[str, Any]: try: obj = s3.get_object(Bucket=bucket, Key=_builder_state_key(name)) except ClientError as exc: code = exc.response.get("Error", {}).get("Code") if code in {"404", "NoSuchKey", "NotFound"}: return {} return {} try: data = json.loads(obj["Body"].read().decode("utf-8")) except (TypeError, ValueError, UnicodeDecodeError): return {} return data if isinstance(data, dict) else {} def _write_builder_state( s3: Any, bucket: str, name: str, state: dict[str, Any], ) -> None: body = json.dumps(state, sort_keys=True, indent=2).encode("utf-8") s3.put_object( Bucket=bucket, Key=_builder_state_key(name), Body=body, ContentType="application/json", ) def _render_skill_md(skill_name: str, description: str, instructions: str) -> str: desc = " ".join(description.strip().split()) body = instructions.strip() if not desc: raise ValueError("skill description is required") if len(desc) > 1024: raise ValueError("skill description must be 1024 characters or less") if not body: raise ValueError("skill instructions are required") if "\n---" in body or body.startswith("---"): raise ValueError("skill instructions must not contain frontmatter delimiters") return ( "---\n" f"name: {skill_name}\n" "description: " + json.dumps(desc) + "\n" "---\n" f"# {skill_name}\n\n" + body + "\n" ) def _parse_supporting_skill_files(raw: str) -> dict[str, str]: try: parsed = json.loads(raw or "{}") except (TypeError, ValueError) as exc: raise ValueError("supporting_files_json must be a JSON object") from exc if not isinstance(parsed, dict): raise ValueError("supporting_files_json must be a JSON object") out: dict[str, str] = {} for path, content in parsed.items(): if not isinstance(path, str) or not isinstance(content, str): raise ValueError("supporting skill file paths and contents must be strings") rel = _safe_supporting_skill_path(path) if rel == "SKILL.md": raise ValueError("supporting_files_json cannot override SKILL.md") out[rel] = content return out def _safe_supporting_skill_path(path: str) -> str: rel = path.replace("\\", "/").lstrip("/") while rel.startswith("./"): rel = rel[2:] if not rel or ".." in rel.split("/") or rel.startswith("."): raise ValueError(f"unsafe supporting skill file path: {path!r}") if rel.endswith("/") or "//" in rel: raise ValueError(f"unsafe supporting skill file path: {path!r}") return rel def _delete_workspace_objects(s3: Any, bucket: str, prefix: str) -> None: # MinIO rejects boto3's batch DeleteObjects request in this environment # when Content-MD5 is not present. Per-object deletes avoid that fragile # API path and are fine for agent source workspaces. paginator = s3.get_paginator("list_objects_v2") for page in paginator.paginate(Bucket=bucket, Prefix=prefix): for obj in page.get("Contents") or (): key = obj.get("Key") if key: s3.delete_object(Bucket=bucket, Key=key) def _files_from_tarball(bundle: bytes) -> dict[str, bytes]: out: dict[str, bytes] = {} try: with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf: for member in tf.getmembers(): if not member.isfile(): continue rel = member.name while rel.startswith("./"): rel = rel[2:] if not rel or rel.startswith("/") or ".." in rel.split("/"): raise ValueError(f"unsafe member path: {member.name}") if rel == _BUILDER_STATE_FILE: continue extracted = tf.extractfile(member) if extracted is None: continue out[rel] = extracted.read() except tarfile.TarError as exc: raise ValueError(str(exc)) from exc return out def _replace_workspace_files( s3: Any, bucket: str, prefix: str, files: dict[str, bytes], ) -> list[dict[str, Any]]: _delete_workspace_objects(s3, bucket, prefix) written: list[dict[str, Any]] = [] for rel, body in sorted(files.items()): key = prefix + rel s3.put_object( Bucket=bucket, Key=key, Body=body, ContentType="application/octet-stream", ) written.append({"path": rel, "size": len(body)}) return written async def _latest_cp_deployment( cp_url: str, cp_jwt: str, name: str, ) -> dict[str, Any] | None: try: async with httpx.AsyncClient(timeout=15.0) as c: r = await c.get( f"{cp_url}/v1/agents/{name}/deployments", headers={"authorization": f"bearer {cp_jwt}"}, ) except httpx.HTTPError as exc: raise RuntimeError(f"cp unreachable: {exc}") from exc if r.status_code == 404: return None if r.status_code >= 400: raise RuntimeError(f"cp {r.status_code}: {r.text[:1000]}") body = r.json() or [] if not isinstance(body, list) or not body: return None first = body[0] return first if isinstance(first, dict) else None def _deployment_drift_error( name: str, latest_deployment: dict[str, Any] | None, builder_state: dict[str, Any], *, force: bool = False, ) -> dict[str, Any] | None: if force or latest_deployment is None: return None latest_head = latest_deployment.get("head_sha") base_head = builder_state.get("repo_head_sha") if not isinstance(base_head, str) or not base_head: return { "ok": False, "error": "workspace_untracked", "agent": name, "message": ( "This agent already has managed source, but the MinIO " "workspace has no deploy base marker. Refresh it before " "deploying, or use force=True only if the user wants to " "replace the repo." ), "current_head_sha": latest_head, "current_deployment_id": latest_deployment.get("deploy_id"), } return None 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 _read_bundle_text(bundle: bytes, path: str) -> str | None: try: with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf: for member in tf.getmembers(): if member.name == path and member.isfile(): extracted = tf.extractfile(member) if extracted is None: return None return extracted.read().decode("utf-8", "replace") except Exception: # noqa: BLE001 return None return None def _class_name(slug: str) -> str: return "".join(p.capitalize() for p in re.split(r"[-_]+", slug) if p) or "MyAgent" _FRONTEND_TEMPLATE_FILES: dict[str, dict[str, str]] = { "static": { "frontend/dist/index.html": "frontend/static-index.html.tmpl", }, "react": { "frontend/package.json": "frontend/react-package.json.tmpl", "frontend/index.html": "frontend/react-index.html.tmpl", "frontend/vite.config.js": "frontend/react-vite.config.js.tmpl", "frontend/src/main.jsx": "frontend/react-main.jsx.tmpl", "frontend/src/App.jsx": "frontend/react-app.jsx.tmpl", "frontend/src/a2a.js": "frontend/react-a2a.js.tmpl", "frontend/src/style.css": "frontend/react-style.css.tmpl", }, } def _normalize_frontend_kind(frontend: str | None) -> str: kind = (frontend or "none").strip().lower() if kind in {"", "no", "off", "false"}: kind = "none" if kind not in {"none", "static", "react"}: raise ValueError("frontend must be one of: none, static, react") return kind def _frontend_block(frontend: str) -> str: kind = _normalize_frontend_kind(frontend) if kind == "none": return "" build = "\n build: npm run build" if kind == "react" else "" return ( "frontend:\n" " path: frontend" f"{build}\n" " dist: dist\n" " mount: /app\n" " auth: inherit" ) def _render_frontend_files(frontend: str, *, name: str) -> dict[str, str]: kind = _normalize_frontend_kind(frontend) if kind == "none": return {} return { path: _render_sdk_template(template, name=name) for path, template in _FRONTEND_TEMPLATE_FILES[kind].items() } def _render_a2a_init_template( name: str, *, description: str = "A new A2A agent", frontend: str = "none", ) -> dict[str, str]: _validate_name(name) frontend_kind = _normalize_frontend_kind(frontend) class_name = _class_name(name) files = { "agent.py": _render_sdk_template( "agent.py.tmpl", name=name, class_name=class_name, description=description, ), "a2a.yaml": _render_sdk_template( "a2a.yaml.tmpl", name=name, class_name=class_name, frontend_block=_frontend_block(frontend_kind), ), "requirements.txt": _render_sdk_template("requirements.txt.tmpl"), } files.update(_render_frontend_files(frontend_kind, name=name)) return files def _render_sdk_template(template: str, /, **vars: str) -> str: parts = template.split("/") local_templates = ( Path(__file__).resolve().parents[2] / "a2a" / "a2a_pack" / "cli" / "templates" ) if local_templates.exists(): text = local_templates.joinpath(*parts).read_text(encoding="utf-8") else: resource = resources.files("a2a_pack.cli.templates") for part in parts: resource = resource.joinpath(part) text = resource.read_text(encoding="utf-8") for key, value in vars.items(): text = text.replace("{{ " + key + " }}", value) return text