Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/agent_env/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ Use your own URI, not `urn:agentenv:*`. Reference: `packages/agentenv-protocol/e
```bash
agent-env run ./my-bundle # build, register under @local, run its tasks
agent-env a2a-agent put --id my-agent --dockerfile my-bundle/agents/my-agent/Dockerfile
agent-env a2a-agent put --id my-claude --published claude-code # a shipped agent's released image, nothing built
agent-env a2a-agent validate --id my-agent # agent-env's built-in checks
agent-env a2a-agent get --id my-agent # the validation results
```
Expand Down
5 changes: 4 additions & 1 deletion src/agent_env/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ endpoint. The image holds Claude Code, which is Anthropic's software under its
at `ghcr.io/scaleapi/agentenv-claude-code:src-<hex>`, built from this folder, and again only when
the folder changes. A run that deploys the agent registers that image when it's published for the sources installed: each deploy
pulls it by its digest and builds it from this folder only when it can't, and a provider that runs images by name can
run it. The CLI's version is the Dockerfile's
run it. To register that published image under an id of your own, in a store a hosted control plane reads,
`agent-env a2a-agent put --id <id> --published claude-code` takes its tag and digest from the release installed and this
folder as its build context, with nothing built or pushed; it refuses when no image is published for those sources, as
for a git checkout. The CLI's version is the Dockerfile's
`CLAUDE_CODE_VERSION`. The image gives the scripts the model runs
`node`, without npm or npx, and the Python packages `model-requirements.txt` pins.

Expand Down
41 changes: 36 additions & 5 deletions src/agent_env/cli/a2a_agent/put.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from agent_env.a2a_agent import A2AAgent
from agent_env.artifact import DockerImageArtifact
from agent_env.cli.utils import build_platform_option, detect_env_metadata, refuse_unwritable_ids
from agent_env.env.published_images import AGENT_IMAGES, PublishedImage, published_digest
from agent_env.store.ids import derive_id, image_repository
from agent_env.utils.docker.build import DEFAULT_BUILD_PLATFORM, build_image

Expand All @@ -17,18 +18,22 @@
@click.option("--image-ref", "image_ref", default=None,
help="An image already in a registry, e.g. ghcr.io/org/agent:v1: registered as it is, with its tag pinned "
"to the digest the registry serves now, and pulled by sandboxes (instead of --dockerfile)")
@click.option("--published", "published", default=None, metavar="AGENT",
help="A shipped agent, e.g. claude-code: registers the image agent-env's release published for the agent "
"this install ships, by its tag and digest, with the agent's folder as the build context "
"(instead of --dockerfile)")
@click.option("--env-var", "env_var_pairs", multiple=True, help="Default env var KEY=VALUE (repeatable)")
@click.option("--metadata", "metadata_pairs", multiple=True, help="Metadata key=value pair (repeatable)")
@click.option("--min-disk-size-gb", type=float, default=None, help="Minimum disk size in GB for agent deployment")
@click.option("--default-model", type=str, default=None, help="Default model for agent prompts (e.g., claude-sonnet-4-6)")
@click.option("--skip-validation", is_flag=True, default=False, help="Skip A2A agent validation after registration")
@click.option("--litellm-api-key", type=str, default=None, help="LiteLLM API key for validation prompts")
@build_platform_option
def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_ref: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str):
def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_ref: str | None, published: str | None, env_var_pairs: tuple[str, ...], metadata_pairs: tuple[str, ...], min_disk_size_gb: float | None, default_model: str | None, skip_validation: bool, litellm_api_key: str | None, build_platform: str):
"""Build a Docker image and register an A2A agent, or register one over an image already in a registry."""

if bool(dockerfile) == bool(image_ref):
click.echo("Error: exactly one of --dockerfile and --image-ref is required", err=True)
if sum(map(bool, (dockerfile, image_ref, published))) != 1:
click.echo("Error: exactly one of --dockerfile, --image-ref and --published is required", err=True)
sys.exit(1)
if context_path and not dockerfile:
click.echo("Error: --context needs --dockerfile", err=True)
Expand All @@ -52,9 +57,25 @@ def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_r

image_id = derive_id(agent_id, "agent_image")
refuse_unwritable_ids(agent_id, image_id)
if image_ref:
if image_ref or published:
if build_platform != DEFAULT_BUILD_PLATFORM:
click.echo(f"Warning: --platform {build_platform!r} is ignored for --image-ref, which builds nothing", err=True)
flag = "--image-ref" if image_ref else "--published"
click.echo(f"Warning: --platform {build_platform!r} is ignored for {flag}, which builds nothing", err=True)
if published:
image = _shipped_image(published)
digest = published_digest(image)
if digest is None:
click.echo(f"Error: no image is published for this install's {published} ({image.ref()}): a release's wheel "
"from PyPI holds the sources its release published", err=True)
sys.exit(1)
folder = image.dockerfile.parent
artifact = DockerImageArtifact.put_ref(
image_id, description=f"A2A agent image for {agent_id}", image_name=image.ref(), image_digest=digest,
context_path=str(folder), dockerfile_path=str(image.dockerfile))
click.echo(f"Registered published image: id={artifact.id} version={artifact.version} "
f"image={artifact.image_name}@{digest}")
metadata = detect_env_metadata(image.dockerfile, folder)
elif image_ref:
artifact = DockerImageArtifact.put_ref(image_id, description=f"A2A agent image for {agent_id}", image_name=image_ref)
click.echo(f"Registered image: id={artifact.id} version={artifact.version} image={artifact.image_name}")
metadata = {}
Expand Down Expand Up @@ -96,3 +117,13 @@ def put(agent_id: str, dockerfile: str | None, context_path: str | None, image_r
from agent_env.cli.a2a_agent.validate import run_validation
click.echo()
run_validation(agent, litellm_api_key=litellm_api_key)


def _shipped_image(name: str) -> PublishedImage:
"""The published image of the shipped agent in folder ``name``."""
for image in AGENT_IMAGES:
if image.dockerfile.parent.name == name:
return image
names = ", ".join(image.dockerfile.parent.name for image in AGENT_IMAGES)
click.echo(f"Error: agent-env publishes no image for {name!r}; it publishes {names}", err=True)
sys.exit(1)
75 changes: 75 additions & 0 deletions tst/unit/cli/a2a_agent_put_published_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""`agent-env a2a-agent put --published`: an agent registered over the image a release published for a shipped agent."""

import importlib

import pytest
from click.testing import CliRunner

from agent_env.a2a_agent import A2AAgent
from agent_env.artifact.store import get_artifact_store
from agent_env.cli import cli
from agent_env.env.published_images import PublishedImage
from agent_env.store.base import NotFoundError

put_module = importlib.import_module("agent_env.cli.a2a_agent.put")
DIGEST = "sha256:" + "a" * 64


@pytest.fixture
def shipped(tmp_path, monkeypatch, local_stores):
"""A shipped claude-code agent folder, and a release that published its image under ``DIGEST``."""
folder = tmp_path / "agents" / "claude-code"
folder.mkdir(parents=True)
(folder / "Dockerfile").write_text("FROM scratch\nCOPY a2a_server.py /\n")
(folder / "a2a_server.py").write_text("print('ok')\n")
image = PublishedImage("agentenv-claude-code", folder / "Dockerfile")
monkeypatch.setattr(put_module, "AGENT_IMAGES", (image,))
monkeypatch.setattr(put_module, "published_digest", lambda published: DIGEST)
return image


def _put(*args):
return CliRunner().invoke(cli, ["a2a-agent", "put", "--id", "claude-code-cli", *args, "--skip-validation"])


def test_published_registers_the_released_image_by_tag_and_digest_with_the_folder_as_its_context(shipped):
result = _put("--published", "claude-code")

assert result.exit_code == 0, result.output
agent = A2AAgent.get("claude-code-cli")
image = get_artifact_store().get(agent.docker_image_artifact.id, agent.docker_image_artifact.version)
assert image.image_name == shipped.ref() and image.image_name.startswith("ghcr.io/scaleapi/agentenv-claude-code:src-")
assert image.image_digest == DIGEST
assert image.build_context_object_url
assert image.source_digest == "sha256:" + shipped.tag().removeprefix("src-")
assert not image.context_only
assert f"{shipped.ref()}@{DIGEST}" in result.output


def test_published_refuses_when_no_image_is_published_for_the_installed_sources(shipped, monkeypatch):
monkeypatch.setattr(put_module, "published_digest", lambda published: None)

result = _put("--published", "claude-code")

assert result.exit_code == 1
assert "no image is published" in result.output and shipped.ref() in result.output
assert get_artifact_store().next_version("claude-code-cli__agent_image") == 1
with pytest.raises(NotFoundError):
A2AAgent.get("claude-code-cli")


def test_published_names_the_agents_it_can_register(shipped):
result = _put("--published", "pi")

assert result.exit_code == 1
assert "publishes no image for 'pi'" in result.output and "claude-code" in result.output


@pytest.mark.parametrize("other", [["--image-ref", "ghcr.io/acme/agent:1"], ["--dockerfile", "{dockerfile}"]])
def test_published_is_one_of_the_image_sources(shipped, other):
args = [arg.format(dockerfile=shipped.dockerfile) for arg in other]

result = _put("--published", "claude-code", *args)

assert result.exit_code == 1
assert "exactly one of --dockerfile, --image-ref and --published" in result.output
4 changes: 2 additions & 2 deletions tst/unit/cli/image_ref_put_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ def test_an_agent_is_registered_over_an_image_in_a_registry(local_stores, pinned


@pytest.mark.parametrize("extra, message", [
([], "exactly one of --dockerfile and --image-ref is required"),
(["--image-ref", REF, "--dockerfile", "Dockerfile"], "exactly one of --dockerfile and --image-ref is required"),
([], "exactly one of --dockerfile, --image-ref and --published is required"),
(["--image-ref", REF, "--dockerfile", "Dockerfile"], "exactly one of --dockerfile, --image-ref and --published is required"),
(["--image-ref", REF, "--context", "."], "--context needs --dockerfile"),
])
def test_an_agent_takes_one_image_source(local_stores, pinned, tmp_path, extra, message):
Expand Down
Loading