483 lines
21 KiB
Python
483 lines
21 KiB
Python
"""Build the inner deepagents graph that writes + tests + deploys a new agent."""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deepagents import create_deep_agent
|
|
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
|
|
from deepagents.backends.utils import create_file_data
|
|
from langchain_openai import ChatOpenAI
|
|
from langgraph.store.memory import InMemoryStore
|
|
|
|
from .config import Settings, load_settings
|
|
from .tools import ToolContext, build_tools
|
|
|
|
|
|
BUILDER_SKILL_SOURCE = "/.agent-builder/skills/"
|
|
_BUILDER_SKILL_NAMESPACE = ("agent-builder", "skills")
|
|
_BUILDER_ARTIFACT_DIR = ".agent-builder"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BuilderContext:
|
|
bucket: str
|
|
cp_jwt: str | None = None
|
|
settings: Settings | None = None
|
|
project_prefix: str | None = None
|
|
workspace: Any | None = None
|
|
sandbox: Any | None = None
|
|
grant_token: str | None = None
|
|
# LLM creds forwarded by the orchestrator according to this agent's Card.
|
|
# The builder uses the caller's saved LLM credential, usually proxied
|
|
# through LiteLLM by the control plane. Falls back to settings only for
|
|
# local development.
|
|
llm_base_url: str | None = None
|
|
llm_api_key: str | None = None
|
|
llm_model: str | None = None
|
|
llm_temperature_mode: str | None = None
|
|
llm_temperature: float | None = None
|
|
llm_extra_body: dict[str, Any] | 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.
|
|
|
|
The default starter is the current a2a-pack DeepAgents scaffold. It declares
|
|
``LLMProvisioning.PLATFORM``, reads the caller's saved LLM credential from
|
|
``ctx.llm``, builds a ``ChatOpenAI`` model from those credentials, and wires a small
|
|
tool-calling DeepAgent with ``create_deep_agent`` plus ``wrap_model_call``
|
|
middleware. Do not recreate this from memory: call ``init_agent_template`` first
|
|
and modify the generated files.
|
|
|
|
Default hosted generated/user agents to ``LLMProvisioning.PLATFORM`` and
|
|
``caller_pays_llm=True`` so main-agent handoffs forward the caller's saved LLM
|
|
credential for the callee. The generated agent must still read ``ctx.llm`` and must never
|
|
read ``A2A_LITELLM_KEY``, ``OPENAI_API_KEY``, LiteLLM master keys, or provider
|
|
keys directly. ``LLMProvisioning.CALLER_PROVIDED`` is still acceptable for
|
|
explicit BYOK wording. Always make missing ``ctx.llm.api_key`` a clear setup/config result before constructing
|
|
``ChatOpenAI``.
|
|
``Pricing`` accepts only ``price_per_call_usd``, ``caller_pays_llm``, and
|
|
``notes``. Never set ``runtime.pricing.compute`` or
|
|
``runtime.pricing.total_usd`` in generated source; those are derived later by
|
|
the control plane/dashboard from declared ``Resources`` and billing policy.
|
|
``LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`` is retained only for backwards
|
|
compatibility; do not use it for ordinary generated agents unless the user
|
|
explicitly asks for that legacy mixed mode.
|
|
|
|
You have packaged DeepAgents skills loaded from ``/.agent-builder/skills/``.
|
|
Use ``deepagent-agent-design`` before designing generated agent internals,
|
|
``a2apack-agent-authoring`` when shaping the public A2A class and Card,
|
|
``deepagents-implementation-patterns`` when wiring tools/subagents/skills,
|
|
``workspace-artifact-safety`` when files or artifacts are involved, and
|
|
``agent-quality-review`` before the final sandbox test/deploy pass.
|
|
|
|
The generated ``a2a.yaml`` looks like:
|
|
|
|
```yaml
|
|
name: <slug>
|
|
version: 0.1.0
|
|
entrypoint: agent:<Name>
|
|
expose:
|
|
public: true
|
|
```
|
|
|
|
If the agent should ship with a browser UI, scaffold a packed frontend instead
|
|
of bolting on an external app. Call ``init_agent_template(..., frontend="react")``
|
|
for a Vite/React app that can read the live Agent Card, signed-in session, and
|
|
skill schemas from the deployed agent. Use ``frontend="static"`` only for a
|
|
simple no-build HTML app. The manifest block is:
|
|
|
|
```yaml
|
|
frontend:
|
|
path: frontend
|
|
build: npm run build
|
|
dist: dist
|
|
mount: /app
|
|
auth: inherit
|
|
```
|
|
|
|
Packed frontends are served from the same agent at ``/app``. They receive
|
|
generated runtime metadata from ``/app/config.json``, can call skills through
|
|
the generated endpoints, and can use the browser helper at
|
|
``/app/a2a-client.js``. Do not put platform secrets, provider keys, or private
|
|
runtime details in frontend source; the frontend should use the agent's public
|
|
skill schemas and inherited platform auth. For local development, run the
|
|
agent and then run the React dev server with ``A2A_DEV_AGENT_URL`` pointed at
|
|
the local agent. See ``https://docs.a2acloud.io/concepts/packed-frontends`` for
|
|
the deployment contract.
|
|
|
|
If the agent needs system binaries the Python-only base image doesn't
|
|
ship (ffmpeg, imagemagick, poppler-utils, sqlite3, etc.), declare
|
|
them under ``runtime.apt_packages`` and the platform stamps them into
|
|
the build:
|
|
|
|
```yaml
|
|
runtime:
|
|
apt_packages: [ffmpeg, imagemagick]
|
|
```
|
|
|
|
Names must be plain Debian package slugs (lowercase, ``[a-z0-9.+-]``).
|
|
Don't reach for this for Python deps — those go in ``requirements.txt``.
|
|
|
|
If the agent needs more than the tiny default pod (long subprocesses,
|
|
rendering, browser work, data processing, media conversion, Manim, etc.),
|
|
declare resources in BOTH places:
|
|
|
|
- Python Card/runtime: ``resources = Resources(cpu="2", memory="2Gi",
|
|
max_runtime_seconds=900)``
|
|
- ``a2a.yaml`` for the Gitea/Argo scaffold, which cannot import user code
|
|
before the image is built:
|
|
|
|
```yaml
|
|
runtime:
|
|
resources:
|
|
cpu: "2"
|
|
memory: 2Gi
|
|
max_runtime_seconds: 900
|
|
```
|
|
|
|
When the user asks to compose existing agents, build a declarative meta-agent
|
|
manifest instead of hand-writing orchestration code. The source of truth is a
|
|
JSON object with ``composition`` plus optional ``goal`` and ``memory``:
|
|
|
|
```json
|
|
{
|
|
"composition": {
|
|
"sub_agents": [
|
|
{"name": "writer-agent", "skills": ["draft"]},
|
|
{"tag": "charting", "skills": ["render_chart"], "required": false}
|
|
],
|
|
"max_nodes": 6,
|
|
"max_parallel": 2,
|
|
"max_replans": 1
|
|
},
|
|
"goal": {
|
|
"objective": "Ship a launch report",
|
|
"success_criteria": ["draft complete", "chart complete"]
|
|
},
|
|
"memory": {"tiers": ["files", "kv"], "namespace": "launch-report"}
|
|
}
|
|
```
|
|
|
|
Deploy that manifest with ``cp_compose_meta_agent``. That endpoint validates
|
|
children against the registry, generates editable ``MetaAgent`` source, commits
|
|
it, and deploys it through the same GitOps path. Use this path for
|
|
meta-agents unless the user explicitly asks for custom source.
|
|
|
|
For render/media/data agents, commands that create user-visible files MUST run
|
|
through ``await ctx.workspace_shell(...)`` or ``await ctx.workspace_python(...)``.
|
|
The platform sandbox persists ``/workspace`` writes directly and captures changed
|
|
files elsewhere in the guest rootfs under ``outputs/rootfs-captures/...``. Do
|
|
not use in-process ``asyncio.create_subprocess_exec`` for durable outputs; the
|
|
agent container is not workspace-mounted or rootfs-captured, so files created in
|
|
``/tmp`` or the image filesystem can vanish after the request. Commands must
|
|
still be bounded with timeouts and broad exception handling, and failures must
|
|
return structured JSON instead of crashing the worker. Use ``render: bool = False``
|
|
by default and expose every public skill argument with concrete type hints so
|
|
the live ``input_schema`` contains the full call surface. If a render command
|
|
returns nonzero after emitting a usable file, preserve the file and include the
|
|
command failure as a warning instead of dropping the result.
|
|
|
|
For Manim presentation agents specifically, generate a stable PDF fallback
|
|
that does not require Manim rendering. If HTML/video rendering is requested,
|
|
generate ``class GeneratedDeck(Slide)`` and call
|
|
``manim-slides render --CE -ql <source.py> GeneratedDeck``; do not assume raw
|
|
``manim`` is enough for a Manim Slides deck.
|
|
|
|
Generated agents that invoke an inner DeepAgents graph MUST pass
|
|
``config={"recursion_limit": 500}`` to ``graph.ainvoke(...)`` or
|
|
``graph.astream_events(...)``. This matches agent-builder's own runtime budget
|
|
and prevents complex skill/subagent workflows from failing at the default
|
|
LangGraph recursion cap.
|
|
|
|
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:
|
|
|
|
- init_agent_template(name, description, frontend="none")
|
|
— initialize agents/<name>/ from the
|
|
installed a2a-pack `a2a init` template.
|
|
Use frontend="react" for a packed app,
|
|
frontend="static" for simple bundled HTML,
|
|
or frontend="none" for a headless agent.
|
|
Use this FIRST for a new project, then
|
|
edit the generated files.
|
|
- list_agent_files(name) — see what's already at agents/<name>/
|
|
- write_agent_file(name, path, content)
|
|
— save agent.py / a2a.yaml / requirements.txt
|
|
/ frontend/* files
|
|
- read_agent_file(name, path) — re-read a file (for iteration)
|
|
- write_agent_skill(name, skill_name, description, instructions,
|
|
supporting_files_json="{}")
|
|
— create ``skills/<skill_name>/SKILL.md``
|
|
plus optional bundled resources for a
|
|
generated DeepAgent. Use this for rich
|
|
behavior before wiring ``skills=[...]``
|
|
in agent.py.
|
|
- test_agent_in_sandbox(name) — pip install + ``a2a card`` round-trip;
|
|
check exit_code == 0 and the card JSON
|
|
looks right. If a frontend is declared,
|
|
inspect the printed ``a2a frontend info``.
|
|
- cp_deploy_tarball(name, version="0.1.0", public=True, force=False)
|
|
— ship it to the platform; returns the
|
|
public URL. It refuses stale MinIO
|
|
workspaces if the managed repo changed
|
|
elsewhere; use force=True only after
|
|
the user explicitly accepts replacing
|
|
the current repo source.
|
|
- cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0",
|
|
public=True, refresh_existing=False)
|
|
— create/deploy a manifest-backed
|
|
meta-agent that composes existing agents.
|
|
Use for "compose these agents toward this
|
|
goal" requests instead of hand-writing
|
|
orchestration source.
|
|
- sync_agent_workspace_from_repo(name)
|
|
— replace agents/<name>/ in MinIO with
|
|
the current managed repo source and
|
|
record its repo base marker. Use this
|
|
before editing an existing deployed
|
|
agent, or when deploy reports
|
|
workspace_untracked/workspace_drift.
|
|
- cp_refresh_agent(name) — force the control plane to re-fetch
|
|
the agent's live card after an
|
|
out-of-band redeploy
|
|
- list_a2a_pack(subdir="") — browse the installed a2a_pack SDK
|
|
- read_a2a_pack(path) — read SDK source. Use these when
|
|
you're unsure what's exposed. The
|
|
code you scaffold runs on this
|
|
exact SDK, so reading it is the
|
|
authoritative reference — start
|
|
with ``read_a2a_pack("agent.py")``
|
|
for ``@skill`` semantics,
|
|
``context.py`` for ``RunContext``
|
|
(workspace, sandbox, emit_progress,
|
|
ask, collect, request_scope,
|
|
ensure_read/ensure_write),
|
|
``runtime.py`` for ``AgentRuntime``
|
|
fields (apt_packages, pricing,
|
|
egress, etc.).
|
|
|
|
Discipline:
|
|
|
|
- Pick a kebab-case slug for ``name`` (e.g. ``research-agent``,
|
|
``csv-sanitizer``). Class name is PascalCase from the slug.
|
|
- For a new project, call init_agent_template first. Then read/edit the
|
|
generated files instead of inventing boilerplate from memory.
|
|
- For a new meta-agent that only composes existing agents, call
|
|
cp_compose_meta_agent with a manifest. Do not scaffold a normal project
|
|
unless the user needs custom code beyond composition.
|
|
- Ensure all three core files (agent.py, a2a.yaml, requirements.txt)
|
|
exist before testing — partial scaffolds break ``a2a card``.
|
|
- When the user asks for a usable app, dashboard, charting UI, workflow UI,
|
|
or demo surface, prefer ``frontend="react"`` and customize
|
|
``frontend/src/App.jsx`` plus ``frontend/src/a2a.js`` around the public
|
|
``@skill`` schemas. Keep frontend calls routed through
|
|
``/app/config.json`` or the generated client/endpoints instead of
|
|
hard-coding deployment URLs.
|
|
- For non-trivial agents, create one or more DeepAgents skills under
|
|
``skills/<skill-name>/`` and wire ``create_deep_agent(..., skills=[...])``.
|
|
Do not replace LLM reasoning with fake deterministic tools that return
|
|
canned answers. Tools should do exact work; skills and subagents should
|
|
carry reusable workflow knowledge and judgement-heavy behavior.
|
|
- When invoking the generated DeepAgents graph, use
|
|
``config={"recursion_limit": 500}`` so child agents have the same graph
|
|
budget as agent-builder.
|
|
- If generated agent.py calls ``ctx.workspace_backend()``, declare
|
|
``workspace_access = WorkspaceAccess.dynamic(...)`` on the A2A agent class
|
|
so local/dev/platform invocations actually receive a workspace grant.
|
|
- 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.
|
|
- If cp_deploy_tarball returns workspace_drift or workspace_untracked,
|
|
use sync_agent_workspace_from_repo when the user wants the builder to
|
|
continue from the current repo. Do not use force=True unless the user
|
|
explicitly says to overwrite the managed repo with the current MinIO
|
|
files.
|
|
- After cp_deploy_tarball returns, compare ``live_skills[].input_schema``
|
|
with the skill signatures you wrote. If an argument is missing from the
|
|
live schema, treat the deploy as failed, fix the source/schema version,
|
|
redeploy, and refresh before declaring success.
|
|
- 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,
|
|
workspace=ctx.workspace,
|
|
grant_token=ctx.grant_token,
|
|
)
|
|
)
|
|
model_kwargs: dict[str, Any] = {
|
|
"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,
|
|
"stream_usage": True,
|
|
}
|
|
if ctx.llm_temperature_mode != "omit":
|
|
model_kwargs["temperature"] = (
|
|
ctx.llm_temperature if ctx.llm_temperature is not None else 0.0
|
|
)
|
|
if ctx.llm_extra_body:
|
|
model_kwargs["extra_body"] = dict(ctx.llm_extra_body)
|
|
model = ChatOpenAI(**model_kwargs)
|
|
kwargs: dict[str, Any] = {
|
|
"model": model,
|
|
"tools": tools,
|
|
"system_prompt": SYSTEM_PROMPT,
|
|
}
|
|
backend = _build_project_backend(ctx, settings=settings)
|
|
if backend is not None:
|
|
kwargs["backend"] = backend
|
|
kwargs["skills"] = [BUILDER_SKILL_SOURCE]
|
|
return create_deep_agent(**kwargs)
|
|
|
|
|
|
def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any | None:
|
|
"""Persist DeepAgents built-in file tools into this project directory.
|
|
|
|
The explicit builder tools remain the preferred path, but this prevents
|
|
a model-selected DeepAgents ``write_file`` from disappearing into
|
|
LangGraph state. Scope it to the project prefix, not the whole bucket.
|
|
"""
|
|
if not ctx.project_prefix:
|
|
return _with_builder_skills(StateBackend())
|
|
prefix = ctx.project_prefix.strip("/")
|
|
if not prefix:
|
|
return _with_builder_skills(StateBackend())
|
|
if ctx.workspace is not None:
|
|
try:
|
|
from a2a_pack.deepagents import WorkspaceBackend
|
|
except Exception: # noqa: BLE001
|
|
return _with_builder_skills(StateBackend())
|
|
return _with_builder_skills(
|
|
_workspace_backend_with_grep_compat(
|
|
WorkspaceBackend,
|
|
ctx.workspace,
|
|
sandbox=ctx.sandbox,
|
|
default_image=settings.image,
|
|
),
|
|
artifacts_root=f"/{prefix}/{_BUILDER_ARTIFACT_DIR}",
|
|
)
|
|
try:
|
|
from a2a_pack import Grant, WorkspaceAccess, WorkspaceMode
|
|
from a2a_pack.deepagents import WorkspaceBackend
|
|
from a2a_pack.workspace import MinIOWorkspaceClient
|
|
except Exception: # noqa: BLE001
|
|
return _with_builder_skills(StateBackend())
|
|
|
|
workspace = MinIOWorkspaceClient(
|
|
bucket=ctx.bucket,
|
|
endpoint_url=settings.minio_endpoint,
|
|
access_key_id=settings.minio_access_key,
|
|
secret_access_key=settings.minio_secret_key,
|
|
access=WorkspaceAccess.dynamic(
|
|
max_files=2000,
|
|
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
|
require_reason=False,
|
|
),
|
|
issuer="agent-builder",
|
|
)
|
|
workspace.install_grant(
|
|
Grant(
|
|
grant_id=f"agent-builder-{prefix.replace('/', '-')}",
|
|
issuer="agent-builder",
|
|
audience="agent-builder",
|
|
bucket=ctx.bucket,
|
|
mode=WorkspaceMode.READ_WRITE_OVERLAY,
|
|
allow_patterns=(f"{prefix}/**", "outputs/**"),
|
|
deny_patterns=(),
|
|
outputs_prefix=None,
|
|
write_prefixes=(f"{prefix}/", "outputs/"),
|
|
)
|
|
)
|
|
return _with_builder_skills(
|
|
_workspace_backend_with_grep_compat(
|
|
WorkspaceBackend,
|
|
workspace,
|
|
sandbox=ctx.sandbox,
|
|
default_image=settings.image,
|
|
),
|
|
artifacts_root=f"/{prefix}/{_BUILDER_ARTIFACT_DIR}",
|
|
)
|
|
|
|
|
|
def _workspace_backend_with_grep_compat(
|
|
workspace_backend_cls: Any,
|
|
workspace: Any,
|
|
**backend_kwargs: Any,
|
|
) -> Any:
|
|
"""Patch older a2a-pack WorkspaceBackend grep glob behavior.
|
|
|
|
Older ``WorkspaceBackend.grep`` only matched ``glob`` against the full
|
|
workspace key. DeepAgents commonly calls ``grep(path="/agents/name",
|
|
glob="agent.py")``, which should match ``agents/name/agent.py``.
|
|
"""
|
|
|
|
class BuilderWorkspaceBackend(workspace_backend_cls): # type: ignore[misc, valid-type]
|
|
def grep(
|
|
self,
|
|
pattern: str,
|
|
path: str | None = None,
|
|
glob: str | None = None,
|
|
) -> Any:
|
|
result = super().grep(pattern, path=path, glob=glob)
|
|
if not glob or not path or getattr(result, "matches", None):
|
|
return result
|
|
norm = getattr(self, "_norm", None)
|
|
base = norm(path) if callable(norm) else path.strip("/")
|
|
if not base:
|
|
return result
|
|
scoped_glob = f"{base.rstrip('/')}/{glob.lstrip('/')}"
|
|
if scoped_glob == glob:
|
|
return result
|
|
return super().grep(pattern, path=path, glob=scoped_glob)
|
|
|
|
return BuilderWorkspaceBackend(workspace, **backend_kwargs)
|
|
|
|
|
|
def _with_builder_skills(default_backend: Any, *, artifacts_root: str = "/") -> Any:
|
|
skill_store = InMemoryStore()
|
|
for path, content in _builder_skill_files().items():
|
|
skill_store.put(
|
|
_BUILDER_SKILL_NAMESPACE,
|
|
"/" + path,
|
|
create_file_data(content),
|
|
)
|
|
skill_backend = StoreBackend(
|
|
store=skill_store,
|
|
namespace=lambda _runtime: _BUILDER_SKILL_NAMESPACE,
|
|
)
|
|
return CompositeBackend(
|
|
default=default_backend,
|
|
routes={BUILDER_SKILL_SOURCE: skill_backend},
|
|
artifacts_root=artifacts_root,
|
|
)
|
|
|
|
|
|
def _builder_skill_files() -> dict[str, str]:
|
|
root = Path(__file__).with_name("skills")
|
|
files: dict[str, str] = {}
|
|
if not root.exists():
|
|
return files
|
|
for path in root.rglob("*"):
|
|
if path.is_file():
|
|
files[path.relative_to(root).as_posix()] = path.read_text(encoding="utf-8")
|
|
return files
|