72 Commits

Author SHA1 Message Date
ci
b5b90c92c4 ci: bump image to d182ade6fd 2026-06-13 16:42:57 +00:00
robert
d182ade6fd Save current dirty changes
All checks were successful
build / build (push) Successful in 48s
2026-06-13 13:41:40 -03:00
robert
4cb480d74b Use cluster Knative rollout default 2026-06-11 20:06:40 -03:00
robert
85181403f3 Harden agent-builder Knative rollout 2026-06-11 19:53:12 -03:00
robert
7c9e906ef1 Clamp agent-builder Knative timeout 2026-06-11 09:32:14 -03:00
ci
49f0ec4c60 ci: bump image to c5fdaba848 2026-06-08 23:40:12 +00:00
robert
c5fdaba848 Add explicit source repo deploy tool
All checks were successful
build / build (push) Successful in 50s
2026-06-08 20:39:16 -03:00
ci
03f51fe27b ci: bump image to 4cef2ef76f 2026-06-08 13:40:31 +00:00
robert
4cef2ef76f Handle deterministic agents without LLM creds
All checks were successful
build / build (push) Successful in 11s
2026-06-08 10:40:15 -03:00
ci
8c0dda8405 ci: bump image to e511295023 2026-06-08 12:41:55 +00:00
robert
e511295023 Use canonical builder source hash
All checks were successful
build / build (push) Successful in 13s
2026-06-08 09:41:40 -03:00
ci
4d1dcfb2df ci: bump image to 16cdf2c2be 2026-06-08 12:14:43 +00:00
robert
16cdf2c2be Align builder with a2a-pack 0.1.82
All checks were successful
build / build (push) Successful in 53s
2026-06-08 09:13:49 -03:00
ci
cd6c13288a ci: bump image to e6d159a853 2026-06-08 12:08:50 +00:00
robert
e6d159a853 Refresh builder runtime visibility
All checks were successful
build / build (push) Successful in 59s
2026-06-08 09:04:30 -03:00
ci
e5e75e2ea3 ci: bump image to 293550b56f 2026-06-08 11:14:29 +00:00
robert
293550b56f Raise builder complex run timeout
All checks were successful
build / build (push) Successful in 11s
2026-06-08 08:14:18 -03:00
ci
f132394aa7 ci: bump image to a5913d6c2c 2026-06-08 11:08:42 +00:00
robert
a5913d6c2c Bump agent builder runtime refresh
All checks were successful
build / build (push) Successful in 44s
2026-06-08 08:07:55 -03:00
ci
85647a3142 ci: bump image to b285f3cdc8 2026-06-08 01:54:11 +00:00
robert
b285f3cdc8 Require sandbox pass before builder deploy
All checks were successful
build / build (push) Successful in 12s
2026-06-07 22:53:57 -03:00
ci
49a41949ab ci: bump image to 4bb04d81ad 2026-06-08 01:32:50 +00:00
robert
4bb04d81ad Strengthen builder sandbox validation
All checks were successful
build / build (push) Successful in 15s
2026-06-07 22:32:35 -03:00
ci
9d3ccab800 ci: bump image to cd1c661109 2026-06-08 01:10:39 +00:00
robert
cd1c661109 Harden builder sandbox package install
All checks were successful
build / build (push) Successful in 10s
2026-06-07 22:10:23 -03:00
robert
aba68aaf2e Exclude cache artifacts from builder source packages
Some checks failed
build / build (push) Failing after 2s
2026-06-07 19:54:03 -03:00
ci
d7cb4b3f8f ci: bump image to c06fe8777f 2026-06-07 20:07:35 +00:00
robert
c06fe8777f Require a2a-pack 0.1.79
All checks were successful
build / build (push) Successful in 13s
2026-06-07 17:07:19 -03:00
ci
c4eebeebea ci: bump image to 96749be014 2026-06-07 12:20:05 +00:00
robert
96749be014 Reject deploy error URLs in builder
All checks were successful
build / build (push) Successful in 3s
2026-06-07 09:19:59 -03:00
ci
451a67e4f8 ci: bump image to c24676356d 2026-06-07 12:06:30 +00:00
robert
c24676356d Fix builder graph state capture
All checks were successful
build / build (push) Successful in 14s
2026-06-07 09:06:13 -03:00
robert
c679afc01f ci: rebuild after a2a-pack 0.1.76 publish 2026-06-06 21:57:26 -03:00
ci
b83e900dee ci: bump image to afdbda91bf 2026-06-07 00:54:24 +00:00
robert
afdbda91bf Bound builder runs and require a2a-pack 0.1.76
All checks were successful
build / build (push) Successful in 16s
2026-06-06 21:53:09 -03:00
ci
39b4760f38 ci: bump image to 3e18c7fa18 2026-06-06 23:42:40 +00:00
robert
3e18c7fa18 Avoid DeepAgents default_temperature kwarg
All checks were successful
build / build (push) Successful in 4s
2026-06-06 20:42:15 -03:00
ci
873ac0590a ci: bump image to d9cbd7dd52 2026-06-06 23:35:09 +00:00
robert
d9cbd7dd52 Use DeepAgents model resolver in builder graph
All checks were successful
build / build (push) Successful in 14s
2026-06-06 20:34:46 -03:00
ci
c3259f2022 ci: bump image to 137f84be6a 2026-06-06 23:26:43 +00:00
robert
137f84be6a Use sandbox client for builder validation
All checks were successful
build / build (push) Successful in 14s
2026-06-06 20:25:15 -03:00
ci
5d03074fa4 ci: bump image to 64fd7c26f2 2026-06-06 15:22:43 +00:00
robert
64fd7c26f2 Require user LLM credentials
All checks were successful
build / build (push) Successful in 17s
2026-06-06 12:21:48 -03:00
ci
00ad0b8790 ci: bump image to 47a686bf6b 2026-06-04 22:05:50 +00:00
robert
47a686bf6b Bump a2a-pack minimum to 0.1.60
All checks were successful
build / build (push) Successful in 17s
2026-06-04 19:00:26 -03:00
robert
b262371e03 Bump a2a-pack minimum to 0.1.59
Some checks failed
build / build (push) Failing after 2s
2026-06-04 18:50:15 -03:00
ci
086f5644dd ci: bump image to a49bebd8d9 2026-06-04 20:16:50 +00:00
robert
a49bebd8d9 Use public SDK DSL compiler
All checks were successful
build / build (push) Successful in 3s
2026-06-04 17:16:46 -03:00
ci
2a4f31bd97 ci: bump image to 6ab34c7200 2026-06-04 20:10:24 +00:00
robert
6ab34c7200 Send Agent DSL from agent-builder deploys
All checks were successful
build / build (push) Successful in 3s
2026-06-04 17:10:18 -03:00
robert
be37a2f248 Make Knative probe defaults explicit 2026-06-04 10:31:43 -03:00
robert
ec903e10a7 Run agent-builder as Knative platform service 2026-06-04 10:25:47 -03:00
ci
8d0faab0d4 ci: bump image to 8ccd5d0283 2026-06-04 00:59:10 +00:00
robert
8ccd5d0283 Update a2a-pack to 0.1.57
All checks were successful
build / build (push) Successful in 14s
2026-06-03 21:58:34 -03:00
ci
e0e8962639 ci: bump image to bc8de5a7ae 2026-06-04 00:51:36 +00:00
robert
bc8de5a7ae Forward sandbox into agent builder backend
All checks were successful
build / build (push) Successful in 4s
2026-06-03 21:51:30 -03:00
ci
74c99643b4 ci: bump image to 1c7ceace23 2026-06-04 00:49:28 +00:00
robert
1c7ceace23 Compat fix workspace grep globs
All checks were successful
build / build (push) Successful in 15s
2026-06-03 21:49:10 -03:00
ci
c206e75123 ci: bump image to 819d381efd 2026-06-03 17:34:30 +00:00
robert
819d381efd Update a2a-pack minimum to 0.1.56
All checks were successful
build / build (push) Successful in 14s
2026-06-03 14:34:00 -03:00
ci
3a6cc2017b ci: bump image to cedb84d3e1 2026-06-02 19:13:04 +00:00
robert
cedb84d3e1 Poll private deployed agents by canonical URL
All checks were successful
build / build (push) Successful in 4s
2026-06-02 16:12:58 -03:00
ci
05502a3cc0 ci: bump image to a0229a1e64 2026-06-02 18:31:06 +00:00
robert
a0229a1e64 Use Ed25519 runtime trust keys
All checks were successful
build / build (push) Successful in 14s
2026-06-02 15:30:50 -03:00
robert
daeadda8bb Declare agent-builder platform secret env 2026-06-02 14:53:57 -03:00
ci
c611973c49 ci: bump image to 1d70c0c1b1 2026-06-02 17:27:37 +00:00
robert
1d70c0c1b1 Run agent-builder on a2a-pack 0.1.52
All checks were successful
build / build (push) Successful in 14s
2026-06-02 14:27:20 -03:00
ci
5d6c356c9a ci: bump image to 9a90add738 2026-06-02 17:19:20 +00:00
robert
9a90add738 Sanitize generated pricing fields
All checks were successful
build / build (push) Successful in 4s
2026-06-02 14:19:13 -03:00
ci
c9818bda97 ci: bump image to 08195a46f8 2026-06-02 17:04:53 +00:00
robert
08195a46f8 Rebuild agent-builder on a2a-pack 0.1.51
All checks were successful
build / build (push) Successful in 16s
2026-06-02 14:04:34 -03:00
ci
610ccd9401 ci: bump image to d665563953 2026-06-02 01:48:28 +00:00
14 changed files with 1509 additions and 209 deletions

View File

