diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 34cc5e0c..bb63911c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -41,6 +41,8 @@ updates: - "/src/agent_env/env/envs/service_db" - "/src/agent_env/env/envs/website_browser" - "/src/agent_env/agents/agents/claude-code" + - "/src/agent_env/agents/agents/openhands" + - "/src/agent_env/agents/agents/pi" schedule: interval: "weekly" open-pull-requests-limit: 5 diff --git a/.github/scripts/infra_build.py b/.github/scripts/infra_build.py index 4a9fe46b..0e21ac11 100644 --- a/.github/scripts/infra_build.py +++ b/.github/scripts/infra_build.py @@ -1,5 +1,5 @@ """What the release workflow's jobs that hold a token to push images or publish to PyPI run for the images agent-env -publishes, its infra images and the Claude Code agent's. It uses only the standard library and the docker CLI, so those +publishes, its infra images and the shipped agents'. It uses only the standard library and the docker CLI, so those jobs install nothing; what they're handed by the jobs that run agent-env (``infra_images.py``) is checked against the images agent-env ships before it's built, pushed or recorded. @@ -32,6 +32,8 @@ "agentenv-db-mcp": "src/agent_env/env/envs/service_db/Dockerfile.db-mcp", "agentenv-website-browser": "src/agent_env/env/envs/website_browser/Dockerfile", "agentenv-claude-code": "src/agent_env/agents/agents/claude-code/Dockerfile", + "agentenv-openhands": "src/agent_env/agents/agents/openhands/Dockerfile", + "agentenv-pi": "src/agent_env/agents/agents/pi/Dockerfile", } _TAG = re.compile(r"^src-[0-9a-f]{64}$") _DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") @@ -49,7 +51,8 @@ _RETRY_WAITS_SECONDS = (30, 60, 120) # Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, the sidecars and the -# browser answer their own commands, and Claude Code's agent runs its CLI and imports its server. +# browser answer their own commands, Claude Code's and pi's agents run their CLIs and import their servers, and OpenHands' +# imports its server, which loads the OpenHands SDK. _SMOKES = { "agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"], "agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"], @@ -59,6 +62,9 @@ " && ls -d /root/.cache/ms-playwright/chromium-*"], "agentenv-claude-code": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'claude --version && python -c "import a2a_server"'], + "agentenv-openhands": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'python -c "import a2a_server"'], + "agentenv-pi": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", + 'pi --version && python3 -c "import a2a_server"'], } diff --git a/.github/scripts/infra_images.py b/.github/scripts/infra_images.py index 2c11bd45..ea200d52 100644 --- a/.github/scripts/infra_images.py +++ b/.github/scripts/infra_images.py @@ -1,4 +1,4 @@ -"""The images agent-env publishes, its infra images and the Claude Code agent's (``agent_env.env.published_images``), for +"""The images agent-env publishes, its infra images and the shipped agents' (``agent_env.env.published_images``), for the release workflow's jobs that only read: this imports agent-env, so it runs where agent-env and its dependencies are installed, and no job that holds a token to push images or publish to PyPI runs it (those run ``infra_build.py``). diff --git a/AGENTS.md b/AGENTS.md index f3aaf341..60ede76c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,7 +283,7 @@ and every job that reads those folders runs it first, so an image's tag, its bui and creates the GitHub release, whose notes are the titles of the pull requests merged since the previous release. Once the repository variable `PUBLISH_INFRA_IMAGES` is `true`, it also publishes the infra images agent-env -ships and the Claude Code agent's (`agent_env.env.published_images`) whose `src-` tags GHCR lacks: after `main`'s CI passes, each is +ships and the shipped agents', one per agent folder (`agent_env.env.published_images`), whose `src-` tags GHCR lacks: after `main`'s CI passes, each is built on a runner of each platform, checked, and tagged as one multi-platform image, and a tag that exists is never moved. The infra images are published before the protocol, and a failed infra image job sends neither package to PyPI. An agent's `requirements.txt` keeps its protocol floor, unpinned, so its image is built again only when its diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a9dbad0..a5a08f53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,12 +89,12 @@ the previous release, so a clear pull request title is also the release note. A `main`'s CI is not published and gets no release; its pull requests are in the next release's notes. A release also publishes the infra images agent-env ships, once the `PUBLISH_INFRA_IMAGES` repository variable is -`true`: one per Dockerfile under `src/agent_env/env`, and the Claude Code agent's (`agentenv-claude-code`, its CLI -included), at `ghcr.io/scaleapi/agentenv-:src-` for linux/amd64 +`true`: one per Dockerfile under `src/agent_env/env`, and one per shipped agent (`agentenv-claude-code`, its CLI +included, `agentenv-pi` and `agentenv-openhands`), at `ghcr.io/scaleapi/agentenv-:src-` for linux/amd64 and linux/arm64, where the tag is the build context's source digest, so an unchanged Dockerfile is not built again. -The Claude Code image installs the newest `agentenv-framework-protocol` at or above the floor in its -`requirements.txt`, which a release doesn't pin, so it is built again only when its folder changes: once the protocol is -on PyPI, as a raised floor may name it, and before `agentenv-framework` is published. +The agents' images install the newest `agentenv-framework-protocol` at or above the floor in their +`requirements.txt`, which a release doesn't pin, so each is built again only when its folder changes: once the protocol +is on PyPI, as a raised floor may name it, and before `agentenv-framework` is published. Each of them pins its base images by digest, and Dependabot bumps those pins within each base's major and minor version (an upgrade, such as postgres 16 to 18, is made by hand), so a base's fixes reach the published image: a bump changes the sources, and the next release builds it again. diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 7ef8cb48..b4f66a09 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -13,8 +13,8 @@ does: the `[model]` base URL and key reach it as `ANTHROPIC_BASE_URL` and a bear on every model call. The `LITELLM_*` variables stay in its environment too, so a script the model runs can call the endpoint. The image holds Claude Code, which is Anthropic's software under its [Commercial Terms](https://www.anthropic.com/legal/commercial-terms); a release also publishes the image, CLI included, -at `ghcr.io/scaleapi/agentenv-claude-code:src-`, built from this folder with the protocol pinned as the wheel -ships it. A run that deploys the agent registers that image when it's published for the sources installed: each deploy +at `ghcr.io/scaleapi/agentenv-claude-code:src-`, 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 `CLAUDE_CODE_VERSION`. The image gives the scripts the model runs @@ -28,7 +28,8 @@ or in `/app` on the host. `agents/pi/` runs the [pi coding agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) behind the protocol's A2A agent framework: each turn is one `pi --mode json` run, the task env's MCP servers go to its -`mcp.json`, and its JSON events are the turn's trajectory. Name it as `@local/agentenv-framework/agents/pi`. It calls +`mcp.json`, and its JSON events are the turn's trajectory. Name it as `pi`, its alias, or by its id, +`@local/agentenv-framework/agents/pi`. It calls the `[model]` endpoint as an OpenAI-compatible one and takes its model from the prompt, so its smoke task needs `[model] default` (or the `agent` role) set. It declares `install/v1` too, for Debian or Ubuntu targets. Its folder's `README.md` covers the extensions it implements. @@ -36,9 +37,15 @@ Its folder's `README.md` covers the extensions it implements. `agents/openhands/` runs the [OpenHands](https://github.com/OpenHands/software-agent-sdk) agent behind the protocol's A2A agent framework, driving the OpenHands SDK in process rather than a CLI: each turn is one run of a persisted SDK conversation, the task env's MCP servers go to its `mcp_config`, and its SDK events are the turn's -trajectory. Name it as `@local/agentenv-framework/agents/openhands`. It calls the `[model]` endpoint through LiteLLM -and takes its model from the prompt, so its smoke task needs `[model] default` (or the `agent` role) set. It declares -`install/v1` too, for Debian or Ubuntu targets. Its folder's `README.md` covers the extensions it implements. +trajectory. Name it as `openhands`, its alias, or by its id, `@local/agentenv-framework/agents/openhands`. It +calls the `[model]` endpoint through LiteLLM and takes its model from the prompt, so its smoke task needs +`[model] default` (or the `agent` role) set. It declares `install/v1` too, for Debian or Ubuntu targets. Its folder's +`README.md` covers the extensions it implements. -`agent-env run agents` runs every agent's smoke task, building each image; `--task claude-code-smoke` runs Claude +A release publishes pi's and OpenHands' images too, at `ghcr.io/scaleapi/agentenv-pi:src-` and +`ghcr.io/scaleapi/agentenv-openhands:src-`, each built again only when its folder changes, and a run deploys +them as it does Claude Code's: it pulls the image +published for the sources installed, by its digest, and builds it from the agent's folder only when it can't. + +`agent-env run agents` runs every agent's smoke task, pulling or building each image; `--task claude-code-smoke` runs Claude Code's alone, `--task pi-smoke` pi's, `--task openhands-smoke` OpenHands'. diff --git a/src/agent_env/agents/agents/openhands/requirements.txt b/src/agent_env/agents/agents/openhands/requirements.txt index 63fd059e..a8f25c20 100644 --- a/src/agent_env/agents/agents/openhands/requirements.txt +++ b/src/agent_env/agents/agents/openhands/requirements.txt @@ -1,3 +1,6 @@ +# The image installs the newest agentenv-framework-protocol at or above this floor; a release doesn't pin it, so the +# image is built again only when this folder changes. The floor is the first that serves peer-agents/v1, which the +# agent leaves to the SDK; raise it when the agent needs a newer protocol. agentenv-framework-protocol[agent]>=0.1.351 openhands-sdk==1.57.0 openhands-tools==1.57.0 diff --git a/src/agent_env/agents/aliases.toml b/src/agent_env/agents/aliases.toml index 2c4e5473..c27815e3 100644 --- a/src/agent_env/agents/aliases.toml +++ b/src/agent_env/agents/aliases.toml @@ -2,3 +2,4 @@ [agents] claude-code = ["claude-code"] pi = ["pi"] +openhands = ["openhands"] diff --git a/src/agent_env/env/published_images.py b/src/agent_env/env/published_images.py index 8028bf83..1ccd99a6 100644 --- a/src/agent_env/env/published_images.py +++ b/src/agent_env/env/published_images.py @@ -50,8 +50,9 @@ def ref(self) -> str: return f"{self.repository}:{self.tag()}" -# The images of the agents agent-env ships that its releases publish, Claude Code's CLI included: the default agent's. -AGENT_IMAGES = (PublishedImage("agentenv-claude-code", _AGENTS / "claude-code" / "Dockerfile"),) +# The images of the agents agent-env ships that its releases publish: one per agent folder, Claude Code's CLI included. +AGENT_IMAGES = tuple(PublishedImage(f"agentenv-{folder.name}", folder / "Dockerfile") + for folder in sorted(_AGENTS.iterdir()) if (folder / "Dockerfile").is_file()) def published_digest(image: PublishedImage) -> str | None: diff --git a/tst/unit/bundle/shipped_bundles_test.py b/tst/unit/bundle/shipped_bundles_test.py index 85513f8e..a5ad2380 100644 --- a/tst/unit/bundle/shipped_bundles_test.py +++ b/tst/unit/bundle/shipped_bundles_test.py @@ -42,7 +42,8 @@ def test_the_shipped_aliases_are_agent_envs_alone_and_claude_code_names_the_defa assert table.refused == {} and table.refused_claims == {} assert {alias: named.agent_id for alias, named in table.aliases.items()} == { - "claude-code": "@local/agentenv-framework/agents/claude-code", "pi": "@local/agentenv-framework/agents/pi"} + "claude-code": "@local/agentenv-framework/agents/claude-code", "pi": "@local/agentenv-framework/agents/pi", + "openhands": "@local/agentenv-framework/agents/openhands"} assert table.target("claude-code").agent_id == get_config().get_default_a2a_agent_id() diff --git a/tst/unit/env/published_images_test.py b/tst/unit/env/published_images_test.py index 397efc60..932c2c60 100644 --- a/tst/unit/env/published_images_test.py +++ b/tst/unit/env/published_images_test.py @@ -25,9 +25,9 @@ def test_every_shipped_dockerfile_is_published_once_under_a_name_of_its_own(): - """The infra envs' and the default agent's, Claude Code.""" + """The infra envs' and every shipped agent's.""" shipped = [dockerfile for dockerfiles in bootstrap._DOCKERFILES.values() for dockerfile in dockerfiles] - shipped.append(published_images._AGENTS / "claude-code" / "Dockerfile") + shipped += published_images._AGENTS.glob("*/Dockerfile") assert sorted(image.dockerfile for image in bootstrap.PUBLISHED_IMAGES) == sorted(shipped) assert len({image.name for image in bootstrap.PUBLISHED_IMAGES}) == len(bootstrap.PUBLISHED_IMAGES) diff --git a/tst/unit/test_infra_images_script.py b/tst/unit/test_infra_images_script.py index 5a454b22..1090e926 100644 --- a/tst/unit/test_infra_images_script.py +++ b/tst/unit/test_infra_images_script.py @@ -60,7 +60,7 @@ def test_plan_only_lists_the_infra_images_or_the_agents_images(script, monkeypat agents = [image.name for image in AGENT_IMAGES] assert planned == {"infra": [image.name for image in bootstrap.PUBLISHED_IMAGES if image.name not in agents], "agents": agents} - assert "agentenv-gateway" in planned["infra"] and agents == ["agentenv-claude-code"] + assert "agentenv-gateway" in planned["infra"] and agents == ["agentenv-claude-code", "agentenv-openhands", "agentenv-pi"] def test_plan_missing_lists_the_images_the_registry_lacks_or_refuses_to_show(script, monkeypatch, capsys):