Compare commits
72 Commits
meta-agent
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5b90c92c4 | ||
|
|
d182ade6fd | ||
|
|
4cb480d74b | ||
|
|
85181403f3 | ||
|
|
7c9e906ef1 | ||
|
|
49f0ec4c60 | ||
|
|
c5fdaba848 | ||
|
|
03f51fe27b | ||
|
|
4cef2ef76f | ||
|
|
8c0dda8405 | ||
|
|
e511295023 | ||
|
|
4d1dcfb2df | ||
|
|
16cdf2c2be | ||
|
|
cd6c13288a | ||
|
|
e6d159a853 | ||
|
|
e5e75e2ea3 | ||
|
|
293550b56f | ||
|
|
f132394aa7 | ||
|
|
a5913d6c2c | ||
|
|
85647a3142 | ||
|
|
b285f3cdc8 | ||
|
|
49a41949ab | ||
|
|
4bb04d81ad | ||
|
|
9d3ccab800 | ||
|
|
cd1c661109 | ||
|
|
aba68aaf2e | ||
|
|
d7cb4b3f8f | ||
|
|
c06fe8777f | ||
|
|
c4eebeebea | ||
|
|
96749be014 | ||
|
|
451a67e4f8 | ||
|
|
c24676356d | ||
|
|
c679afc01f | ||
|
|
b83e900dee | ||
|
|
afdbda91bf | ||
|
|
39b4760f38 | ||
|
|
3e18c7fa18 | ||
|
|
873ac0590a | ||
|
|
d9cbd7dd52 | ||
|
|
c3259f2022 | ||
|
|
137f84be6a | ||
|
|
5d03074fa4 | ||
|
|
64fd7c26f2 | ||
|
|
00ad0b8790 | ||
|
|
47a686bf6b | ||
|
|
b262371e03 | ||
|
|
086f5644dd | ||
|
|
a49bebd8d9 | ||
|
|
2a4f31bd97 | ||
|
|
6ab34c7200 | ||
|
|
be37a2f248 | ||
|
|
ec903e10a7 | ||
|
|
8d0faab0d4 | ||
|
|
8ccd5d0283 | ||
|
|
e0e8962639 | ||
|
|
bc8de5a7ae | ||
|
|
74c99643b4 | ||
|
|
1c7ceace23 | ||
|
|
c206e75123 | ||
|
|
819d381efd | ||
|
|
3a6cc2017b | ||
|
|
cedb84d3e1 | ||
|
|
05502a3cc0 | ||
|
|
a0229a1e64 | ||
|
|
daeadda8bb | ||
|
|
c611973c49 | ||
|
|
1d70c0c1b1 | ||
|
|
5d6c356c9a | ||
|
|
9a90add738 | ||
|
|
c9818bda97 | ||
|
|
08195a46f8 | ||
|
|
610ccd9401 |
13
README.md
13
README.md
@@ -35,6 +35,9 @@ The single `build(name, prompt)` skill:
|
|||||||
- `cp_deploy_tarball(name, version, public)` — POSTs the tarball
|
- `cp_deploy_tarball(name, version, public)` — POSTs the tarball
|
||||||
to `/v1/agents/from-tarball` on the user's behalf using their
|
to `/v1/agents/from-tarball` on the user's behalf using their
|
||||||
forwarded CP JWT
|
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
|
2. Streams the graph's tool calls back to the dashboard as
|
||||||
`agent_progress` events (so the user watches the build happen in
|
`agent_progress` events (so the user watches the build happen in
|
||||||
real time, not in silence).
|
real time, not in silence).
|
||||||
@@ -47,7 +50,7 @@ Three platform-managed bits land on the inner skill via `RunContext`:
|
|||||||
| Field | Source | Required? |
|
| Field | Source | Required? |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ctx.workspace.bucket` | grant minted by the orchestrator | yes |
|
| `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` |
|
| `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
|
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
|
## Pricing
|
||||||
|
|
||||||
`$0.10 / call` during the current pricing model. The builder uses your
|
`$0.10 / call` during the current pricing model. The builder uses your saved
|
||||||
selected LLM creds when present and falls back to a scoped platform grant.
|
LLM credential. The deployed agent is yours forever; subsequent invocations of
|
||||||
The deployed agent is yours forever; subsequent invocations of a generated LLM
|
a generated LLM agent also use the caller's saved LLM credential via `ctx.llm`.
|
||||||
agent default to scoped platform LLM grants unless you explicitly generate a
|
|
||||||
BYOK/caller-paid agent.
|
|
||||||
|
|
||||||
## How to call it
|
## How to call it
|
||||||
|
|
||||||
|
|||||||
2
a2a.yaml
2
a2a.yaml
@@ -1,5 +1,5 @@
|
|||||||
name: agent-builder
|
name: agent-builder
|
||||||
version: 0.1.1
|
version: 0.1.10
|
||||||
entrypoint: agent:AgentBuilder
|
entrypoint: agent:AgentBuilder
|
||||||
description: Generate, test, and deploy new a2a agents from natural language.
|
description: Generate, test, and deploy new a2a agents from natural language.
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
123
agent.py
123
agent.py
@@ -10,8 +10,8 @@ This agent only works when invoked through the platform orchestrator,
|
|||||||
because it needs three things forwarded from the caller:
|
because it needs three things forwarded from the caller:
|
||||||
|
|
||||||
1. ``ctx.workspace.bucket`` — the user's MinIO bucket
|
1. ``ctx.workspace.bucket`` — the user's MinIO bucket
|
||||||
2. ``ctx.llm`` — caller-selected LLM credentials when available, otherwise
|
2. ``ctx.llm`` — the caller's saved LLM credential for the builder's own
|
||||||
a platform-scoped LiteLLM grant token for the builder's own DeepAgents loop
|
DeepAgents loop
|
||||||
3. ``ctx.cp_jwt`` — the user's CP JWT, so cp_deploy_tarball can
|
3. ``ctx.cp_jwt`` — the user's CP JWT, so cp_deploy_tarball can
|
||||||
POST to /v1/agents/from-tarball as the user.
|
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/?#%&=.-]*")
|
_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*")
|
||||||
DEEPAGENTS_RECURSION_LIMIT = 500
|
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]):
|
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 "
|
"Writes the project into the user's workspace, validates it in a "
|
||||||
"sandbox, then ships it via the control plane."
|
"sandbox, then ships it via the control plane."
|
||||||
)
|
)
|
||||||
version = "0.1.2"
|
version = "0.1.10"
|
||||||
|
|
||||||
config_model = BuilderConfig
|
config_model = BuilderConfig
|
||||||
auth_model = NoAuth
|
auth_model = NoAuth
|
||||||
tools_used = ("deepagents", "langgraph", "a2a-pack", "litellm", "microsandbox")
|
tools_used = ("deepagents", "langgraph", "a2a-pack", "litellm", "microsandbox")
|
||||||
llm_provisioning = LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED
|
llm_provisioning = LLMProvisioning.PLATFORM
|
||||||
wants_cp_jwt = True
|
wants_cp_jwt = True
|
||||||
workspace_access = WorkspaceAccess.dynamic(
|
workspace_access = WorkspaceAccess.dynamic(
|
||||||
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
allowed_modes=(WorkspaceMode.READ_ONLY, WorkspaceMode.READ_WRITE_OVERLAY),
|
||||||
)
|
)
|
||||||
pricing = Pricing(
|
pricing = Pricing(
|
||||||
price_per_call_usd=0.10,
|
price_per_call_usd=0.10,
|
||||||
caller_pays_llm=False,
|
caller_pays_llm=True,
|
||||||
notes=(
|
notes=(
|
||||||
"Uses caller-selected LLM credentials when available; otherwise "
|
"Uses the caller's saved LLM credential for the builder's own "
|
||||||
"uses a platform-scoped LiteLLM grant for the builder's own "
|
|
||||||
"reasoning loop. Deployed agents remain yours."
|
"reasoning loop. Deployed agents remain yours."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -76,14 +77,14 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
),
|
),
|
||||||
tags=["builder", "scaffold", "deepagents", "meta"],
|
tags=["builder", "scaffold", "deepagents", "meta"],
|
||||||
stream=True,
|
stream=True,
|
||||||
timeout_seconds=1800,
|
timeout_seconds=BUILDER_WALL_TIMEOUT_SECONDS,
|
||||||
cost_class="expensive",
|
cost_class="expensive",
|
||||||
grant_mode="read_write_overlay",
|
grant_mode="read_write_overlay",
|
||||||
grant_allow_patterns=("agents/{name}/**",),
|
grant_allow_patterns=("agents/{name}/**",),
|
||||||
grant_outputs_prefix="agents/{name}/",
|
grant_outputs_prefix="agents/{name}/",
|
||||||
grant_write_prefixes=("agents/{name}/",),
|
grant_write_prefixes=("agents/{name}/",),
|
||||||
grant_ttl_seconds=1800,
|
grant_ttl_seconds=BUILDER_WALL_TIMEOUT_SECONDS,
|
||||||
grant_run_timeout_seconds=1740,
|
grant_run_timeout_seconds=BUILDER_GRANT_RUN_TIMEOUT_SECONDS,
|
||||||
grant_approval_timeout_seconds=180,
|
grant_approval_timeout_seconds=180,
|
||||||
grant_scope_approval_timeout_seconds=120,
|
grant_scope_approval_timeout_seconds=120,
|
||||||
)
|
)
|
||||||
@@ -129,11 +130,16 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
await ctx.emit_progress(
|
await ctx.emit_progress(
|
||||||
f"llm: {creds.model} via {creds.source} ({creds.base_url})"
|
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(
|
graph = build_agent_builder(BuilderContext(
|
||||||
bucket=bucket, cp_jwt=cp_jwt,
|
bucket=bucket, cp_jwt=cp_jwt,
|
||||||
project_prefix=f"agents/{name}",
|
project_prefix=f"agents/{name}",
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
|
sandbox=sandbox,
|
||||||
grant_token=getattr(workspace, "_grant_token", None),
|
grant_token=getattr(workspace, "_grant_token", None),
|
||||||
llm_base_url=creds.base_url,
|
llm_base_url=creds.base_url,
|
||||||
llm_api_key=creds.api_key,
|
llm_api_key=creds.api_key,
|
||||||
@@ -141,6 +147,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
llm_temperature_mode=creds.temperature_mode,
|
llm_temperature_mode=creds.temperature_mode,
|
||||||
llm_temperature=creds.temperature,
|
llm_temperature=creds.temperature,
|
||||||
llm_extra_body=creds.extra_body,
|
llm_extra_body=creds.extra_body,
|
||||||
|
llm_metadata=getattr(creds, "metadata", None),
|
||||||
))
|
))
|
||||||
|
|
||||||
user_msg = (
|
user_msg = (
|
||||||
@@ -161,8 +168,8 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
await asyncio.sleep(20)
|
await asyncio.sleep(20)
|
||||||
await ctx.emit_progress(f"agent-builder still working on {name!r}")
|
await ctx.emit_progress(f"agent-builder still working on {name!r}")
|
||||||
|
|
||||||
heartbeat_task = asyncio.create_task(_heartbeat())
|
async def _run_build_graph() -> None:
|
||||||
try:
|
nonlocal deployed_url, final_state
|
||||||
async for event in graph.astream_events(
|
async for event in graph.astream_events(
|
||||||
{"messages": [{"role": "user", "content": user_msg}]},
|
{"messages": [{"role": "user", "content": user_msg}]},
|
||||||
version="v2",
|
version="v2",
|
||||||
@@ -181,16 +188,52 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
raw = raw["input"]
|
raw = raw["input"]
|
||||||
await ctx.emit_progress(f"→ {tname}({_one_line(raw)})")
|
await ctx.emit_progress(f"→ {tname}({_one_line(raw)})")
|
||||||
elif kind == "on_tool_end":
|
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}")
|
await ctx.emit_progress(f" {tname} ← {summary}")
|
||||||
# Capture URL from any tool output that mentions one.
|
if tname == "cp_deploy_tarball":
|
||||||
deployed_url = deployed_url or _find_url(data.get("output"))
|
deployed_url = deployed_url or _find_deploy_url(output, name)
|
||||||
elif kind == "on_chain_end" and not event.get("parent_ids"):
|
elif kind == "on_chain_end" and not event.get("parent_ids"):
|
||||||
out = data.get("output")
|
out = data.get("output")
|
||||||
if isinstance(out, dict):
|
if isinstance(out, dict):
|
||||||
final_state = out
|
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
|
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:
|
finally:
|
||||||
heartbeat_task.cancel()
|
heartbeat_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
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)
|
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
|
||||||
if isinstance(content, str) and content.strip():
|
if isinstance(content, str) and content.strip():
|
||||||
last_reply = content
|
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:
|
if not deployed_url:
|
||||||
return {
|
return {
|
||||||
@@ -264,18 +307,52 @@ def _summarize(name: str, output: Any) -> str:
|
|||||||
return str(output)[:120]
|
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:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for k in ("url", "live_url"):
|
for k in ("url", "live_url"):
|
||||||
v = value.get(k)
|
v = value.get(k)
|
||||||
if isinstance(v, str) and v.startswith("http"):
|
if isinstance(v, str) and v.rstrip("/") == expected:
|
||||||
return v
|
return v
|
||||||
# JSON-stringified outputs from tools.
|
# JSON-stringified outputs from tools.
|
||||||
return _find_url(json.dumps(value))
|
return _find_agent_url(json.dumps(value), agent_name)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
m = _URL_RE.search(value)
|
for m in _URL_RE.finditer(value):
|
||||||
return m.group(0) if m else None
|
candidate = m.group(0).rstrip("/")
|
||||||
|
if candidate == expected:
|
||||||
|
return candidate
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
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 import CompositeBackend, StateBackend, StoreBackend
|
||||||
from deepagents.backends.utils import create_file_data
|
from deepagents.backends.utils import create_file_data
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langgraph.store.memory import InMemoryStore
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
|
||||||
from .config import Settings, load_settings
|
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/"
|
BUILDER_SKILL_SOURCE = "/.agent-builder/skills/"
|
||||||
@@ -27,17 +27,19 @@ class BuilderContext:
|
|||||||
settings: Settings | None = None
|
settings: Settings | None = None
|
||||||
project_prefix: str | None = None
|
project_prefix: str | None = None
|
||||||
workspace: Any | None = None
|
workspace: Any | None = None
|
||||||
|
sandbox: Any | None = None
|
||||||
grant_token: str | None = None
|
grant_token: str | None = None
|
||||||
# LLM creds forwarded by the orchestrator according to this agent's Card.
|
# LLM creds forwarded by the orchestrator according to this agent's Card.
|
||||||
# The builder accepts caller-selected creds and falls back to platform
|
# The builder uses the caller's saved LLM credential, usually proxied
|
||||||
# grants. Hosted generated agents should usually use platform grants too.
|
# through LiteLLM by the control plane. Falls back to settings only for
|
||||||
# Falls back to settings for local development.
|
# local development.
|
||||||
llm_base_url: str | None = None
|
llm_base_url: str | None = None
|
||||||
llm_api_key: str | None = None
|
llm_api_key: str | None = None
|
||||||
llm_model: str | None = None
|
llm_model: str | None = None
|
||||||
llm_temperature_mode: str | None = None
|
llm_temperature_mode: str | None = None
|
||||||
llm_temperature: float | None = None
|
llm_temperature: float | None = None
|
||||||
llm_extra_body: dict[str, Any] | None = None
|
llm_extra_body: dict[str, Any] | None = None
|
||||||
|
llm_metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """\
|
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
|
user's workspace at ``agents/<name>/`` and then deploy it through the
|
||||||
control plane.
|
control plane.
|
||||||
|
|
||||||
The default starter is the current a2a-pack DeepAgents scaffold. It declares
|
The default starter is the current a2a-pack DeepAgents scaffold. For agents
|
||||||
``LLMProvisioning.PLATFORM``, reads the scoped LLM grant from ``ctx.llm``,
|
that call an LLM, it declares ``LLMProvisioning.PLATFORM``, reads the caller's
|
||||||
builds a ``ChatOpenAI`` model from those credentials, and wires a small
|
saved LLM credential from ``ctx.llm``, resolves the model through
|
||||||
tool-calling DeepAgent with ``create_deep_agent`` plus ``wrap_model_call``
|
``create_a2a_deep_agent``, and wires a small tool-calling DeepAgent with the
|
||||||
middleware. Do not recreate this from memory: call ``init_agent_template`` first
|
A2A DeepAgents model middleware. Do not recreate this from memory: call
|
||||||
|
``init_agent_template`` first
|
||||||
and modify the generated files.
|
and modify the generated files.
|
||||||
|
|
||||||
Default hosted generated/user agents to ``LLMProvisioning.PLATFORM`` and
|
For hosted generated/user agents that call an LLM, default to
|
||||||
``caller_pays_llm=False`` so main-agent handoffs mint a scoped A2A LiteLLM grant
|
``LLMProvisioning.PLATFORM`` and ``caller_pays_llm=True`` so main-agent
|
||||||
for the callee. The generated agent must still read ``ctx.llm`` and must never
|
handoffs forward the caller's saved LLM credential for the callee. The
|
||||||
read ``A2A_LITELLM_KEY``, ``OPENAI_API_KEY``, LiteLLM master keys, or provider
|
generated agent must still read ``ctx.llm`` and must never read
|
||||||
keys directly. If the user explicitly asks for BYOK or caller-paid inference,
|
``A2A_LITELLM_KEY``, ``OPENAI_API_KEY``, LiteLLM master keys, or provider keys
|
||||||
use ``LLMProvisioning.CALLER_PROVIDED`` with ``caller_pays_llm=True`` and make
|
directly. ``LLMProvisioning.CALLER_PROVIDED`` is still acceptable for explicit
|
||||||
missing ``ctx.llm.api_key`` a clear setup/config result before constructing
|
BYOK wording. Always make missing ``ctx.llm.api_key`` a clear setup/config
|
||||||
``ChatOpenAI``.
|
result before constructing a DeepAgents model.
|
||||||
``LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`` is reserved for trusted
|
|
||||||
platform/meta agents that should prefer caller-selected credentials and fall
|
For explicitly deterministic/local-logic agents that do not call an LLM, remove
|
||||||
back to a scoped platform grant; do not use it for ordinary generated agents
|
the starter's ``LLMProvisioning`` import and class ``llm_provisioning``
|
||||||
unless the user explicitly asks for that mixed billing mode.
|
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/``.
|
You have packaged DeepAgents skills loaded from ``/.agent-builder/skills/``.
|
||||||
Use ``deepagent-agent-design`` before designing generated agent internals,
|
Use ``deepagent-agent-design`` before designing generated agent internals,
|
||||||
@@ -230,6 +242,11 @@ Your tools:
|
|||||||
elsewhere; use force=True only after
|
elsewhere; use force=True only after
|
||||||
the user explicitly accepts replacing
|
the user explicitly accepts replacing
|
||||||
the current repo source.
|
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",
|
- cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0",
|
||||||
public=True, refresh_existing=False)
|
public=True, refresh_existing=False)
|
||||||
— create/deploy a manifest-backed
|
— create/deploy a manifest-backed
|
||||||
@@ -296,6 +313,8 @@ Discipline:
|
|||||||
scaffold.
|
scaffold.
|
||||||
- When deploying, return the URL the platform gave back to the user so
|
- When deploying, return the URL the platform gave back to the user so
|
||||||
they can curl it / share it.
|
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,
|
- If cp_deploy_tarball returns workspace_drift or workspace_untracked,
|
||||||
use sync_agent_workspace_from_repo when the user wants the builder to
|
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
|
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,
|
settings=settings,
|
||||||
cp_jwt=ctx.cp_jwt,
|
cp_jwt=ctx.cp_jwt,
|
||||||
workspace=ctx.workspace,
|
workspace=ctx.workspace,
|
||||||
|
sandbox=ctx.sandbox,
|
||||||
grant_token=ctx.grant_token,
|
grant_token=ctx.grant_token,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
model_kwargs: dict[str, Any] = {
|
llm_creds = SimpleNamespace(
|
||||||
"model": ctx.llm_model or settings.litellm_model,
|
model=ctx.llm_model or settings.litellm_model,
|
||||||
"base_url": ctx.llm_base_url or (settings.litellm_url + "/v1"),
|
base_url=ctx.llm_base_url or (settings.litellm_url + "/v1"),
|
||||||
"api_key": ctx.llm_api_key or settings.litellm_key,
|
api_key=ctx.llm_api_key or settings.litellm_key,
|
||||||
"stream_usage": True,
|
temperature_mode=ctx.llm_temperature_mode or "default",
|
||||||
}
|
temperature=ctx.llm_temperature,
|
||||||
if ctx.llm_temperature_mode != "omit":
|
extra_body=dict(ctx.llm_extra_body or {}),
|
||||||
model_kwargs["temperature"] = (
|
metadata=dict(ctx.llm_metadata or {}),
|
||||||
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] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model,
|
|
||||||
"tools": tools,
|
"tools": tools,
|
||||||
"system_prompt": SYSTEM_PROMPT,
|
"system_prompt": SYSTEM_PROMPT,
|
||||||
}
|
}
|
||||||
@@ -343,7 +358,11 @@ def build_agent_builder(ctx: BuilderContext) -> Any:
|
|||||||
if backend is not None:
|
if backend is not None:
|
||||||
kwargs["backend"] = backend
|
kwargs["backend"] = backend
|
||||||
kwargs["skills"] = [BUILDER_SKILL_SOURCE]
|
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:
|
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
|
except Exception: # noqa: BLE001
|
||||||
return _with_builder_skills(StateBackend())
|
return _with_builder_skills(StateBackend())
|
||||||
return _with_builder_skills(
|
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}",
|
artifacts_root=f"/{prefix}/{_BUILDER_ARTIFACT_DIR}",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -400,11 +424,57 @@ def _build_project_backend(ctx: BuilderContext, *, settings: Settings) -> Any |
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return _with_builder_skills(
|
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}",
|
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:
|
def _with_builder_skills(default_backend: Any, *, artifacts_root: str = "/") -> Any:
|
||||||
skill_store = InMemoryStore()
|
skill_store = InMemoryStore()
|
||||||
for path, content in _builder_skill_files().items():
|
for path, content in _builder_skill_files().items():
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: a2apack-agent-authoring
|
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
|
# A2A Pack Agent Authoring
|
||||||
|
|
||||||
@@ -22,15 +22,25 @@ Use `A2AAgent` class attributes for runtime and marketplace behavior:
|
|||||||
caller's control-plane token.
|
caller's control-plane token.
|
||||||
|
|
||||||
For hosted generated/user-owned agents that call an LLM, default to
|
For hosted generated/user-owned agents that call an LLM, default to
|
||||||
`LLMProvisioning.PLATFORM` with `Pricing(..., caller_pays_llm=False, ...)` so
|
`LLMProvisioning.PLATFORM` with `Pricing(..., caller_pays_llm=True, ...)` so
|
||||||
main-agent handoffs mint a scoped A2A LiteLLM grant for the callee. Use
|
main-agent handoffs forward the caller's saved LLM credential for the callee.
|
||||||
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
|
`LLMProvisioning.CALLER_PROVIDED` is still acceptable for explicit BYOK wording.
|
||||||
caller-paid inference. Reserve `LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED`
|
Reserve `LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED` for legacy compatibility.
|
||||||
for trusted platform/meta agents that must work with either the caller's
|
In every mode, read `ctx.llm`;
|
||||||
selected creds or a platform fallback grant. In every mode, read `ctx.llm`;
|
|
||||||
never read `A2A_LITELLM_KEY`, `OPENAI_API_KEY`, provider keys, or platform
|
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
|
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
|
Keep each `@skill` method `async`, put `RunContext[...]` immediately after
|
||||||
`self`, and annotate every public argument. Do not use `*args` or `**kwargs`;
|
`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
|
llm_provisioning = LLMProvisioning.PLATFORM
|
||||||
pricing = Pricing(
|
pricing = Pricing(
|
||||||
price_per_call_usd=0.05,
|
price_per_call_usd=0.05,
|
||||||
caller_pays_llm=False,
|
caller_pays_llm=True,
|
||||||
notes="Uses a scoped platform LLM grant through ctx.llm.",
|
notes="Uses the caller's saved LLM credential through ctx.llm.",
|
||||||
)
|
)
|
||||||
resources = Resources(cpu="1", memory="1Gi", max_runtime_seconds=900)
|
resources = Resources(cpu="1", memory="1Gi", max_runtime_seconds=900)
|
||||||
egress = EgressPolicy(allow_hosts=("api.openai.com",))
|
egress = EgressPolicy(allow_hosts=("api.openai.com",))
|
||||||
|
|||||||
@@ -30,17 +30,24 @@ Check these items:
|
|||||||
arguments.
|
arguments.
|
||||||
- The public schema is small and stable. No unbounded command strings, hidden
|
- The public schema is small and stable. No unbounded command strings, hidden
|
||||||
prompt fragments, or provider credentials as public inputs.
|
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
|
`PLATFORM` LLM provisioning. `PLATFORM_OR_CALLER_PROVIDED` is acceptable
|
||||||
only for trusted platform/meta agents and must also read `ctx.llm`.
|
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
|
`llm_provisioning = LLMProvisioning.PLATFORM` and
|
||||||
`Pricing(..., caller_pays_llm=False, ...)`. Use
|
`Pricing(..., caller_pays_llm=True, ...)`. `LLMProvisioning.CALLER_PROVIDED`
|
||||||
`LLMProvisioning.CALLER_PROVIDED` only when the user explicitly wants BYOK or
|
is acceptable for explicit BYOK wording. In both modes, the code must use
|
||||||
caller-paid inference. In both modes, the code must use `ctx.llm` and never
|
`ctx.llm` and never read LiteLLM/provider keys directly.
|
||||||
read LiteLLM/provider keys directly.
|
- Deterministic/local-logic agents that do not call an LLM omit the concrete
|
||||||
- Any code path that builds `ChatOpenAI` checks `ctx.llm.api_key` first or
|
`llm_provisioning` declaration, remove unused `LLMProvisioning` imports, do
|
||||||
returns a clear setup/config result before the model constructor runs.
|
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 use of DeepAgents file tools passes `backend=ctx.workspace_backend()`.
|
||||||
- Any project skills are seeded into the backend and passed with
|
- Any project skills are seeded into the backend and passed with
|
||||||
`skills=skill_sources or None`.
|
`skills=skill_sources or None`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: deepagent-agent-design
|
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
|
# DeepAgent Agent Design
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ planning, file work, skill selection, and subagent delegation.
|
|||||||
Use this shape for non-trivial generated agents:
|
Use this shape for non-trivial generated agents:
|
||||||
|
|
||||||
1. Keep one or two public A2A `@skill` methods with clear typed parameters.
|
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
|
2. Inside each method, read `ctx.llm`, verify credentials are available, and
|
||||||
constructing `ChatOpenAI`, and build a DeepAgent with `create_deep_agent`.
|
build a DeepAgent with `create_a2a_deep_agent`.
|
||||||
3. Pass `backend=ctx.workspace_backend()` so DeepAgents file tools write to the
|
3. Pass `backend=ctx.workspace_backend()` so DeepAgents file tools write to the
|
||||||
caller's durable workspace instead of LangGraph state.
|
caller's durable workspace instead of LangGraph state.
|
||||||
4. Seed project skills into the current grant's write prefix and pass
|
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
|
Project `skills/` files are source files in the agent image. DeepAgents loads
|
||||||
skills from its backend, so seed those files into the invocation workspace
|
skills from its backend, so seed those files into the invocation workspace
|
||||||
before `create_deep_agent`:
|
before `create_a2a_deep_agent`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -84,8 +84,9 @@ Then:
|
|||||||
```python
|
```python
|
||||||
backend = ctx.workspace_backend()
|
backend = ctx.workspace_backend()
|
||||||
skill_sources = _seed_runtime_skills(backend, ctx)
|
skill_sources = _seed_runtime_skills(backend, ctx)
|
||||||
return create_deep_agent(
|
return create_a2a_deep_agent(
|
||||||
model=model,
|
ctx,
|
||||||
|
creds=creds,
|
||||||
backend=backend,
|
backend=backend,
|
||||||
skills=skill_sources or None,
|
skills=skill_sources or None,
|
||||||
tools=[small_deterministic_tool],
|
tools=[small_deterministic_tool],
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: deepagents-implementation-patterns
|
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 Implementation Patterns
|
||||||
|
|
||||||
DeepAgents supplies the inner agent loop. In this platform, the reliable shape
|
DeepAgents supplies the inner agent loop. In this platform, the reliable shape
|
||||||
is: `ctx.llm`-backed `ChatOpenAI`, A2A workspace backend, project DeepAgents
|
is: `ctx.llm`-backed `create_a2a_deep_agent`, A2A workspace backend, project
|
||||||
skills, and a small number of exact tools. Hosted generated agents should
|
DeepAgents skills, and a small number of exact tools. Hosted generated agents should
|
||||||
declare `LLMProvisioning.PLATFORM`; explicit BYOK agents can declare
|
declare `LLMProvisioning.PLATFORM`; explicit BYOK agents can declare
|
||||||
`CALLER_PROVIDED`, but both modes still read the same `ctx.llm` object.
|
`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 typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from langchain_openai import ChatOpenAI
|
from a2a_pack.deepagents import create_a2a_deep_agent
|
||||||
from deepagents import create_deep_agent
|
|
||||||
|
|
||||||
RUNTIME_SKILLS_DIR = "research-agent/.deepagents/skills/"
|
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 "
|
"LLM credentials were not available; declare PLATFORM for hosted "
|
||||||
"generated agents or configure caller-provided credentials."
|
"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
|
@tool
|
||||||
def validate_citations(items_json: str) -> str:
|
def validate_citations(items_json: str) -> str:
|
||||||
"""Validate that each citation has title, url, and claim fields."""
|
"""Validate that each citation has title, url, and claim fields."""
|
||||||
@@ -58,8 +50,9 @@ def build_graph(ctx: Any) -> Any:
|
|||||||
|
|
||||||
backend = ctx.workspace_backend()
|
backend = ctx.workspace_backend()
|
||||||
skill_sources = seed_runtime_skills(backend, ctx)
|
skill_sources = seed_runtime_skills(backend, ctx)
|
||||||
return create_deep_agent(
|
return create_a2a_deep_agent(
|
||||||
model=model,
|
ctx,
|
||||||
|
creds=creds,
|
||||||
backend=backend,
|
backend=backend,
|
||||||
skills=skill_sources or None,
|
skills=skill_sources or None,
|
||||||
tools=[validate_citations],
|
tools=[validate_citations],
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Pass the backend into DeepAgents:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
backend = ctx.workspace_backend()
|
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
|
For stable, human-readable paths, ask model-created files to use
|
||||||
|
|||||||
@@ -3,16 +3,22 @@
|
|||||||
- workspace file CRUD through the caller's scoped workspace grant
|
- workspace file CRUD through the caller's scoped workspace grant
|
||||||
- sandbox python (for `a2a card` / `a2a validate` round-trips)
|
- sandbox python (for `a2a card` / `a2a validate` round-trips)
|
||||||
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
|
- 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)
|
- cp_compose_meta_agent (call /v1/agents/compose with a manifest)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import ast
|
||||||
import base64
|
import base64
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
@@ -29,7 +35,9 @@ if TYPE_CHECKING:
|
|||||||
from .config import Settings
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -38,9 +46,24 @@ class ToolContext:
|
|||||||
settings: "Settings"
|
settings: "Settings"
|
||||||
cp_jwt: str | None = None
|
cp_jwt: str | None = None
|
||||||
workspace: Any | None = None
|
workspace: Any | None = None
|
||||||
|
sandbox: Any | None = None
|
||||||
grant_token: str | 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(
|
async def _wait_for_live_card(
|
||||||
url: str | None,
|
url: str | None,
|
||||||
expected_version: 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])?$")
|
_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_STATE_FILE = ".a2a-builder-state.json"
|
||||||
_BUILDER_INTERNAL_PREFIX = ".agent-builder/"
|
_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:
|
def _validate_name(name: str) -> None:
|
||||||
@@ -221,6 +256,24 @@ def _builder_state_key(name: str) -> str:
|
|||||||
return _agent_prefix(name) + _BUILDER_STATE_FILE
|
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]:
|
def build_tools(ctx: ToolContext) -> list[Any]:
|
||||||
bucket = ctx.bucket
|
bucket = ctx.bucket
|
||||||
settings = ctx.settings
|
settings = ctx.settings
|
||||||
@@ -288,9 +341,14 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for key in store.iter_keys(prefix):
|
for key in store.iter_keys(prefix):
|
||||||
rel = key[len(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
|
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})
|
return json.dumps({"agent": name, "files": out})
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
@@ -305,11 +363,23 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
rel = path.lstrip("/")
|
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"})
|
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
|
key = prefix + rel
|
||||||
store.put(key, content.encode("utf-8"), "text/plain; charset=utf-8")
|
encoded = content.encode("utf-8")
|
||||||
return json.dumps({"ok": True, "path": key, "size": len(content)})
|
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
|
@tool
|
||||||
def read_agent_file(name: str, path: str) -> str:
|
def read_agent_file(name: str, path: str) -> str:
|
||||||
@@ -319,7 +389,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
rel = path.lstrip("/")
|
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"})
|
return json.dumps({"error": f"{rel} is managed by agent-builder"})
|
||||||
key = prefix + rel
|
key = prefix + rel
|
||||||
try:
|
try:
|
||||||
@@ -388,6 +458,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
prefix = _agent_prefix(name)
|
prefix = _agent_prefix(name)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return json.dumps({"error": str(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
|
# Bundle the workspace files into the sandbox via base64 tar so
|
||||||
# the sandbox doesn't need MinIO read access for this skill.
|
# 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({
|
return json.dumps({
|
||||||
"error": "no files in agents/" + name + "/ — write some first",
|
"error": "no files in agents/" + name + "/ — write some first",
|
||||||
})
|
})
|
||||||
|
source_hash = _source_bundle_hash(bundle_bytes)
|
||||||
b64 = base64.b64encode(bundle_bytes).decode("ascii")
|
b64 = base64.b64encode(bundle_bytes).decode("ascii")
|
||||||
script = (
|
script = (
|
||||||
"set -e\n"
|
"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"
|
"mkdir -p /tmp/agent\n"
|
||||||
f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n"
|
f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n"
|
||||||
"cd /tmp/agent\n"
|
"cd /tmp/agent\n"
|
||||||
@@ -409,30 +488,199 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
"fi\n"
|
"fi\n"
|
||||||
"echo '--- card ---'\n"
|
"echo '--- card ---'\n"
|
||||||
"a2a card --project .\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"
|
"if grep -q '^frontend:' a2a.yaml 2>/dev/null; then\n"
|
||||||
" echo '--- frontend ---'\n"
|
" echo '--- frontend ---'\n"
|
||||||
" a2a frontend info --project . || true\n"
|
" a2a frontend info --project . || true\n"
|
||||||
"fi\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)
|
headers = _sandbox_headers(settings.sandbox_token, ctx.grant_token)
|
||||||
async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c:
|
async with httpx.AsyncClient(timeout=settings.sandbox_timeout_s + 30) as c:
|
||||||
r = await c.post(
|
r = await c.post(
|
||||||
f"{settings.sandbox_url}/v1/run_shell",
|
f"{settings.sandbox_url}/v1/run_shell",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json={
|
json={
|
||||||
"bucket": bucket, "script": script,
|
"bucket": bucket,
|
||||||
"image": settings.image, "memory_mib": 512,
|
"script": script,
|
||||||
|
"image": settings.image,
|
||||||
|
"memory_mib": 512,
|
||||||
"timeout_seconds": settings.sandbox_timeout_s,
|
"timeout_seconds": settings.sandbox_timeout_s,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]})
|
return json.dumps({"error": f"sandbox {r.status_code}", "detail": r.text[:1000]})
|
||||||
out = r.json()
|
out = r.json()
|
||||||
return json.dumps({
|
result: dict[str, Any] = {
|
||||||
"exit_code": out.get("exit_code"),
|
"exit_code": out.get("exit_code"),
|
||||||
"stdout": (out.get("stdout") or "")[:4000],
|
"stdout": (out.get("stdout") or "")[:4000],
|
||||||
"stderr": (out.get("stderr") 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
|
@tool
|
||||||
async def cp_deploy_tarball(
|
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",
|
"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:
|
try:
|
||||||
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
latest_deploy = await _latest_cp_deployment(settings.cp_url, ctx.cp_jwt, name)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return json.dumps({"error": str(exc)})
|
return json.dumps({"error": str(exc)})
|
||||||
builder_state = _read_builder_state(store, name)
|
|
||||||
drift = _deployment_drift_error(
|
drift = _deployment_drift_error(
|
||||||
name,
|
name,
|
||||||
latest_deploy,
|
latest_deploy,
|
||||||
@@ -492,11 +749,12 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
if force:
|
if force:
|
||||||
base_head = None
|
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.
|
# Read the entrypoint from a2a.yaml so we can pass it verbatim.
|
||||||
entrypoint = _read_entrypoint(bundle_bytes) or f"agent:{_class_name(name)}"
|
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
|
# NOTE: the CP from-tarball endpoint names the file field
|
||||||
# ``source`` (see apps/control-plane/control_plane/routes/agents.py).
|
# ``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,
|
"name": name, "version": version,
|
||||||
"entrypoint": entrypoint, "public": "true" if public else "false",
|
"entrypoint": entrypoint, "public": "true" if public else "false",
|
||||||
"description": "Built by agent-builder.",
|
"description": "Built by agent-builder.",
|
||||||
|
"agent_dsl": agent_dsl_json,
|
||||||
}
|
}
|
||||||
if isinstance(base_head, str) and base_head:
|
if isinstance(base_head, str) and base_head:
|
||||||
data["base_head_sha"] = base_head
|
data["base_head_sha"] = base_head
|
||||||
@@ -522,7 +781,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
name_ = body.get("name")
|
name_ = body.get("name")
|
||||||
version_ = body.get("version")
|
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")
|
head_sha = body.get("head_sha")
|
||||||
if isinstance(head_sha, str) and head_sha:
|
if isinstance(head_sha, str) and head_sha:
|
||||||
_write_builder_state(
|
_write_builder_state(
|
||||||
@@ -561,6 +820,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
"workspace_base_head_sha": base_head,
|
"workspace_base_head_sha": base_head,
|
||||||
"live": live,
|
"live": live,
|
||||||
}
|
}
|
||||||
|
if sanitized_fields:
|
||||||
|
out["sanitized_agent_card_fields"] = list(sanitized_fields)
|
||||||
if live:
|
if live:
|
||||||
out["live_version"] = live_card.get("version")
|
out["live_version"] = live_card.get("version")
|
||||||
out["live_skills"] = [
|
out["live_skills"] = [
|
||||||
@@ -580,6 +841,58 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
out["last_seen_version"] = live_card.get("version")
|
out["last_seen_version"] = live_card.get("version")
|
||||||
return json.dumps(out)
|
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
|
@tool
|
||||||
async def cp_compose_meta_agent(
|
async def cp_compose_meta_agent(
|
||||||
name: str,
|
name: str,
|
||||||
@@ -863,7 +1176,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
|
|||||||
return [
|
return [
|
||||||
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
|
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
|
||||||
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball,
|
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,
|
sync_agent_workspace_from_repo,
|
||||||
list_a2a_pack, read_a2a_pack,
|
list_a2a_pack, read_a2a_pack,
|
||||||
]
|
]
|
||||||
@@ -884,11 +1197,13 @@ def _tarball_workspace_dir(
|
|||||||
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
|
||||||
for key in store.iter_keys(prefix):
|
for key in store.iter_keys(prefix):
|
||||||
rel = key[len(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):
|
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
body = store.get(key)
|
body = store.get(key)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("Skipping disappeared source file during tarball: %s", key)
|
||||||
|
continue
|
||||||
info = tarfile.TarInfo(name=rel)
|
info = tarfile.TarInfo(name=rel)
|
||||||
info.size = len(body)
|
info.size = len(body)
|
||||||
info.mode = 0o644
|
info.mode = 0o644
|
||||||
@@ -897,6 +1212,109 @@ def _tarball_workspace_dir(
|
|||||||
return buf.getvalue() if added else b""
|
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]:
|
def _read_builder_state(store: _ObjectStore, name: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
data = json.loads(store.get(_builder_state_key(name)).decode("utf-8"))
|
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")
|
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]:
|
def _parse_manifest_json(raw: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(raw or "{}")
|
parsed = json.loads(raw or "{}")
|
||||||
@@ -997,7 +1479,7 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
|
|||||||
rel = rel[2:]
|
rel = rel[2:]
|
||||||
if not rel or rel.startswith("/") or ".." in rel.split("/"):
|
if not rel or rel.startswith("/") or ".." in rel.split("/"):
|
||||||
raise ValueError(f"unsafe member path: {member.name}")
|
raise ValueError(f"unsafe member path: {member.name}")
|
||||||
if rel == _BUILDER_STATE_FILE:
|
if not _should_include_agent_rel_path(rel):
|
||||||
continue
|
continue
|
||||||
extracted = tf.extractfile(member)
|
extracted = tf.extractfile(member)
|
||||||
if extracted is None:
|
if extracted is None:
|
||||||
@@ -1008,6 +1490,63 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
|
|||||||
return out
|
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(
|
def _replace_workspace_files(
|
||||||
store_or_s3: Any,
|
store_or_s3: Any,
|
||||||
bucket_or_prefix: str,
|
bucket_or_prefix: str,
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
apiVersion: apps/v1
|
apiVersion: serving.knative.dev/v1
|
||||||
kind: Deployment
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: agent-builder
|
name: agent-builder
|
||||||
namespace: agents
|
namespace: agents
|
||||||
|
annotations:
|
||||||
|
argocd.argoproj.io/sync-options: ServerSideApply=true
|
||||||
labels:
|
labels:
|
||||||
app: agent-builder
|
app: agent-builder
|
||||||
a2a/managed-by: control-plane
|
a2a/managed-by: control-plane
|
||||||
|
a2a/platform-agent: "true"
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
|
||||||
revisionHistoryLimit: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: agent-builder
|
|
||||||
strategy:
|
|
||||||
type: RollingUpdate
|
|
||||||
rollingUpdate:
|
|
||||||
maxSurge: 1
|
|
||||||
maxUnavailable: 0
|
|
||||||
progressDeadlineSeconds: 900
|
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: agent-builder
|
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:
|
spec:
|
||||||
enableServiceLinks: false
|
enableServiceLinks: false
|
||||||
terminationGracePeriodSeconds: 30
|
containerConcurrency: 10
|
||||||
|
timeoutSeconds: 1800
|
||||||
|
responseStartTimeoutSeconds: 1800
|
||||||
containers:
|
containers:
|
||||||
- name: agent
|
- name: agent
|
||||||
image: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
|
image: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8000
|
- containerPort: 8000
|
||||||
@@ -35,9 +34,9 @@ spec:
|
|||||||
protocol: TCP
|
protocol: TCP
|
||||||
env:
|
env:
|
||||||
- name: A2A_AGENT_IMAGE
|
- name: A2A_AGENT_IMAGE
|
||||||
value: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
|
value: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
|
||||||
- name: A2A_RENDER_IMAGE
|
- name: A2A_RENDER_IMAGE
|
||||||
value: registry.a2acloud.io/agents/agent-builder:696dec47403967af0ff4d0b899596f886bb9fed8
|
value: registry.a2acloud.io/agents/agent-builder:d182ade6fd6bc0882fbc542cf2bfea3a43cb5c9d
|
||||||
- name: SANDBOX_URL
|
- name: SANDBOX_URL
|
||||||
value: http://sandbox.sandbox.svc.cluster.local:8000
|
value: http://sandbox.sandbox.svc.cluster.local:8000
|
||||||
- name: A2A_SANDBOX_URL
|
- name: A2A_SANDBOX_URL
|
||||||
@@ -51,15 +50,26 @@ spec:
|
|||||||
- name: A2A_GRANT_VERIFYING_KEY
|
- name: A2A_GRANT_VERIFYING_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: {name: platform-secrets, key: grant_verifying_key}
|
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
|
- name: A2A_CP_URL
|
||||||
value: http://control-plane.control-plane.svc.cluster.local
|
value: http://control-plane.control-plane.svc.cluster.local
|
||||||
|
startupProbe:
|
||||||
|
httpGet: {path: /healthz, port: 8000}
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 60
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: {path: /healthz, port: 8000}
|
httpGet: {path: /healthz, port: 8000}
|
||||||
initialDelaySeconds: 8
|
initialDelaySeconds: 8
|
||||||
periodSeconds: 5
|
periodSeconds: 5
|
||||||
failureThreshold: 3
|
failureThreshold: 6
|
||||||
successThreshold: 1
|
successThreshold: 1
|
||||||
timeoutSeconds: 1
|
timeoutSeconds: 3
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet: {path: /healthz, port: 8000}
|
httpGet: {path: /healthz, port: 8000}
|
||||||
initialDelaySeconds: 25
|
initialDelaySeconds: 25
|
||||||
@@ -67,46 +77,3 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests: {cpu: 200m, memory: 512Mi}
|
requests: {cpu: 200m, memory: 512Mi}
|
||||||
limits: {cpu: "1", memory: 1Gi}
|
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
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
a2a-pack>=0.1.47
|
a2a-pack>=0.1.82
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
boto3>=1.34
|
boto3>=1.34
|
||||||
deepagents>=0.5.0
|
deepagents>=0.5.0
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from agent_builder.builder import (
|
from agent_builder.builder import (
|
||||||
BUILDER_SKILL_SOURCE,
|
BUILDER_SKILL_SOURCE,
|
||||||
@@ -10,10 +12,142 @@ from agent_builder.builder import (
|
|||||||
_build_project_backend,
|
_build_project_backend,
|
||||||
_builder_skill_files,
|
_builder_skill_files,
|
||||||
_with_builder_skills,
|
_with_builder_skills,
|
||||||
|
_workspace_backend_with_grep_compat,
|
||||||
)
|
)
|
||||||
from agent_builder.config import Settings
|
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 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):
|
class BuilderPromptTests(unittest.TestCase):
|
||||||
@@ -25,25 +159,33 @@ class BuilderPromptTests(unittest.TestCase):
|
|||||||
"config={\"recursion_limit\": DEEPAGENTS_RECURSION_LIMIT}",
|
"config={\"recursion_limit\": DEEPAGENTS_RECURSION_LIMIT}",
|
||||||
agent_source,
|
agent_source,
|
||||||
)
|
)
|
||||||
|
self.assertIn("sandbox = ctx.sandbox", agent_source)
|
||||||
|
self.assertIn("sandbox=sandbox", agent_source)
|
||||||
self.assertIn("stream=True", agent_source)
|
self.assertIn("stream=True", agent_source)
|
||||||
self.assertIn('grant_write_prefixes=("agents/{name}/",)', 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()
|
agent_source = Path(__file__).resolve().parents[1].joinpath("agent.py").read_text()
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"llm_provisioning = LLMProvisioning.PLATFORM_OR_CALLER_PROVIDED",
|
"llm_provisioning = LLMProvisioning.PLATFORM",
|
||||||
agent_source,
|
agent_source,
|
||||||
)
|
)
|
||||||
self.assertIn("caller_pays_llm=False", agent_source)
|
self.assertIn("caller_pays_llm=True", agent_source)
|
||||||
self.assertIn("platform-scoped LiteLLM grant", agent_source)
|
self.assertIn("caller's saved LLM credential", agent_source)
|
||||||
self.assertIn("caller-selected LLM credentials", agent_source)
|
|
||||||
|
|
||||||
def test_builder_defaults_raise_timeout_budget(self) -> None:
|
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(
|
config_source = Path(__file__).resolve().parents[1].joinpath(
|
||||||
"agent_builder/config.py"
|
"agent_builder/config.py"
|
||||||
).read_text()
|
).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("A2A_SANDBOX_TIMEOUT_S")', config_source)
|
||||||
self.assertIn('os.environ.get("SANDBOX_TIMEOUT_S", "1200")', 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)
|
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(f"a2a-pack>={A2A_PACK_MIN_VERSION}", requirements)
|
||||||
self.assertIn("a2a-pack>={A2A_PACK_MIN_VERSION}", tools_source)
|
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("LLMProvisioning.PLATFORM", SYSTEM_PROMPT)
|
||||||
self.assertIn("caller_pays_llm=False", SYSTEM_PROMPT)
|
self.assertIn("caller_pays_llm=True", SYSTEM_PROMPT)
|
||||||
self.assertIn("main-agent handoffs mint a scoped A2A LiteLLM grant", SYSTEM_PROMPT)
|
self.assertIn("handoffs forward the caller's saved LLM credential", SYSTEM_PROMPT)
|
||||||
self.assertIn("ctx.llm.api_key", 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()
|
files = _builder_skill_files()
|
||||||
self.assertIn("LLMProvisioning.PLATFORM", files["a2apack-agent-authoring/SKILL.md"])
|
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("caller_pays_llm=False", files["a2apack-agent-authoring/SKILL.md"])
|
||||||
self.assertIn("ctx.llm.api_key", files["agent-quality-review/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:
|
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
|
||||||
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
|
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("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("from a2a_pack import", files["a2apack-agent-authoring/SKILL.md"])
|
||||||
self.assertIn("ctx.workspace_shell", 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('config={"recursion_limit": 500}', files["deepagents-implementation-patterns/SKILL.md"])
|
||||||
self.assertIn("ctx.write_artifact", files["workspace-artifact-safety/SKILL.md"])
|
self.assertIn("ctx.write_artifact", files["workspace-artifact-safety/SKILL.md"])
|
||||||
self.assertIn("ctx.workspace_shell", 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:
|
for skill_name in expected:
|
||||||
self.assertIn(f"{BUILDER_SKILL_SOURCE}{skill_name}/", paths)
|
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:
|
def test_project_backend_keeps_deepagents_artifacts_inside_project_grant(self) -> None:
|
||||||
backend = _build_project_backend(
|
backend = _build_project_backend(
|
||||||
BuilderContext(
|
BuilderContext(
|
||||||
@@ -159,3 +357,83 @@ class BuilderPromptTests(unittest.TestCase):
|
|||||||
backend.artifacts_root,
|
backend.artifacts_root,
|
||||||
"/agents/svgwrite-agent/.agent-builder",
|
"/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")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import gzip
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import tarfile
|
import tarfile
|
||||||
@@ -8,16 +9,20 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from agent_builder.config import Settings
|
from agent_builder.config import Settings
|
||||||
|
from agent_builder.builder import _workspace_backend_with_grep_compat
|
||||||
from agent_builder.tools import (
|
from agent_builder.tools import (
|
||||||
_BUILDER_STATE_FILE,
|
_BUILDER_STATE_FILE,
|
||||||
_BUILDER_INTERNAL_PREFIX,
|
_BUILDER_INTERNAL_PREFIX,
|
||||||
|
_WorkspaceObjectStore,
|
||||||
_parse_manifest_json,
|
_parse_manifest_json,
|
||||||
|
_deploy_poll_url,
|
||||||
_deployment_drift_error,
|
_deployment_drift_error,
|
||||||
_files_from_tarball,
|
_files_from_tarball,
|
||||||
_parse_supporting_skill_files,
|
_parse_supporting_skill_files,
|
||||||
_replace_workspace_files,
|
_replace_workspace_files,
|
||||||
_render_a2a_init_template,
|
_render_a2a_init_template,
|
||||||
_render_skill_md,
|
_render_skill_md,
|
||||||
|
_source_bundle_hash,
|
||||||
_tarball_workspace_dir,
|
_tarball_workspace_dir,
|
||||||
ToolContext,
|
ToolContext,
|
||||||
build_tools,
|
build_tools,
|
||||||
@@ -46,7 +51,7 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
self.assertIn("skills=skill_sources or None", files["agent.py"])
|
self.assertIn("skills=skill_sources or None", files["agent.py"])
|
||||||
self.assertIn("ctx.llm", files["agent.py"])
|
self.assertIn("ctx.llm", files["agent.py"])
|
||||||
self.assertIn("ctx.workspace_backend()", 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("name: research-agent", files["a2a.yaml"])
|
||||||
self.assertIn("entrypoint: agent:ResearchAgent", files["a2a.yaml"])
|
self.assertIn("entrypoint: agent:ResearchAgent", files["a2a.yaml"])
|
||||||
self.assertNotIn("{{ frontend_block }}", 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:
|
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
|
||||||
self.assertEqual(tf.getnames(), ["agent.py"])
|
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:
|
def test_render_skill_md_creates_valid_frontmatter(self) -> None:
|
||||||
text = _render_skill_md(
|
text = _render_skill_md(
|
||||||
"market-research",
|
"market-research",
|
||||||
@@ -271,6 +314,250 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("manifest_json must be a JSON object", result["error"])
|
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:
|
def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None:
|
||||||
workspace = _FakeWorkspace()
|
workspace = _FakeWorkspace()
|
||||||
tools = build_tools(
|
tools = build_tools(
|
||||||
@@ -356,6 +643,22 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("no CP JWT", result["error"])
|
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:
|
def _tarball(files: dict[str, str]) -> bytes:
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
@@ -396,6 +699,36 @@ class _FakeS3:
|
|||||||
raise AssertionError("batch DeleteObjects should not be used")
|
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:
|
class _FakePaginator:
|
||||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||||
self.objects = objects
|
self.objects = objects
|
||||||
@@ -429,6 +762,21 @@ class _FakeWorkspace:
|
|||||||
self.objects.pop(key, None)
|
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:
|
def _tool_by_name(tools: list[object], name: str) -> object:
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if getattr(tool, "name", None) == name:
|
if getattr(tool, "name", None) == name:
|
||||||
@@ -480,14 +828,23 @@ class _FakeAsyncClient:
|
|||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
headers: dict[str, 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:
|
) -> _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(
|
return _FakeResponse(
|
||||||
201,
|
201,
|
||||||
{
|
{
|
||||||
"name": json["name"],
|
"name": payload["name"],
|
||||||
"version": json["version"],
|
"version": payload["version"],
|
||||||
"status": "building",
|
"status": "building",
|
||||||
"expected_url": "https://report-meta.a2acloud.io",
|
"expected_url": "https://report-meta.a2acloud.io",
|
||||||
"deployment_id": "dpl_123",
|
"deployment_id": "dpl_123",
|
||||||
|
|||||||
Reference in New Issue
Block a user