@@ -35,6 +35,9 @@ The single `build(name, prompt)` skill:
- `cp_deploy_tarball(name, version, public)` — POSTs the tarball
to `/v1/agents/from-tarball` on the user's behalf using their
forwarded CP JWT
- `cp_deploy_source_repo(name)` — POSTs the current managed source repo
to `/v1/agents/{name}/source/deploy` when source edits already live in
Gitea
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).
@@ -47,7 +50,7 @@ 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-selected LLM creds when available, otherwise a platform-scoped LiteLLM grant (Card declares `llm_provisioning=platform_or_caller_provided`) | yes |
| `ctx.llm` | caller's saved LLM credential, routed by the platform (Card declares `llm_provisioning=platform`) | yes |
| `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
@@ -55,11 +58,9 @@ marketplace — the dashboard already surfaces these on the agent card.
## Pricing
`$0.10 / call` during the current pricing model. The builder uses your
selected LLM creds when present and falls back to a scoped platform grant.
The deployed agent is yours forever; subsequent invocations of a generated LLM
agent default to scoped platform LLM grants unless you explicitly generate a
BYOK/caller-paid agent.
`$0.10 / call` during the current pricing model. The builder uses your saved
LLM credential. The deployed agent is yours forever; subsequent invocations of
a generated LLM agent also use the caller's saved LLM credential via `ctx.llm`.
## How to call it

View File

@@ -1,5 +1,5 @@
name: agent-builder
version: 0.1.1
version: 0.1.10
entrypoint: agent:AgentBuilder
description: Generate, test, and deploy new a2a agents from natural language.
expose:

123
agent.py
View File

@@ -10,8 +10,8 @@ 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-selected LLM credentials when available, otherwise
a platform-scoped LiteLLM grant token for the builder's own DeepAgents loop
2. ``ctx.llm`` — the caller's saved LLM credential for the builder's own
DeepAgents loop
3. ``ctx.cp_jwt`` — the user's CP JWT, so cp_deploy_tarball can
POST to /v1/agents/from-tarball as the user.
"""
@@ -39,6 +39,8 @@ class BuilderConfig(BaseModel):
_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*")
DEEPAGENTS_RECURSION_LIMIT = 500
BUILDER_WALL_TIMEOUT_SECONDS = 60 * 60
BUILDER_GRANT_RUN_TIMEOUT_SECONDS = BUILDER_WALL_TIMEOUT_SECONDS - 60
class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
@@ -48,22 +50,21 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
"Writes the project into the user's workspace, validates it in a "
"sandbox, then ships it via the control plane."
)
version = "0.1.2"
version = "0.1.10"
config_model = BuilderConfig
auth_model = NoAuth
tools_used = ("deepagents", "langgraph", "a2a-pack", "litellm", "microsandbox")
llm_provisioning = LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED
llm_provisioning = LLMProvisioning.PLATFORM
wants_cp_jwt = True
workspace_access = WorkspaceAccess.dynamic(
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
)
pricing = Pricing(
price_per_call_usd=0.10,
caller_pays_llm=False,
caller_pays_llm=True,
notes=(
"Uses caller-selected LLM credentials when available; otherwise "
"uses a platform-scoped LiteLLM grant for the builder's own "
"Uses the caller's saved LLM credential for the builder's own "
"reasoning loop. Deployed agents remain yours."
),
)
@@ -76,14 +77,14 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
),
tags=["builder", "scaffold", "deepagents", "meta"],
stream=True,
timeout_seconds=1800,
timeout_seconds=BUILDER_WALL_TIMEOUT_SECONDS,
cost_class="expensive",
grant_mode="read_write_overlay",
grant_allow_patterns=("agents/{name}/**",),
grant_outputs_prefix="agents/{name}/",
grant_write_prefixes=("agents/{name}/",),
grant_ttl_seconds=1800,
grant_run_timeout_seconds=1740,
grant_ttl_seconds=BUILDER_WALL_TIMEOUT_SECONDS,
grant_run_timeout_seconds=BUILDER_GRANT_RUN_TIMEOUT_SECONDS,
grant_approval_timeout_seconds=180,
grant_scope_approval_timeout_seconds=120,
)
@@ -129,11 +130,16 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
await ctx.emit_progress(
f"llm: {creds.model} via {creds.source} ({creds.base_url})"
)
try:
sandbox = ctx.sandbox
except Exception: # noqa: BLE001 - sandbox is optional in local/MCP runs.
sandbox = None
graph = build_agent_builder(BuilderContext(
bucket=bucket, cp_jwt=cp_jwt,
project_prefix=f"agents/{name}",
workspace=workspace,
sandbox=sandbox,
grant_token=getattr(workspace, "_grant_token", None),
llm_base_url=creds.base_url,
llm_api_key=creds.api_key,
@@ -141,6 +147,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
llm_temperature_mode=creds.temperature_mode,
llm_temperature=creds.temperature,
llm_extra_body=creds.extra_body,
llm_metadata=getattr(creds, "metadata", None),
))
user_msg = (
@@ -161,8 +168,8 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
await asyncio.sleep(20)
await ctx.emit_progress(f"agent-builder still working on {name!r}")
heartbeat_task = asyncio.create_task(_heartbeat())
try:
async def _run_build_graph() -> None:
nonlocal deployed_url, final_state
async for event in graph.astream_events(
{"messages": [{"role": "user", "content": user_msg}]},
version="v2",
@@ -181,16 +188,52 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
raw = raw["input"]
await ctx.emit_progress(f"{tname}({_one_line(raw)})")
elif kind == "on_tool_end":
summary = _summarize(tname, data.get("output"))
output = data.get("output")
summary = _summarize(tname, 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"))
if tname == "cp_deploy_tarball":
deployed_url = deployed_url or _find_deploy_url(output, name)
elif kind == "on_chain_end" and not event.get("parent_ids"):
out = data.get("output")
if isinstance(out, dict):
final_state = out
heartbeat_task = asyncio.create_task(_heartbeat())
try:
await asyncio.wait_for(
_run_build_graph(),
timeout=BUILDER_WALL_TIMEOUT_SECONDS,
)
except TimeoutError:
return {
"ok": False,
"status": "failed",
"stop_reason": "builder_timeout",
"name": name,
"workspace_dir": f"agents/{name}/",
"error": (
"agent-builder exceeded its wall-clock budget before "
"reaching a terminal result"
),
}
except asyncio.CancelledError:
return {
"ok": False,
"status": "canceled",
"stop_reason": "builder_canceled",
"name": name,
"workspace_dir": f"agents/{name}/",
"error": "agent-builder was canceled before it reached a terminal result",
}
except Exception as exc: # noqa: BLE001
return {"error": f"build graph failed: {type(exc).__name__}: {exc}"}
return {
"ok": False,
"status": "failed",
"stop_reason": "builder_failed",
"name": name,
"workspace_dir": f"agents/{name}/",
"error": _build_failure_error(exc),
}
finally:
heartbeat_task.cancel()
with suppress(asyncio.CancelledError):
@@ -200,7 +243,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
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)
deployed_url = deployed_url or _find_agent_url(last_reply, name)
if not deployed_url:
return {
@@ -264,18 +307,52 @@ def _summarize(name: str, output: Any) -> str:
return str(output)[:120]
def _find_url(value: Any) -> str | None:
def _find_deploy_url(value: Any, agent_name: str) -> str | None:
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, ValueError):
return _find_agent_url(value, agent_name)
if not isinstance(value, dict):
return None
if value.get("error"):
return None
if value.get("ok") is False or value.get("live") is False:
return None
return _find_agent_url(value.get("url") or value.get("live_url"), agent_name)
def _find_agent_url(value: Any, agent_name: str) -> str | None:
expected = f"https://{agent_name}.a2acloud.io"
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"):
if isinstance(v, str) and v.rstrip("/") == expected:
return v
# JSON-stringified outputs from tools.
return _find_url(json.dumps(value))
return _find_agent_url(json.dumps(value), agent_name)
if isinstance(value, str):
m = _URL_RE.search(value)
return m.group(0) if m else None
for m in _URL_RE.finditer(value):
candidate = m.group(0).rstrip("/")
if candidate == expected:
return candidate
return None
# rebuild against a2a-pack 0.1.47 for manifest-backed meta-agent defaults
def _build_failure_error(exc: BaseException) -> str:
message = str(exc)
lowered = message.lower()
if (
"budget has been exceeded" in lowered
or "budget exceeded" in lowered
or "ratelimiterror" in type(exc).__name__.lower()
or "rate limit" in lowered
):
return (
"LLM budget exceeded before deployment; retry with a larger budget "
"or a smaller build scope"
)
return f"build graph failed: {type(exc).__name__}: {exc}"
# rebuild against a2a-pack 0.1.82 for LiteLLM metadata and long builder grants

View File

@@ -3,16 +3,16 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from deepagents import create_deep_agent
from a2a_pack.deepagents import create_a2a_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
from .tools import ToolContext, _should_include_source_path, build_tools
BUILDER_SKILL_SOURCE = "/.agent-builder/skills/"
@@ -27,17 +27,19 @@ class BuilderContext:
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 accepts caller-selected creds and falls back to platform
# grants. Hosted generated agents should usually use platform grants too.
# Falls back to settings for local development.
# 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
llm_metadata: dict[str, Any] | None = None
SYSTEM_PROMPT = """\
@@ -47,25 +49,35 @@ 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 scoped LLM grant 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
The default starter is the current a2a-pack DeepAgents scaffold. For agents
that call an LLM, it declares ``LLMProvisioning.PLATFORM``, reads the caller's
saved LLM credential from ``ctx.llm``, resolves the model through
``create_a2a_deep_agent``, and wires a small tool-calling DeepAgent with the
A2A DeepAgents model 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=False`` so main-agent handoffs mint a scoped A2A LiteLLM grant
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. If the user explicitly asks for BYOK or caller-paid inference,
use ``LLMProvisioning.CALLER_PROVIDED`` with ``caller_pays_llm=True`` and make
missing ``ctx.llm.api_key`` a clear setup/config result before constructing
``ChatOpenAI``.
``LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`` is reserved for trusted
platform/meta agents that should prefer caller-selected credentials and fall
back to a scoped platform grant; do not use it for ordinary generated agents
unless the user explicitly asks for that mixed billing mode.
For hosted generated/user agents that call an LLM, default 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 a DeepAgents model.
For explicitly deterministic/local-logic agents that do not call an LLM, remove
the starter's ``LLMProvisioning`` import and class ``llm_provisioning``
declaration, do not read ``ctx.llm``, and set
``Pricing(..., caller_pays_llm=False, ...)``. A no-LLM agent must be callable
without requiring the user's LLM credential.
``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,
@@ -230,6 +242,11 @@ Your tools:
elsewhere; use force=True only after
the user explicitly accepts replacing
the current repo source.
- cp_deploy_source_repo(name) — deploy the current managed Gitea source
repo without uploading MinIO files. Use
only when source changes already live in
the platform repo; normal builder
workspaces should use cp_deploy_tarball.
- cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0",
public=True, refresh_existing=False)
— create/deploy a manifest-backed
@@ -296,6 +313,8 @@ Discipline:
scaffold.
- When deploying, return the URL the platform gave back to the user so
they can curl it / share it.
- Source repo edits do not auto-deploy. If you edited the managed repo
directly, call cp_deploy_source_repo after the coherent edit set is done.
- 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
@@ -318,24 +337,20 @@ def build_agent_builder(ctx: BuilderContext) -> Any:
settings=settings,
cp_jwt=ctx.cp_jwt,
workspace=ctx.workspace,
sandbox=ctx.sandbox,
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)
llm_creds = SimpleNamespace(
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_mode=ctx.llm_temperature_mode or "default",
temperature=ctx.llm_temperature,
extra_body=dict(ctx.llm_extra_body or {}),
metadata=dict(ctx.llm_metadata or {}),
)
kwargs: dict[str, Any] = {
"model": model,
"tools": tools,
"system_prompt": SYSTEM_PROMPT,
}
@@ -343,7 +358,11 @@ def build_agent_builder(ctx: BuilderContext) -> Any:
if backend is not None:
kwargs["backend"] = backend
kwargs["skills"] = [BUILDER_SKILL_SOURCE]
return create_deep_agent(**kwargs)
return create_a2a_deep_agent(
ctx,
creds=llm_creds,
**kwargs,
)
def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any | None:
@@ -364,7 +383,12 @@ def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any |
except Exception: # noqa: BLE001
return _with_builder_skills(StateBackend())
return _with_builder_skills(
WorkspaceBackend(ctx.workspace),
_workspace_backend_with_grep_compat(
WorkspaceBackend,
ctx.workspace,
sandbox=ctx.sandbox,
default_image=settings.image,
),
artifacts_root=f"/{prefix}/{_BUILDER_ARTIFACT_DIR}",
)
try:
@@ -400,11 +424,57 @@ def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any |
)
)
return _with_builder_skills(
WorkspaceBackend(workspace),
_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 _all_paths(self) -> list[str]:
return [
path
for path in super()._all_paths()
if _should_include_source_path(path)
]
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():

View File

@@ -1,6 +1,6 @@
---
name: a2apack-agent-authoring
description: Author production a2a-pack agents with clear public @skill schemas, runtime declarations, platform LLM grants, optional caller-provided LLMs, workspace grants, pricing, resources, and secure platform capabilities. Use when writing or reviewing agent.py for an A2A agent.
description: Author production a2a-pack agents with clear public @skill schemas, runtime declarations, caller-funded LLM credentials, optional agent BYOK, workspace grants, pricing, resources, and secure platform capabilities. Use when writing or reviewing agent.py for an A2A agent.
---
# A2A Pack Agent Authoring
@@ -22,15 +22,25 @@ Use `A2AAgent` class attributes for runtime and marketplace behavior:
caller's control-plane token.
For hosted generated/user-owned agents that call an LLM, default to
`LLMProvisioning.PLATFORM` with `Pricing(..., caller_pays_llm=False, ...)` so
main-agent handoffs mint a scoped A2A LiteLLM grant for the callee. Use
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
caller-paid inference. Reserve `LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`
for trusted platform/meta agents that must work with either the caller's
selected creds or a platform fallback grant. In every mode, read `ctx.llm`;
`LLMProvisioning.PLATFORM` with `Pricing(..., caller_pays_llm=True, ...)` so
main-agent handoffs forward the caller's saved LLM credential for the callee.
`LLMProvisioning.CALLER_PROVIDED` is still acceptable for explicit BYOK wording.
Reserve `LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED` for legacy compatibility.
In every mode, read `ctx.llm`;
never read `A2A_LITELLM_KEY`, `OPENAI_API_KEY`, provider keys, or platform
secrets directly. If `ctx.llm.api_key` is empty, return a clear setup/config
result before constructing `ChatOpenAI`.
result before constructing a model or calling `create_a2a_deep_agent`.
For explicitly deterministic/local-logic agents that do not call an LLM, omit
the concrete class `llm_provisioning` declaration, remove unused
`LLMProvisioning` imports, do not read `ctx.llm`, and set
`Pricing(..., caller_pays_llm=False, ...)`. A no-LLM agent must be callable
without requiring the user's LLM credential.
`Pricing` only accepts `price_per_call_usd`, `caller_pays_llm`, and `notes`.
Do not put `runtime.pricing.compute`, `runtime.pricing.total_usd`, `compute=`,
or `total_usd=` in generated `agent.py`; the platform derives those values from
declared `Resources` and billing policy.
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
@@ -74,8 +84,8 @@ class ResearchAgent(A2AAgent[ResearchConfig, NoAuth]):
llm_provisioning = LLMProvisioning.PLATFORM
pricing = Pricing(
price_per_call_usd=0.05,
caller_pays_llm=False,
notes="Uses a scoped platform LLM grant through ctx.llm.",
caller_pays_llm=True,
notes="Uses the caller's saved LLM credential through ctx.llm.",
)
resources = Resources(cpu="1", memory="1Gi", max_runtime_seconds=900)
egress = EgressPolicy(allow_hosts=("api.openai.com",))

View File

@@ -30,17 +30,24 @@ Check these items:
arguments.
- The public schema is small and stable. No unbounded command strings, hidden
prompt fragments, or provider credentials as public inputs.
- The implementation reads `ctx.llm` for both `CALLER_PROVIDED` and
- LLM-backed implementations read `ctx.llm` for both `CALLER_PROVIDED` and
`PLATFORM` LLM provisioning. `PLATFORM_OR_CALLER_PROVIDED` is acceptable
only for trusted platform/meta agents and must also read `ctx.llm`.
- Hosted generated/user agents should default to
- Hosted generated/user agents that call an LLM should default to
`llm_provisioning = LLMProvisioning.PLATFORM` and
`Pricing(..., caller_pays_llm=False, ...)`. Use
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
caller-paid inference. In both modes, the code must use `ctx.llm` and never
read LiteLLM/provider keys directly.
- Any code path that builds `ChatOpenAI` checks `ctx.llm.api_key` first or
returns a clear setup/config result before the model constructor runs.
`Pricing(..., caller_pays_llm=True, ...)`. `LLMProvisioning.CALLER_PROVIDED`
is acceptable for explicit BYOK wording. In both modes, the code must use
`ctx.llm` and never read LiteLLM/provider keys directly.
- Deterministic/local-logic agents that do not call an LLM omit the concrete
`llm_provisioning` declaration, remove unused `LLMProvisioning` imports, do
not read `ctx.llm`, and use `Pricing(..., caller_pays_llm=False, ...)`.
Reject no-LLM agents that would require a user's LLM credential before
invoking deterministic skills.
- `Pricing` contains only `price_per_call_usd`, `caller_pays_llm`, and `notes`.
Reject `runtime.pricing.compute`, `runtime.pricing.total_usd`, `compute=`, or
`total_usd=` in `agent.py`; those are derived platform fields.
- DeepAgents code uses `create_a2a_deep_agent` or another `ctx.llm`-backed
resolver, and checks `ctx.llm.api_key` before model construction.
- Any use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
- Any project skills are seeded into the backend and passed with
`skills=skill_sources or None`.

View File

@@ -1,6 +1,6 @@
---
name: deepagent-agent-design
description: Design generated A2A agents around DeepAgents skills, subagents, durable workspace backends, and ctx.llm-backed reasoning. Use whenever building or modifying an agent, deciding whether to add tools, creating skill bundles, wiring create_deep_agent, or avoiding shallow fake tools.
description: Design generated A2A agents around DeepAgents skills, subagents, durable workspace backends, and ctx.llm-backed reasoning. Use whenever building or modifying an agent, deciding whether to add tools, creating skill bundles, wiring create_a2a_deep_agent, or avoiding shallow fake tools.
---
# DeepAgent Agent Design
@@ -13,8 +13,8 @@ planning, file work, skill selection, and subagent delegation.
Use this shape for non-trivial generated agents:
1. Keep one or two public A2A `@skill` methods with clear typed parameters.
2. Inside each method, read `ctx.llm`, verify credentials are available before
constructing `ChatOpenAI`, and build a DeepAgent with `create_deep_agent`.
2. Inside each method, read `ctx.llm`, verify credentials are available, and
build a DeepAgent with `create_a2a_deep_agent`.
3. Pass `backend=ctx.workspace_backend()` so DeepAgents file tools write to the
caller's durable workspace instead of LangGraph state.
4. Seed project skills into the current grant's write prefix and pass
@@ -44,7 +44,7 @@ linked from `SKILL.md`.
Project `skills/` files are source files in the agent image. DeepAgents loads
skills from its backend, so seed those files into the invocation workspace
before `create_deep_agent`:
before `create_a2a_deep_agent`:
```python
from pathlib import Path
@@ -84,8 +84,9 @@ Then:
```python
backend = ctx.workspace_backend()
skill_sources = _seed_runtime_skills(backend, ctx)
return create_deep_agent(
model=model,
return create_a2a_deep_agent(
ctx,
creds=creds,
backend=backend,
skills=skill_sources or None,
tools=[small_deterministic_tool],

View File

@@ -1,12 +1,12 @@
---
name: deepagents-implementation-patterns
description: Implement DeepAgents inside a2a-pack agents with real deterministic tools, project skills, custom subagents, workspace-backed files, and ctx.llm-backed ChatOpenAI models. Use when editing create_deep_agent calls or deciding what belongs in tools, skills, or subagents.
description: Implement DeepAgents inside a2a-pack agents with real deterministic tools, project skills, custom subagents, workspace-backed files, and ctx.llm-backed model resolution. Use when editing create_a2a_deep_agent calls or deciding what belongs in tools, skills, or subagents.
---
# DeepAgents Implementation Patterns
DeepAgents supplies the inner agent loop. In this platform, the reliable shape
is: `ctx.llm`-backed `ChatOpenAI`, A2A workspace backend, project DeepAgents
skills, and a small number of exact tools. Hosted generated agents should
is: `ctx.llm`-backed `create_a2a_deep_agent`, A2A workspace backend, project
DeepAgents skills, and a small number of exact tools. Hosted generated agents should
declare `LLMProvisioning.PLATFORM`; explicit BYOK agents can declare
`CALLER_PROVIDED`, but both modes still read the same `ctx.llm` object.
@@ -20,8 +20,7 @@ from pathlib import Path
from typing import Any
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from deepagents import create_deep_agent
from a2a_pack.deepagents import create_a2a_deep_agent
RUNTIME_SKILLS_DIR = "research-agent/.deepagents/skills/"
@@ -39,13 +38,6 @@ def build_graph(ctx: Any) -> Any:
"LLM credentials were not available; declare PLATFORM for hosted "
"generated agents or configure caller-provided credentials."
)
model = ChatOpenAI(
model=creds.model,
base_url=creds.base_url,
api_key=creds.api_key,
temperature=0.0,
)
@tool
def validate_citations(items_json: str) -> str:
"""Validate that each citation has title, url, and claim fields."""
@@ -58,8 +50,9 @@ def build_graph(ctx: Any) -> Any:
backend = ctx.workspace_backend()
skill_sources = seed_runtime_skills(backend, ctx)
return create_deep_agent(
model=model,
return create_a2a_deep_agent(
ctx,
creds=creds,
backend=backend,
skills=skill_sources or None,
tools=[validate_citations],

View File

@@ -26,7 +26,7 @@ Pass the backend into DeepAgents:
```python
backend = ctx.workspace_backend()
graph = create_deep_agent(model=model, backend=backend, skills=skill_sources or None)
graph = create_a2a_deep_agent(ctx, backend=backend, skills=skill_sources or None)
```
For stable, human-readable paths, ask model-created files to use

View File

@@ -3,16 +3,22 @@
- workspace file CRUD through the caller's scoped workspace grant
- sandbox python (for `a2a card` / `a2a validate` round-trips)
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
- cp_deploy_source_repo (call /v1/agents/{name}/source/deploy)
- cp_compose_meta_agent (call /v1/agents/compose with a manifest)
"""
from __future__ import annotations
import asyncio
import ast
import base64
import hashlib
import io
import json
import logging
import os
import re
import tarfile
import tempfile
import time
from dataclasses import dataclass
from importlib import resources
@@ -29,7 +35,9 @@ if TYPE_CHECKING:
from .config import Settings
A2A_PACK_MIN_VERSION = "0.1.47"
A2A_PACK_MIN_VERSION = "0.1.82"
A2A_PACK_SANDBOX_FALLBACK_VERSION = "0.1.82"
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
@@ -38,9 +46,24 @@ class ToolContext:
settings: "Settings"
cp_jwt: str | None = None
workspace: Any | None = None
sandbox: Any | None = None
grant_token: str | None = None
def _canonical_agent_url(name: str | None) -> str | None:
if not isinstance(name, str) or not name.strip():
return None
return f"https://{name.strip()}.a2acloud.io"
def _deploy_poll_url(body: dict[str, Any], name: str | None) -> str | None:
for key in ("expected_url", "url"):
value = body.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return _canonical_agent_url(name)
async def _wait_for_live_card(
url: str | None,
expected_version: str | None,
@@ -195,6 +218,18 @@ _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/"
_DERIVED_PRICING_FIELDS = frozenset({"compute", "total_usd"})
_EXCLUDED_SOURCE_PARTS = frozenset({
"__pycache__",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".venv",
"node_modules",
})
_EXCLUDED_SOURCE_SUFFIXES = (".pyc", ".pyo", ".pyd")
_EXCLUDED_SOURCE_PART_SUFFIXES = (".egg-info",)
def _validate_name(name: str) -> None:
@@ -221,6 +256,24 @@ def _builder_state_key(name: str) -> str:
return _agent_prefix(name) + _BUILDER_STATE_FILE
def _should_include_source_path(path: str) -> bool:
normalized = path.replace("\\", "/").strip("/")
if not normalized:
return False
parts = normalized.split("/")
if any(part in _EXCLUDED_SOURCE_PARTS for part in parts):
return False
if any(part.endswith(_EXCLUDED_SOURCE_PART_SUFFIXES) for part in parts):
return False
return not normalized.endswith(_EXCLUDED_SOURCE_SUFFIXES)
def _should_include_agent_rel_path(rel: str) -> bool:
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
return False
return _should_include_source_path(rel)
def build_tools(ctx: ToolContext) -> list[Any]:
bucket = ctx.bucket
settings = ctx.settings
@@ -288,9 +341,14 @@ def build_tools(ctx: ToolContext) -> list[Any]:
out: list[dict[str, Any]] = []
for key in store.iter_keys(prefix):
rel = key[len(prefix):]
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
if not _should_include_agent_rel_path(rel):
continue
out.append({"path": rel, "size": len(store.get(key))})
try:
size = len(store.get(key))
except FileNotFoundError:
logger.warning("Skipping disappeared source file during list: %s", key)
continue
out.append({"path": rel, "size": size})
return json.dumps({"agent": name, "files": out})
@tool
@@ -305,11 +363,23 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc:
return json.dumps({"error": str(exc)})
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
if not _should_include_agent_rel_path(rel):
return json.dumps({"error": f"{rel} is managed by agent-builder"})
sanitized_fields: tuple[str, ...] = ()
if rel == "agent.py":
content, sanitized_fields = _strip_unsupported_pricing_fields(content)
key = prefix + rel
store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8")
return json.dumps({"ok": True, "path": key, "size": len(content)})
encoded = content.encode("utf-8")
out: dict[str, Any] = {"ok": True, "path": key, "size": len(encoded)}
if sanitized_fields:
out["sanitized_agent_card_fields"] = list(sanitized_fields)
out["warning"] = (
"Removed derived Pricing fields that the Agent Card schema "
"does not accept. Declare Resources separately; compute and "
"total_usd are filled by the platform."
)
store.put(key, encoded, "text/plain; charset=utf-8")
return json.dumps(out)
@tool
def read_agent_file(name: str, path: str) -> str:
@@ -319,7 +389,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc:
return json.dumps({"error": str(exc)})
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
if not _should_include_agent_rel_path(rel):
return json.dumps({"error": f"{rel} is managed by agent-builder"})
key = prefix + rel
try:
@@ -388,6 +458,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
prefix = _agent_prefix(name)
except ValueError as exc:
return json.dumps({"error": str(exc)})
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
# Bundle the workspace files into the sandbox via base64 tar so
# the sandbox doesn't need MinIO read access for this skill.
@@ -396,10 +467,18 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return json.dumps({
"error": "no files in agents/" + name + "/ — write some first",
})
source_hash = _source_bundle_hash(bundle_bytes)
b64 = base64.b64encode(bundle_bytes).decode("ascii")
script = (
"set -e\n"
f"pip install --quiet 'a2a-pack>={A2A_PACK_MIN_VERSION}' >/dev/null\n"
"export PYTHONDONTWRITEBYTECODE=1\n"
"python -m pip install --quiet "
f"'a2a-pack>={A2A_PACK_MIN_VERSION}' >/dev/null || "
"(echo 'a2a-pack>="
f"{A2A_PACK_MIN_VERSION} unavailable; falling back to "
f"a2a-pack=={A2A_PACK_SANDBOX_FALLBACK_VERSION} for sandbox card validation' >&2; "
"python -m pip install --quiet --index-url https://pypi.org/simple "
f"'a2a-pack=={A2A_PACK_SANDBOX_FALLBACK_VERSION}' >/dev/null)\n"
"mkdir -p /tmp/agent\n"
f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n"
"cd /tmp/agent\n"
@@ -409,30 +488,199 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"fi\n"
"echo '--- card ---'\n"
"a2a card --project .\n"
"echo '--- undefined-name check ---'\n"
"python - <<'PY'\n"
"import ast, builtins, pathlib, sys\n"
"path = pathlib.Path('agent.py')\n"
"if not path.exists():\n"
" sys.exit(0)\n"
"tree = ast.parse(path.read_text(), filename=str(path))\n"
"builtins_scope = set(dir(builtins)) | {'__name__', '__file__', '__package__'}\n"
"globals_scope = set(builtins_scope)\n"
"def bind_target(target, scope):\n"
" if isinstance(target, ast.Name):\n"
" scope.add(target.id)\n"
" elif isinstance(target, (ast.Tuple, ast.List)):\n"
" for item in target.elts:\n"
" bind_target(item, scope)\n"
"for node in tree.body:\n"
" if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):\n"
" globals_scope.add(node.name)\n"
" elif isinstance(node, ast.Import):\n"
" for alias in node.names:\n"
" globals_scope.add((alias.asname or alias.name.split('.')[0]))\n"
" elif isinstance(node, ast.ImportFrom):\n"
" for alias in node.names:\n"
" if alias.name != '*':\n"
" globals_scope.add(alias.asname or alias.name)\n"
" elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):\n"
" targets = getattr(node, 'targets', None) or [getattr(node, 'target', None)]\n"
" for target in targets:\n"
" bind_target(target, globals_scope)\n"
"class UndefinedNameVisitor(ast.NodeVisitor):\n"
" def __init__(self):\n"
" self.scopes = [set(globals_scope)]\n"
" self.errors = []\n"
" def _defined(self, name):\n"
" return any(name in scope for scope in reversed(self.scopes))\n"
" def _bind_args(self, args, scope):\n"
" for arg in list(args.posonlyargs) + list(args.args) + list(args.kwonlyargs):\n"
" scope.add(arg.arg)\n"
" if args.vararg:\n"
" scope.add(args.vararg.arg)\n"
" if args.kwarg:\n"
" scope.add(args.kwarg.arg)\n"
" def visit_FunctionDef(self, node):\n"
" self.visit_function(node)\n"
" def visit_AsyncFunctionDef(self, node):\n"
" self.visit_function(node)\n"
" def visit_function(self, node):\n"
" for dec in node.decorator_list:\n"
" self.visit(dec)\n"
" for default in list(node.args.defaults) + list(node.args.kw_defaults):\n"
" if default is not None:\n"
" self.visit(default)\n"
" scope = set()\n"
" self._bind_args(node.args, scope)\n"
" self.scopes.append(scope)\n"
" for stmt in node.body:\n"
" self.visit(stmt)\n"
" self.scopes.pop()\n"
" def visit_ClassDef(self, node):\n"
" for dec in node.decorator_list:\n"
" self.visit(dec)\n"
" for base in node.bases:\n"
" self.visit(base)\n"
" self.scopes.append(set())\n"
" for stmt in node.body:\n"
" self.visit(stmt)\n"
" self.scopes.pop()\n"
" def visit_Import(self, node):\n"
" for alias in node.names:\n"
" self.scopes[-1].add(alias.asname or alias.name.split('.')[0])\n"
" def visit_ImportFrom(self, node):\n"
" for alias in node.names:\n"
" if alias.name != '*':\n"
" self.scopes[-1].add(alias.asname or alias.name)\n"
" def visit_Assign(self, node):\n"
" self.visit(node.value)\n"
" for target in node.targets:\n"
" bind_target(target, self.scopes[-1])\n"
" def visit_AnnAssign(self, node):\n"
" if node.annotation:\n"
" self.visit(node.annotation)\n"
" if node.value:\n"
" self.visit(node.value)\n"
" bind_target(node.target, self.scopes[-1])\n"
" def visit_For(self, node):\n"
" self.visit(node.iter)\n"
" bind_target(node.target, self.scopes[-1])\n"
" for stmt in node.body + node.orelse:\n"
" self.visit(stmt)\n"
" def visit_AsyncFor(self, node):\n"
" self.visit_For(node)\n"
" def visit_With(self, node):\n"
" for item in node.items:\n"
" self.visit(item.context_expr)\n"
" if item.optional_vars:\n"
" bind_target(item.optional_vars, self.scopes[-1])\n"
" for stmt in node.body:\n"
" self.visit(stmt)\n"
" def visit_AsyncWith(self, node):\n"
" self.visit_With(node)\n"
" def visit_ExceptHandler(self, node):\n"
" if node.type:\n"
" self.visit(node.type)\n"
" if node.name:\n"
" self.scopes[-1].add(node.name)\n"
" for stmt in node.body:\n"
" self.visit(stmt)\n"
" def _visit_comp(self, node):\n"
" self.scopes.append(set())\n"
" for gen in node.generators:\n"
" self.visit(gen.iter)\n"
" bind_target(gen.target, self.scopes[-1])\n"
" for cond in gen.ifs:\n"
" self.visit(cond)\n"
" for field in ('elt', 'key', 'value'):\n"
" value = getattr(node, field, None)\n"
" if value is not None:\n"
" self.visit(value)\n"
" self.scopes.pop()\n"
" def visit_ListComp(self, node):\n"
" self._visit_comp(node)\n"
" def visit_SetComp(self, node):\n"
" self._visit_comp(node)\n"
" def visit_DictComp(self, node):\n"
" self._visit_comp(node)\n"
" def visit_GeneratorExp(self, node):\n"
" self._visit_comp(node)\n"
" def visit_Name(self, node):\n"
" if isinstance(node.ctx, ast.Load) and not self._defined(node.id):\n"
" self.errors.append(f'{path}:{node.lineno}:{node.col_offset}: undefined name {node.id!r}')\n"
" elif isinstance(node.ctx, (ast.Store, ast.Del)):\n"
" self.scopes[-1].add(node.id)\n"
"visitor = UndefinedNameVisitor()\n"
"visitor.visit(tree)\n"
"if visitor.errors:\n"
" print('\\n'.join(visitor.errors))\n"
" sys.exit(1)\n"
"print('ok')\n"
"PY\n"
"if grep -q '^frontend:' a2a.yaml 2>/dev/null; then\n"
" echo '--- frontend ---'\n"
" a2a frontend info --project . || true\n"
"fi\n"
)
if ctx.sandbox is not None:
try:
out = await ctx.sandbox.run_shell(
script,
image=settings.image,
workspace=bucket,
memory_mib=512,
timeout_seconds=settings.sandbox_timeout_s,
)
except Exception as exc: # noqa: BLE001
return json.dumps({
"error": f"sandbox {type(exc).__name__}",
"detail": str(exc)[:1000],
})
result: dict[str, Any] = {
"exit_code": getattr(out, "exit_code", None),
"stdout": (getattr(out, "stdout", "") or "")[:4000],
"stderr": (getattr(out, "stderr", "") or "")[:4000],
}
if sanitized_fields:
result["sanitized_agent_card_fields"] = list(sanitized_fields)
_record_sandbox_result(store, name, source_hash=source_hash, result=result)
return json.dumps(result)
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
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,
"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({
result: dict[str, Any] = {
"exit_code": out.get("exit_code"),
"stdout": (out.get("stdout") or "")[:4000],
"stderr": (out.get("stderr") or "")[:4000],
})
}
if sanitized_fields:
result["sanitized_agent_card_fields"] = list(sanitized_fields)
_record_sandbox_result(store, name, source_hash=source_hash, result=result)
return json.dumps(result)
@tool
async def cp_deploy_tarball(
@@ -475,11 +723,20 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
builder_state = _read_builder_state(store, name)
sanitized_fields = _sanitize_agent_workspace_pricing(store, prefix)
bundle_bytes = _tarball_workspace_dir(store, prefix)
if not bundle_bytes:
return json.dumps({"error": f"no files at agents/{name}/"})
source_hash = _source_bundle_hash(bundle_bytes)
sandbox_error = _sandbox_gate_error(builder_state, source_hash=source_hash)
if sandbox_error is not None:
return json.dumps(sandbox_error)
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(store, name)
drift = _deployment_drift_error(
name,
latest_deploy,
@@ -492,11 +749,12 @@ def build_tools(ctx: ToolContext) -> list[Any]:
if force:
base_head = None
bundle_bytes = _tarball_workspace_dir(store, 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)}"
try:
agent_dsl_json = _compile_agent_dsl_json(bundle_bytes)
except Exception as exc: # noqa: BLE001
return json.dumps({"error": f"agent_dsl compile failed: {exc}"})
# NOTE: the CP from-tarball endpoint names the file field
# ``source`` (see apps/control-plane/control_plane/routes/agents.py).
@@ -505,6 +763,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"name": name, "version": version,
"entrypoint": entrypoint, "public": "true" if public else "false",
"description": "Built by agent-builder.",
"agent_dsl": agent_dsl_json,
}
if isinstance(base_head, str) and base_head:
data["base_head_sha"] = base_head
@@ -522,7 +781,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
body = r.json()
name_ = body.get("name")
version_ = body.get("version")
url_ = body.get("url")
url_ = _deploy_poll_url(body, str(name_) if name_ is not None else name)
head_sha = body.get("head_sha")
if isinstance(head_sha, str) and head_sha:
_write_builder_state(
@@ -561,6 +820,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
"workspace_base_head_sha": base_head,
"live": live,
}
if sanitized_fields:
out["sanitized_agent_card_fields"] = list(sanitized_fields)
if live:
out["live_version"] = live_card.get("version")
out["live_skills"] = [
@@ -580,6 +841,58 @@ def build_tools(ctx: ToolContext) -> list[Any]:
out["last_seen_version"] = live_card.get("version")
return json.dumps(out)
@tool
async def cp_deploy_source_repo(name: str) -> str:
"""Deploy the current managed source repo for ``name``.
Use this when source changes already live in the platform-managed
Gitea repo. Do not use it for normal MinIO builder workspaces; for
those, run ``test_agent_in_sandbox`` and then ``cp_deploy_tarball``.
Returns JSON from the control plane, including ``deploy_id`` and a
serialized ``deployment`` when one is queued.
"""
try:
_validate_name(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.post(
f"{settings.cp_url}/v1/agents/{name}/source/deploy",
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]})
payload = r.json()
body = payload if isinstance(payload, dict) else {}
deployment = body.get("deployment") if isinstance(body, dict) else None
url = None
if isinstance(deployment, dict):
url = deployment.get("agent_url")
out = {
"ok": True,
"name": body.get("agent_name") or name,
"status": body.get("status") or (deployment or {}).get("status"),
"url": url or _canonical_agent_url(name),
"head_sha": body.get("source_sha"),
"deployment_id": body.get("deploy_id"),
"deployment": deployment or {},
"already_deployed": bool(body.get("already_deployed")),
"skipped": bool(body.get("skipped")),
}
if body.get("summary"):
out["summary"] = body.get("summary")
if body.get("reason"):
out["reason"] = body.get("reason")
return json.dumps(out)
@tool
async def cp_compose_meta_agent(
name: str,
@@ -863,7 +1176,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return [
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball,
cp_compose_meta_agent, cp_refresh_agent,
cp_deploy_source_repo, cp_compose_meta_agent, cp_refresh_agent,
sync_agent_workspace_from_repo,
list_a2a_pack, read_a2a_pack,
]
@@ -884,11 +1197,13 @@ def _tarball_workspace_dir(
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for key in store.iter_keys(prefix):
rel = key[len(prefix):]
if not rel:
if not _should_include_agent_rel_path(rel):
continue
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
try:
body = store.get(key)
except FileNotFoundError:
logger.warning("Skipping disappeared source file during tarball: %s", key)
continue
body = store.get(key)
info = tarfile.TarInfo(name=rel)
info.size = len(body)
info.mode = 0o644
@@ -897,6 +1212,109 @@ def _tarball_workspace_dir(
return buf.getvalue() if added else b""
def _sanitize_agent_workspace_pricing(
store: _ObjectStore,
prefix: str,
) -> tuple[str, ...]:
key = prefix + "agent.py"
try:
source = store.get(key).decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
return ()
sanitized, fields = _strip_unsupported_pricing_fields(source)
if fields and sanitized != source:
store.put(key, sanitized.encode("utf-8"), "text/plain; charset=utf-8")
return fields
def _strip_unsupported_pricing_fields(source: str) -> tuple[str, tuple[str, ...]]:
"""Remove platform-derived pricing kwargs from generated Agent source."""
try:
tree = ast.parse(source)
except SyntaxError:
return source, ()
offsets = _line_offsets(source)
ranges: list[tuple[int, int, str]] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not _is_pricing_call(node.func):
continue
for keyword in node.keywords:
if keyword.arg not in _DERIVED_PRICING_FIELDS:
continue
if (
keyword.lineno is None
or keyword.end_lineno is None
or keyword.col_offset is None
or keyword.end_col_offset is None
):
continue
start = offsets[keyword.lineno - 1] + keyword.col_offset
end = offsets[keyword.end_lineno - 1] + keyword.end_col_offset
ranges.append((start, end, keyword.arg))
if not ranges:
return source, ()
sanitized = source
removed: set[str] = set()
for start, end, field in sorted(ranges, key=lambda item: item[0], reverse=True):
sanitized = _remove_keyword_source_range(sanitized, start, end)
removed.add(field)
return sanitized, tuple(sorted(removed))
def _line_offsets(source: str) -> list[int]:
offsets: list[int] = []
offset = 0
for line in source.splitlines(keepends=True):
offsets.append(offset)
offset += len(line)
return offsets or [0]
def _is_pricing_call(func: ast.expr) -> bool:
if isinstance(func, ast.Name):
return func.id == "Pricing"
if isinstance(func, ast.Attribute):
return func.attr == "Pricing"
return False
def _remove_keyword_source_range(source: str, start: int, end: int) -> str:
line_start = source.rfind("\n", 0, start) + 1
if source[line_start:start].strip() == "":
trailing = _skip_horizontal_whitespace(source, end)
if trailing < len(source) and source[trailing] == ",":
trailing = _skip_horizontal_whitespace(source, trailing + 1)
if trailing >= len(source) or source[trailing] in "\r\n":
if source[trailing: trailing + 2] == "\r\n":
trailing += 2
elif trailing < len(source):
trailing += 1
return source[:line_start] + source[trailing:]
trailing = _skip_horizontal_whitespace(source, end)
if trailing < len(source) and source[trailing] == ",":
trailing = _skip_horizontal_whitespace(source, trailing + 1)
if trailing < len(source) and source[trailing] == " ":
trailing += 1
return source[:start] + source[trailing:]
leading = start
while leading > 0 and source[leading - 1] in " \t":
leading -= 1
if leading > 0 and source[leading - 1] == ",":
return source[: leading - 1] + source[end:]
return source[:start] + source[end:]
def _skip_horizontal_whitespace(source: str, offset: int) -> int:
while offset < len(source) and source[offset] in " \t":
offset += 1
return offset
def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]:
try:
data = json.loads(store.get(_builder_state_key(name)).decode("utf-8"))
@@ -914,6 +1332,70 @@ def _write_builder_state(
store.put(_builder_state_key(name), body, "application/json")
def _source_bundle_hash(bundle: bytes) -> str:
digest = hashlib.sha256()
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
members = sorted(
(member for member in tf.getmembers() if member.isfile()),
key=lambda member: member.name,
)
for member in members:
extracted = tf.extractfile(member)
body = extracted.read() if extracted is not None else b""
digest.update(member.name.encode("utf-8"))
digest.update(b"\0")
digest.update(str(len(body)).encode("ascii"))
digest.update(b"\0")
digest.update(body)
digest.update(b"\0")
return digest.hexdigest()
def _record_sandbox_result(
store: _ObjectStore,
name: str,
*,
source_hash: str,
result: dict[str, Any],
) -> None:
state = _read_builder_state(store, name)
state["last_sandbox"] = {
"source_hash": source_hash,
"exit_code": result.get("exit_code"),
"updated_at": int(time.time()),
}
_write_builder_state(store, name, state)
def _sandbox_gate_error(
builder_state: dict[str, Any],
*,
source_hash: str,
) -> dict[str, Any] | None:
sandbox = builder_state.get("last_sandbox")
if not isinstance(sandbox, dict):
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Run test_agent_in_sandbox for the current source before deploying.",
}
if sandbox.get("source_hash") != source_hash:
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Source changed after the last sandbox pass; rerun test_agent_in_sandbox.",
"last_exit_code": sandbox.get("exit_code"),
}
if sandbox.get("exit_code") != 0:
return {
"ok": False,
"error": "sandbox_not_passed",
"message": "Last sandbox run failed; fix the source and rerun test_agent_in_sandbox.",
"last_exit_code": sandbox.get("exit_code"),
}
return None
def _parse_manifest_json(raw: str) -> dict[str, Any]:
try:
parsed = json.loads(raw or "{}")
@@ -997,7 +1479,7 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
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:
if not _should_include_agent_rel_path(rel):
continue
extracted = tf.extractfile(member)
if extracted is None:
@@ -1008,6 +1490,63 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
return out
def _compile_agent_dsl_json(bundle: bytes) -> str:
import sys
import yaml
from a2a_pack import apply_project_manifest, compile_agent_to_dsl
from a2a_pack.cli.loader import load_agent_class
files = _files_from_tarball(bundle)
with tempfile.TemporaryDirectory(prefix="agent-builder-dsl-") as tmp:
root = Path(tmp)
for rel, body in files.items():
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(body)
cfg = yaml.safe_load((root / "a2a.yaml").read_text(encoding="utf-8")) or {}
if not isinstance(cfg, dict):
raise ValueError("a2a.yaml must be a mapping")
language = str(cfg.get("language") or "python").strip().lower()
if language != "python":
raise ValueError(f"agent-builder deploy only supports python Agent DSL, got {language!r}")
entrypoint = str(cfg.get("entrypoint") or "").strip()
if not entrypoint:
raise ValueError("a2a.yaml entrypoint is required")
entrypoint_module = entrypoint.split(":", 1)[0].strip()
old_entrypoint_module = sys.modules.get(entrypoint_module)
old_dont_write_bytecode = os.environ.get("PYTHONDONTWRITEBYTECODE")
old_sys_dont_write_bytecode = sys.dont_write_bytecode
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
sys.dont_write_bytecode = True
try:
cls = load_agent_class(entrypoint, project_dir=root)
apply_project_manifest(cls, cfg)
dsl = compile_agent_to_dsl(
cls,
language="python",
entrypoint=entrypoint,
metadata={
"source": "agent-builder",
"project_manifest": {
key: value
for key, value in cfg.items()
if key in {"name", "version", "entrypoint", "frontend"}
},
},
)
finally:
if old_dont_write_bytecode is None:
os.environ.pop("PYTHONDONTWRITEBYTECODE", None)
else:
os.environ["PYTHONDONTWRITEBYTECODE"] = old_dont_write_bytecode
sys.dont_write_bytecode = old_sys_dont_write_bytecode
if old_entrypoint_module is None:
sys.modules.pop(entrypoint_module, None)
else:
sys.modules[entrypoint_module] = old_entrypoint_module
return dsl.model_dump_json()
def _replace_workspace_files(
store_or_s3: Any,
bucket_or_prefix: str,

View File

@@ -1,33 +1,32 @@
apiVersion: apps/v1
kind: Deployment
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: agent-builder
namespace: agents
annotations:
argocd.argoproj.io/sync-options: ServerSideApply=true
labels:
app: agent-builder
a2a/managed-by: control-plane
a2a/platform-agent: "true"
spec:
replicas: 1
revisionHistoryLimit: 1
selector:
matchLabels:
app: agent-builder
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
progressDeadlineSeconds: 900
template:
metadata:
labels:
app: agent-builder
a2a/platform-agent: "true"
annotations:
autoscaling.knative.dev/min-scale: "1"
autoscaling.knative.dev/max-scale: "2"
autoscaling.knative.dev/target: "10"
spec:
enableServiceLinks: false
terminationGracePeriodSeconds: 30
containerConcurrency: 10
timeoutSeconds: 1800
responseStartTimeoutSeconds: 1800
containers:
- name: agent
image: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
image: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
imagePullPolicy: Always
ports:
- containerPort: 8000
@@ -35,9 +34,9 @@ spec:
protocol: TCP
env:
- name: A2A_AGENT_IMAGE
value: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
value: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
- name: A2A_RENDER_IMAGE
value: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
value: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
- name: SANDBOX_URL
value: http://sandbox.sandbox.svc.cluster.local:8000
- name: A2A_SANDBOX_URL
@@ -51,15 +50,26 @@ spec:
- name: A2A_GRANT_VERIFYING_KEY
valueFrom:
secretKeyRef: {name: platform-secrets, key: grant_verifying_key}
- name: A2A_RECEIPT_SIGNING_KEY
valueFrom:
secretKeyRef: {name: platform-secrets, key: receipt_signing_key}
- name: A2A_REPLAY_SIGNING_KEY
valueFrom:
secretKeyRef: {name: platform-secrets, key: replay_signing_key}
- name: A2A_CP_URL
value: http://control-plane.control-plane.svc.cluster.local
startupProbe:
httpGet: {path: /healthz, port: 8000}
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 60
readinessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 8
periodSeconds: 5
failureThreshold: 3
failureThreshold: 6
successThreshold: 1
timeoutSeconds: 1
timeoutSeconds: 3
livenessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 25
@@ -67,46 +77,3 @@ spec:
resources:
requests: {cpu: 200m, memory: 512Mi}
limits: {cpu: "1", memory: 1Gi}
---
apiVersion: v1
kind: Service
metadata:
name: agent-builder
namespace: agents
annotations:
argocd.argoproj.io/sync-options: ServerSideApply=false,Replace=true
labels:
app: agent-builder
a2a/managed-by: control-plane
spec:
selector:
app: agent-builder
ports:
- name: http
port: 80
targetPort: 8000
protocol: TCP
---
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

View File

@@ -1,4 +1,4 @@
a2a-pack>=0.1.47
a2a-pack>=0.1.82
httpx>=0.27
boto3>=1.34
deepagents>=0.5.0

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from agent_builder.builder import (
BUILDER_SKILL_SOURCE,
@@ -10,10 +12,142 @@ from agent_builder.builder import (
_build_project_backend,
_builder_skill_files,
_with_builder_skills,
_workspace_backend_with_grep_compat,
)
from agent_builder.config import Settings
from agent_builder.tools import A2A_PACK_MIN_VERSION
from agent_builder.tools import A2A_PACK_MIN_VERSION, _strip_unsupported_pricing_fields
from deepagents.backends import StateBackend
from agent import AgentBuilder, BUILDER_WALL_TIMEOUT_SECONDS, _build_failure_error
class FakeWorkspace:
bucket = "user-1-files"
_grant_token = "grant-token"
class FakeRunContext:
workspace = FakeWorkspace()
cp_jwt = "cp-jwt"
sandbox = None
llm = SimpleNamespace(
model="gpt-5.5",
source="test",
base_url="http://litellm/v1",
api_key="sk-test",
temperature_mode="default",
temperature=None,
extra_body={},
)
def __init__(self) -> None:
self.progress: list[str] = []
async def emit_progress(self, message: str) -> None:
self.progress.append(message)
class FakeGraph:
async def astream_events(self, *_args, **_kwargs):
yield {
"event": "on_tool_end",
"name": "cp_deploy_tarball",
"data": {"output": {"url": "https://generated-agent.a2acloud.io"}},
}
yield {
"event": "on_chain_end",
"parent_ids": [],
"data": {
"output": {
"messages": [
{
"content": (
"Deployed at https://generated-agent.a2acloud.io"
)
}
]
}
},
}
class FakePlanLimitGraph:
async def astream_events(self, *_args, **_kwargs):
yield {
"event": "on_tool_end",
"name": "cp_deploy_tarball",
"data": {
"output": {
"error": "cp 402",
"detail": (
'{"detail":{"error":"plan_limit","feature":"agents",'
'"upgrade_url":"https://app.a2acloud.io/billing"}}'
),
}
},
}
yield {
"event": "on_chain_end",
"parent_ids": [],
"data": {
"output": {
"messages": [
{
"content": (
"Deployment failed. Upgrade: "
"https://app.a2acloud.io/billing"
)
}
]
}
},
}
class AgentBuilderBuildTests(unittest.IsolatedAsyncioTestCase):
def test_class_metadata_and_skill_policy(self) -> None:
self.assertEqual(AgentBuilder.name, "agent-builder")
self.assertEqual(AgentBuilder.version, "0.1.10")
self.assertTrue(AgentBuilder.wants_cp_jwt)
self.assertEqual(AgentBuilder.pricing.price_per_call_usd, 0.10)
self.assertTrue(AgentBuilder.pricing.caller_pays_llm)
spec = AgentBuilder._skills["build"] # type: ignore[attr-defined]
self.assertTrue(spec.stream)
self.assertEqual(spec.policy.timeout_seconds, BUILDER_WALL_TIMEOUT_SECONDS)
self.assertEqual(spec.policy.grant_outputs_prefix, "agents/{name}/")
self.assertEqual(spec.policy.grant_write_prefixes, ("agents/{name}/",))
async def test_build_captures_nested_graph_state_and_deployed_url(self) -> None:
ctx = FakeRunContext()
with patch("agent.build_agent_builder", return_value=FakeGraph()):
result = await AgentBuilder().build(
ctx,
name="generated-agent",
prompt="make a test agent",
public=False,
)
self.assertEqual(result["ok"], True)
self.assertEqual(result["url"], "https://generated-agent.a2acloud.io")
self.assertIn("Deployed at https://generated-agent.a2acloud.io", result["reply"])
self.assertIn("deployed: https://generated-agent.a2acloud.io", ctx.progress)
async def test_build_does_not_treat_billing_url_as_deployed_agent(self) -> None:
ctx = FakeRunContext()
with patch("agent.build_agent_builder", return_value=FakePlanLimitGraph()):
result = await AgentBuilder().build(
ctx,
name="generated-agent",
prompt="make a test agent",
public=False,
)
self.assertEqual(result["ok"], False)
self.assertNotIn("url", result)
self.assertIn("no live URL was found", result["warning"])
self.assertNotIn("deployed: https://app.a2acloud.io/billing", ctx.progress)
class BuilderPromptTests(unittest.TestCase):
@@ -25,25 +159,33 @@ class BuilderPromptTests(unittest.TestCase):
"config={\"recursion_limit\": DEEPAGENTS_RECURSION_LIMIT}",
agent_source,
)
self.assertIn("sandbox = ctx.sandbox", agent_source)
self.assertIn("sandbox=sandbox", agent_source)
self.assertIn("stream=True", agent_source)
self.assertIn('grant_write_prefixes=("agents/{name}/",)', agent_source)
def test_outer_builder_accepts_user_creds_or_platform_llm_grants(self) -> None:
def test_outer_builder_uses_user_llm_credentials(self) -> None:
agent_source = Path(__file__).resolve().parents[1].joinpath("agent.py").read_text()
self.assertIn(
"llm_provisioning = LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED",
"llm_provisioning = LLMProvisioning.PLATFORM",
agent_source,
)
self.assertIn("caller_pays_llm=False", agent_source)
self.assertIn("platform-scoped LiteLLM grant", agent_source)
self.assertIn("caller-selected LLM credentials", agent_source)
self.assertIn("caller_pays_llm=True", agent_source)
self.assertIn("caller's saved LLM credential", agent_source)
def test_builder_defaults_raise_timeout_budget(self) -> None:
agent_source = Path(__file__).resolve().parents[1].joinpath("agent.py").read_text()
config_source = Path(__file__).resolve().parents[1].joinpath(
"agent_builder/config.py"
).read_text()
self.assertIn("BUILDER_WALL_TIMEOUT_SECONDS = 60 * 60", agent_source)
self.assertIn("timeout_seconds=BUILDER_WALL_TIMEOUT_SECONDS", agent_source)
self.assertIn(
"grant_run_timeout_seconds=BUILDER_GRANT_RUN_TIMEOUT_SECONDS",
agent_source,
)
self.assertIn('os.environ.get("A2A_SANDBOX_TIMEOUT_S")', config_source)
self.assertIn('os.environ.get("SANDBOX_TIMEOUT_S", "1200")', config_source)
self.assertIn('deploy_wait_timeout_s=int(os.environ.get("DEPLOY_WAIT_TIMEOUT_S", "900"))', config_source)
@@ -56,16 +198,65 @@ class BuilderPromptTests(unittest.TestCase):
self.assertIn(f"a2a-pack>={A2A_PACK_MIN_VERSION}", requirements)
self.assertIn("a2a-pack>={A2A_PACK_MIN_VERSION}", tools_source)
def test_prompt_defaults_generated_agents_to_platform_llm_grants(self) -> None:
def test_prompt_routes_llm_and_no_llm_agents_differently(self) -> None:
self.assertIn("LLMProvisioning.PLATFORM", SYSTEM_PROMPT)
self.assertIn("caller_pays_llm=False", SYSTEM_PROMPT)
self.assertIn("main-agent handoffs mint a scoped A2A LiteLLM grant", SYSTEM_PROMPT)
self.assertIn("caller_pays_llm=True", SYSTEM_PROMPT)
self.assertIn("handoffs forward the caller's saved LLM credential", SYSTEM_PROMPT)
self.assertIn("ctx.llm.api_key", SYSTEM_PROMPT)
self.assertIn("deterministic/local-logic agents", SYSTEM_PROMPT)
self.assertIn("caller_pays_llm=False", SYSTEM_PROMPT)
self.assertIn("A no-LLM agent must be callable", SYSTEM_PROMPT)
self.assertIn("without requiring the user's LLM credential", SYSTEM_PROMPT)
files = _builder_skill_files()
self.assertIn("LLMProvisioning.PLATFORM", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("caller_pays_llm=True", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("caller_pays_llm=False", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("ctx.llm.api_key", files["agent-quality-review/SKILL.md"])
self.assertIn("Deterministic/local-logic agents", files["agent-quality-review/SKILL.md"])
self.assertIn("require a user's LLM credential", files["agent-quality-review/SKILL.md"])
def test_prompt_rejects_platform_derived_pricing_fields(self) -> None:
self.assertIn("runtime.pricing.compute", SYSTEM_PROMPT)
self.assertIn("runtime.pricing.total_usd", SYSTEM_PROMPT)
self.assertIn("price_per_call_usd", SYSTEM_PROMPT)
files = _builder_skill_files()
for path in (
"a2apack-agent-authoring/SKILL.md",
"agent-quality-review/SKILL.md",
):
self.assertIn("runtime.pricing.compute", files[path])
self.assertIn("runtime.pricing.total_usd", files[path])
self.assertIn("compute=", files[path])
self.assertIn("total_usd=", files[path])
def test_pricing_sanitizer_removes_derived_agent_card_fields(self) -> None:
source = '''
from a2a_pack import Pricing
pricing = Pricing(
price_per_call_usd=0.05,
compute={
"cpu_usd": 0.000833,
"memory_usd": 0.00018,
},
total_usd=0.10125,
caller_pays_llm=False,
notes="caller gets the real billing projection from the platform",
)
inline = Pricing(price_per_call_usd=0.01, compute={"runtime_seconds": 600}, total_usd=0.02, caller_pays_llm=False)
'''
sanitized, removed = _strip_unsupported_pricing_fields(source)
self.assertEqual(set(removed), {"compute", "total_usd"})
self.assertIn("price_per_call_usd=0.05", sanitized)
self.assertIn("caller_pays_llm=False", sanitized)
self.assertNotIn("compute=", sanitized)
self.assertNotIn("total_usd=", sanitized)
compile(sanitized, "agent.py", "exec")
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
@@ -117,7 +308,7 @@ class BuilderPromptTests(unittest.TestCase):
self.assertIn("skills=skill_sources or None", files["deepagent-agent-design/SKILL.md"])
self.assertIn("from a2a_pack import", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("ctx.workspace_shell", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("create_deep_agent", files["deepagents-implementation-patterns/SKILL.md"])
self.assertIn("create_a2a_deep_agent", files["deepagents-implementation-patterns/SKILL.md"])
self.assertIn('config={"recursion_limit": 500}', files["deepagents-implementation-patterns/SKILL.md"])
self.assertIn("ctx.write_artifact", files["workspace-artifact-safety/SKILL.md"])
self.assertIn("ctx.workspace_shell", files["workspace-artifact-safety/SKILL.md"])
@@ -132,6 +323,13 @@ class BuilderPromptTests(unittest.TestCase):
for skill_name in expected:
self.assertIn(f"{BUILDER_SKILL_SOURCE}{skill_name}/", paths)
def test_build_failure_error_classifies_budget_exhaustion(self) -> None:
self.assertEqual(
_build_failure_error(RuntimeError("Budget has been exceeded! Key=abc")),
"LLM budget exceeded before deployment; retry with a larger budget "
"or a smaller build scope",
)
def test_project_backend_keeps_deepagents_artifacts_inside_project_grant(self) -> None:
backend = _build_project_backend(
BuilderContext(
@@ -159,3 +357,83 @@ class BuilderPromptTests(unittest.TestCase):
backend.artifacts_root,
"/agents/svgwrite-agent/.agent-builder",
)
def test_project_backend_grep_compat_qualifies_relative_glob(self) -> None:
class _LegacyBackend:
def __init__(self, workspace: object) -> None:
self.workspace = workspace
self.calls: list[tuple[str | None, str | None]] = []
def _norm(self, path: str | None) -> str:
return (path or "").strip("/")
def grep(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
):
self.calls.append((path, glob))
matches = (
[{"path": "/agents/github-repo-inspector/agent.py"}]
if glob == "agents/github-repo-inspector/agent.py"
else []
)
return type("GrepResult", (), {"error": None, "matches": matches})()
backend = _workspace_backend_with_grep_compat(_LegacyBackend, object())
result = backend.grep(
"_github_client",
path="/agents/github-repo-inspector",
glob="agent.py",
)
self.assertEqual(
backend.calls,
[
("/agents/github-repo-inspector", "agent.py"),
(
"/agents/github-repo-inspector",
"agents/github-repo-inspector/agent.py",
),
],
)
self.assertEqual(
result.matches,
[{"path": "/agents/github-repo-inspector/agent.py"}],
)
def test_project_backend_grep_compat_preserves_sandbox_kwargs(self) -> None:
class _Backend:
def __init__(
self,
workspace: object,
*,
sandbox: object | None = None,
default_image: str = "python:3.11-slim",
) -> None:
self.workspace = workspace
self.sandbox = sandbox
self.default_image = default_image
def grep(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
):
return type("GrepResult", (), {"error": None, "matches": []})()
workspace = object()
sandbox = object()
backend = _workspace_backend_with_grep_compat(
_Backend,
workspace,
sandbox=sandbox,
default_image="python:3.12-slim",
)
self.assertIs(backend.workspace, workspace)
self.assertIs(backend.sandbox, sandbox)
self.assertEqual(backend.default_image, "python:3.12-slim")

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import gzip
import io
import json
import tarfile
@@ -8,16 +9,20 @@ import unittest
from unittest.mock import patch
from agent_builder.config import Settings
from agent_builder.builder import _workspace_backend_with_grep_compat
from agent_builder.tools import (
_BUILDER_STATE_FILE,
_BUILDER_INTERNAL_PREFIX,
_WorkspaceObjectStore,
_parse_manifest_json,
_deploy_poll_url,
_deployment_drift_error,
_files_from_tarball,
_parse_supporting_skill_files,
_replace_workspace_files,
_render_a2a_init_template,
_render_skill_md,
_source_bundle_hash,
_tarball_workspace_dir,
ToolContext,
build_tools,
@@ -46,7 +51,7 @@ class TemplateInitTests(unittest.TestCase):
self.assertIn("skills=skill_sources or None", files["agent.py"])
self.assertIn("ctx.llm", files["agent.py"])
self.assertIn("ctx.workspace_backend()", files["agent.py"])
self.assertIn("create_deep_agent", files["agent.py"])
self.assertIn("create_a2a_deep_agent", files["agent.py"])
self.assertIn("name: research-agent", files["a2a.yaml"])
self.assertIn("entrypoint: agent:ResearchAgent", files["a2a.yaml"])
self.assertNotIn("{{ frontend_block }}", files["a2a.yaml"])
@@ -159,6 +164,44 @@ class TemplateInitTests(unittest.TestCase):
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def test_tarball_workspace_excludes_python_cache_artifacts(self) -> None:
prefix = "agents/demo-agent/"
s3 = _FakeS3({
prefix + "agent.py": b"print('ok')\n",
prefix + "__pycache__/agent.cpython-311.pyc": b"bytecode",
prefix + "helper.pyc": b"bytecode",
prefix + ".pytest_cache/v/cache/nodeids": b"[]",
prefix + "demo_agent.egg-info/PKG-INFO": b"metadata",
})
bundle = _tarball_workspace_dir(s3, "bucket", prefix)
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def test_tarball_workspace_skips_disappeared_files(self) -> None:
prefix = "agents/demo-agent/"
store = _FakeStaleStore(
objects={prefix + "agent.py": b"print('ok')\n"},
stale_keys={prefix + "generated.txt"},
)
bundle = _tarball_workspace_dir(store, prefix)
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def test_builder_workspace_backend_filters_cache_artifacts(self) -> None:
backend_cls = _workspace_backend_with_grep_compat(
_FakeDeepAgentsBackend,
object(),
)
self.assertEqual(
backend_cls._all_paths(),
["agents/demo-agent/agent.py"],
)
def test_render_skill_md_creates_valid_frontmatter(self) -> None:
text = _render_skill_md(
"market-research",
@@ -271,6 +314,250 @@ class TemplateInitTests(unittest.TestCase):
self.assertIn("manifest_json must be a JSON object", result["error"])
def test_test_agent_in_sandbox_uses_attached_sandbox_client(self) -> None:
workspace = _FakeWorkspace()
prefix = "agents/demo-agent/"
workspace.write_bytes(prefix + "agent.py", b"print('ok')\n")
workspace.write_bytes(prefix + "a2a.yaml", b"name: demo-agent\n")
workspace.write_bytes(prefix + "requirements.txt", b"")
sandbox = _FakeSandbox()
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(),
workspace=workspace,
sandbox=sandbox,
grant_token="grant-token",
)
)
test_tool = _tool_by_name(tools, "test_agent_in_sandbox")
_FakeAsyncClient.posts = []
with patch("agent_builder.tools.httpx.AsyncClient", _FakeAsyncClient):
result = json.loads(
asyncio.run(test_tool.ainvoke({"name": "demo-agent"}))
)
self.assertEqual(result["exit_code"], 0)
self.assertEqual(len(sandbox.calls), 1)
self.assertEqual(sandbox.calls[0]["workspace"], "bucket")
self.assertIn(
"export PYTHONDONTWRITEBYTECODE=1",
str(sandbox.calls[0]["script"]),
)
self.assertIn("a2a-pack>=0.1.82 unavailable", str(sandbox.calls[0]["script"]))
self.assertIn("a2a-pack==0.1.82", str(sandbox.calls[0]["script"]))
self.assertEqual(_FakeAsyncClient.posts, [])
def test_cp_deploy_tarball_posts_agent_dsl(self) -> None:
workspace = _FakeWorkspace()
prefix = "agents/demo-agent/"
files = {
"agent.py": "\n".join(
[
"from a2a_pack import A2AAgent, NoAuth, RunContext, skill",
"",
"class DemoAgent(A2AAgent[None, NoAuth]):",
' name = "demo-agent"',
' description = "Demo agent"',
' version = "0.1.0"',
" auth_model = NoAuth",
"",
' @skill(description="Echo text")',
" async def echo(self, ctx: RunContext[NoAuth], text: str) -> dict:",
" return {'ok': True, 'text': text}",
"",
]
),
"a2a.yaml": "\n".join(
[
"name: demo-agent",
"version: 0.1.0",
"entrypoint: agent:DemoAgent",
"expose:",
" public: false",
"",
]
),
"requirements.txt": "",
}
for rel, content in files.items():
workspace.write_bytes(prefix + rel, content.encode("utf-8"))
source_hash = _source_bundle_hash(
_tarball_workspace_dir(_WorkspaceObjectStore(workspace), prefix)
)
workspace.write_bytes(
prefix + _BUILDER_STATE_FILE,
json.dumps({
"last_sandbox": {
"source_hash": source_hash,
"exit_code": 0,
}
}).encode("utf-8"),
)
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(deploy_wait_timeout_s=0),
workspace=workspace,
cp_jwt="jwt-user",
)
)
deploy = _tool_by_name(tools, "cp_deploy_tarball")
async def _not_live(*args: object, **kwargs: object):
return False, {}
async def _no_latest(*args: object, **kwargs: object):
return None
_FakeAsyncClient.posts = []
with (
patch("agent_builder.tools.httpx.AsyncClient", _FakeAsyncClient),
patch("agent_builder.tools._wait_for_live_card", _not_live),
patch("agent_builder.tools._latest_cp_deployment", _no_latest),
):
result = json.loads(
asyncio.run(
deploy.ainvoke(
{
"name": "demo-agent",
"version": "0.1.0",
"public": False,
}
)
)
)
self.assertFalse(result["ok"])
self.assertEqual(len(_FakeAsyncClient.posts), 1)
post = _FakeAsyncClient.posts[0]
self.assertEqual(post["url"], "http://cp.test/v1/agents/from-tarball")
self.assertEqual(post["headers"]["authorization"], "bearer jwt-user")
data = post["data"]
self.assertEqual(data["name"], "demo-agent")
self.assertEqual(data["entrypoint"], "agent:DemoAgent")
self.assertEqual(data["public"], "false")
dsl = json.loads(data["agent_dsl"])
self.assertEqual(dsl["name"], "demo-agent")
self.assertEqual(dsl["version"], "0.1.0")
self.assertEqual(dsl["entrypoint"]["module"], "agent")
self.assertEqual(dsl["entrypoint"]["class_name"], "DemoAgent")
def test_cp_deploy_tarball_refuses_without_current_sandbox_pass(self) -> None:
workspace = _FakeWorkspace()
prefix = "agents/demo-agent/"
workspace.write_bytes(prefix + "agent.py", b"print('ok')\n")
workspace.write_bytes(
prefix + "a2a.yaml",
b"name: demo-agent\nversion: 0.1.0\nentrypoint: agent:DemoAgent\n",
)
workspace.write_bytes(prefix + "requirements.txt", b"")
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(deploy_wait_timeout_s=0),
workspace=workspace,
cp_jwt="jwt-user",
)
)
deploy = _tool_by_name(tools, "cp_deploy_tarball")
_FakeAsyncClient.posts = []
result = json.loads(
asyncio.run(
deploy.ainvoke(
{
"name": "demo-agent",
"version": "0.1.0",
"public": False,
}
)
)
)
self.assertEqual(result["error"], "sandbox_not_passed")
self.assertEqual(_FakeAsyncClient.posts, [])
def test_cp_deploy_source_repo_posts_manual_source_deploy(self) -> None:
tools = build_tools(
ToolContext(
bucket="bucket",
settings=_settings(deploy_wait_timeout_s=0),
workspace=_FakeWorkspace(),
cp_jwt="jwt-user",
)
)
deploy = _tool_by_name(tools, "cp_deploy_source_repo")
posts: list[dict[str, object]] = []
class _Response:
status_code = 200
text = "{}"
def json(self) -> dict[str, object]:
return {
"summary": "Queued source deployment for demo-agent",
"agent_name": "demo-agent",
"deploy_id": "dpl_source",
"source_sha": "c" * 40,
"deployment": {
"status": "building",
"agent_url": "https://demo-agent.a2acloud.io",
},
}
class _Client:
def __init__(self, *args: object, **kwargs: object) -> None:
pass
async def __aenter__(self) -> "_Client":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def post(self, url: str, *, headers: dict[str, str]) -> _Response:
posts.append({"url": url, "headers": headers})
return _Response()
with patch("agent_builder.tools.httpx.AsyncClient", _Client):
result = json.loads(
asyncio.run(deploy.ainvoke({"name": "demo-agent"}))
)
self.assertTrue(result["ok"])
self.assertEqual(result["deployment_id"], "dpl_source")
self.assertEqual(result["head_sha"], "c" * 40)
self.assertEqual(result["status"], "building")
self.assertEqual(posts, [
{
"url": "http://cp.test/v1/agents/demo-agent/source/deploy",
"headers": {"authorization": "bearer jwt-user"},
}
])
def test_source_bundle_hash_ignores_archive_metadata(self) -> None:
def bundle_with_mtime(mtime: int) -> bytes:
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w") as tf:
body = b"print('ok')\n"
info = tarfile.TarInfo(name="agent.py")
info.size = len(body)
info.mode = 0o644
info.mtime = mtime
tf.addfile(info, io.BytesIO(body))
compressed = io.BytesIO()
with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=mtime) as gz:
gz.write(raw.getvalue())
return compressed.getvalue()
first = bundle_with_mtime(1)
second = bundle_with_mtime(2)
self.assertNotEqual(first, second)
self.assertEqual(_source_bundle_hash(first), _source_bundle_hash(second))
def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None:
workspace = _FakeWorkspace()
tools = build_tools(
@@ -356,6 +643,22 @@ class TemplateInitTests(unittest.TestCase):
self.assertIn("no CP JWT", result["error"])
def test_deploy_poll_url_falls_back_to_private_canonical_route(self) -> None:
self.assertEqual(
_deploy_poll_url({"url": None}, "private-agent"),
"https://private-agent.a2acloud.io",
)
self.assertEqual(
_deploy_poll_url(
{
"expected_url": "https://expected.a2acloud.io",
"url": "https://public.a2acloud.io",
},
"private-agent",
),
"https://expected.a2acloud.io",
)
def _tarball(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
@@ -396,6 +699,36 @@ class _FakeS3:
raise AssertionError("batch DeleteObjects should not be used")
class _FakeStaleStore:
def __init__(self, objects: dict[str, bytes], stale_keys: set[str]) -> None:
self.objects = objects
self.stale_keys = stale_keys
def iter_keys(self, prefix: str) -> list[str]:
return sorted(
key
for key in [*self.objects, *self.stale_keys]
if key.startswith(prefix)
)
def get(self, key: str) -> bytes:
if key in self.stale_keys:
raise FileNotFoundError(key)
return self.objects[key]
class _FakeDeepAgentsBackend:
def __init__(self, workspace: object, **kwargs: object) -> None:
pass
def _all_paths(self) -> list[str]:
return [
"agents/demo-agent/agent.py",
"agents/demo-agent/__pycache__/agent.cpython-311.pyc",
"agents/demo-agent/.pytest_cache/v/cache/nodeids",
]
class _FakePaginator:
def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = objects
@@ -429,6 +762,21 @@ class _FakeWorkspace:
self.objects.pop(key, None)
class _FakeExecResult:
exit_code = 0
stdout = "--- card ---\n{}"
stderr = ""
class _FakeSandbox:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
async def run_shell(self, script: str, **kwargs: object) -> _FakeExecResult:
self.calls.append({"script": script, **kwargs})
return _FakeExecResult()
def _tool_by_name(tools: list[object], name: str) -> object:
for tool in tools:
if getattr(tool, "name", None) == name:
@@ -480,14 +828,23 @@ class _FakeAsyncClient:
url: str,
*,
headers: dict[str, str],
json: dict[str, object],
json: dict[str, object] | None = None,
data: dict[str, object] | None = None,
files: dict[str, object] | None = None,
) -> _FakeResponse:
self.posts.append({"url": url, "headers": headers, "json": json})
self.posts.append({
"url": url,
"headers": headers,
"json": json,
"data": data,
"files": files,
})
payload = json or data or {}
return _FakeResponse(
201,
{
"name": json["name"],
"version": json["version"],
"name": payload["name"],
"version": payload["version"],
"status": "building",
"expected_url": "https://report-meta.a2acloud.io",
"deployment_id": "dpl_123",