Skip to content
Merged
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 .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ updates:
- "/src/agent_env/agents/agents/claude-code"
- "/src/agent_env/agents/agents/codex"
- "/src/agent_env/agents/agents/gemini"
- "/src/agent_env/agents/agents/hermes"
- "/src/agent_env/agents/agents/letta-code"
- "/src/agent_env/agents/agents/opencode"
- "/src/agent_env/agents/agents/openhands"
Expand Down
5 changes: 4 additions & 1 deletion .github/scripts/infra_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"agentenv-claude-code": "src/agent_env/agents/agents/claude-code/Dockerfile",
"agentenv-codex": "src/agent_env/agents/agents/codex/Dockerfile",
"agentenv-gemini": "src/agent_env/agents/agents/gemini/Dockerfile",
"agentenv-hermes": "src/agent_env/agents/agents/hermes/Dockerfile",
"agentenv-letta-code": "src/agent_env/agents/agents/letta-code/Dockerfile",
"agentenv-opencode": "src/agent_env/agents/agents/opencode/Dockerfile",
"agentenv-openhands": "src/agent_env/agents/agents/openhands/Dockerfile",
Expand All @@ -59,7 +60,7 @@
_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, Claude Code's, Codex's, Gemini's, OpenCode's and pi's agents run their CLIs and
# browser answer their own commands, Claude Code's, Codex's, Gemini's, Hermes', OpenCode's and pi's agents run their CLIs and
# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, Pydantic AI's imports its
# server, which loads Pydantic AI, the proxy imports its server, the mail example's env imports its server, and the
# base computer's desktop comes up, every program running.
Expand All @@ -76,6 +77,8 @@
'codex --version && mcp-remote --help >/dev/null && python -c "import a2a_server"'],
"agentenv-gemini": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
'gemini --version && python -c "import a2a_server"'],
"agentenv-hermes": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
'hermes --version && python -c "import a2a_server"'],
"agentenv-letta-code": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
'letta --version && python -c "import a2a_server"'],
"agentenv-opencode": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ the previous release, so a clear pull request title is also the release note. A

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 one per shipped agent (`agentenv-claude-code`, its CLI
included, `agentenv-codex`, `agentenv-gemini`, `agentenv-opencode`, `agentenv-pi`, `agentenv-openhands` and `agentenv-pydantic-ai`), at `ghcr.io/scaleapi/agentenv-<name>:src-<hex>` for linux/amd64
included, `agentenv-codex`, `agentenv-gemini`, `agentenv-hermes`, `agentenv-opencode`, `agentenv-pi`, `agentenv-openhands` and `agentenv-pydantic-ai`), at `ghcr.io/scaleapi/agentenv-<name>:src-<hex>` 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 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
Expand Down
14 changes: 11 additions & 3 deletions src/agent_env/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,17 @@ its smoke task needs `[model] default` (or the `agent` role) set; a `vllm:` pref
requests to the model's family as a vLLM server needs. It declares `install/v1` too, for Debian or Ubuntu targets. Its
folder's `README.md` covers the extensions it implements.

A release publishes Codex's, Gemini's, OpenCode's, pi's, OpenHands' and Pydantic AI's images too, at
`agents/hermes/` runs Nous Research's [Hermes Agent](https://github.com/NousResearch/hermes-agent) behind the
protocol's A2A agent framework: each turn is one `hermes agentenv turn`, a command the agent's own Hermes plugin adds,
which runs the turn in its process, resumed in the context's Hermes session; each context is a Hermes profile of its
own, the task env's MCP servers go to its `config.yaml`, and Hermes' plugin hook events are the turn's trajectory. Name
it as `hermes`, its alias, or by its id, `@local/agentenv-framework/agents/hermes`. It calls the `[model]` endpoint as
an OpenAI-compatible one, `<base>/v1/chat/completions`, and takes its model from the prompt, else
`anthropic/claude-sonnet-4-6`. Its folder's `README.md` covers the extensions it implements.

A release publishes Codex's, Gemini's, Hermes', OpenCode's, pi's, OpenHands' and Pydantic AI's images too, at
`ghcr.io/scaleapi/agentenv-codex:src-<hex>`, `ghcr.io/scaleapi/agentenv-gemini:src-<hex>`,
`ghcr.io/scaleapi/agentenv-opencode:src-<hex>`, `ghcr.io/scaleapi/agentenv-pi:src-<hex>`,
`ghcr.io/scaleapi/agentenv-hermes:src-<hex>`, `ghcr.io/scaleapi/agentenv-opencode:src-<hex>`, `ghcr.io/scaleapi/agentenv-pi:src-<hex>`,
`ghcr.io/scaleapi/agentenv-openhands:src-<hex>` and `ghcr.io/scaleapi/agentenv-pydantic-ai:src-<hex>`, 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.
Expand All @@ -107,5 +115,5 @@ store credentials. A release publishes its image at `ghcr.io/scaleapi/agentenv-p
others'. It has no alias and no smoke task: with nobody driving it, a prompt only waits out its deadline.

`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 codex-smoke` Codex's, `--task gemini-smoke` Gemini's, `--task opencode-smoke` OpenCode's,
Claude Code's alone, `--task codex-smoke` Codex's, `--task gemini-smoke` Gemini's, `--task hermes-smoke` Hermes', `--task opencode-smoke` OpenCode's,
`--task pi-smoke` pi's, `--task openhands-smoke` OpenHands', `--task pydantic-ai-smoke` Pydantic AI's.
2 changes: 2 additions & 0 deletions src/agent_env/agents/agents/hermes/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__
*.pyc
34 changes: 34 additions & 0 deletions src/agent_env/agents/agents/hermes/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c433a185bf3ad162c0288fe484616ce062e0d28add2900
# Hermes Agent at a pinned release, installed by its own installer, which the image checks against its checksum. The
# installer brings Hermes' runtime (its Python, Node and ripgrep among them) under its home, /data/.hermes; git and xz
# are what it needs to fetch them. poppler-utils renders a PDF attachment to the page images Hermes looks at.
ARG HERMES_COMMIT=bf0203a794a07aac3ff764972ab9087baa4e4f4e
ARG HERMES_INSTALLER_SHA256=99cb3691d55019c6973a7acb775a915648666cc24e483ca827a1cdbb3531a674
RUN set -eu; \
apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git poppler-utils xz-utils \
&& rm -rf /var/lib/apt/lists/*; \
useradd --create-home --uid 10001 agent && mkdir -p /app /data; \
curl -fsSL --retry 5 --retry-all-errors \
"https://raw.githubusercontent.com/NousResearch/hermes-agent/${HERMES_COMMIT}/scripts/install.sh" -o /tmp/install.sh; \
echo "$HERMES_INSTALLER_SHA256 /tmp/install.sh" | sha256sum -c -; \
HOME=/data bash /tmp/install.sh --commit "$HERMES_COMMIT" --dir /opt/hermes --hermes-home /data/.hermes \
--non-interactive --skip-browser --skip-computer-use; \
# What a turn never reads: the checkout's history, its UI builds' sources and dependencies, tests and docs, and
# the installer's download cache.
rm -rf /tmp/install.sh /opt/hermes/.git /opt/hermes/node_modules /opt/hermes/website /opt/hermes/tests \
/data/.hermes/cache /data/.local /data/.bashrc /data/.profile; \
ln -s /opt/hermes/.hermes/bin/hermes /usr/local/bin/hermes; \
chown -R agent:agent /app /data
WORKDIR /opt/agent
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY a2a_server.py hermes_bridge.py hermes_kit.py entrypoint.sh ./
COPY agentenv_plugin ./agentenv_plugin
RUN chmod 0755 entrypoint.sh
# Each context's Hermes profile is under /data/.hermes, Hermes' home, whatever the uid: Modal runs the image as root.
ENV A2A_PORT=8000 PYTHONPATH=/opt/agent HOME=/data HERMES_ROOT=/data/.hermes
USER agent
WORKDIR /app
EXPOSE 8000
# CMD rather than ENTRYPOINT: Modal passes a sandbox's env to the image's CMD only.
CMD ["/opt/agent/entrypoint.sh"]
49 changes: 49 additions & 0 deletions src/agent_env/agents/agents/hermes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Hermes Agent A2A agent

Nous Research's [Hermes Agent](https://github.com/NousResearch/hermes-agent) wrapped with the
`agentenv_protocol.a2a_agent` framework, shipped in the `agents` bundle as `@local/agentenv-framework/agents/hermes`.
This directory is the whole build context: the protocol package comes from PyPI (`requirements.txt`), and Hermes from
its own installer at a pinned release, checked against its checksum, in the `Dockerfile` (`HERMES_COMMIT`, the
`v0.22.5` release).

Each context is a Hermes profile of its own, `$HERMES_ROOT/profiles/ctx-<hash>/`, with its own config, sessions and
memory. Each task runs `hermes agentenv turn` once in `/app`, a command the agent's Hermes plugin, `agentenv_plugin/`,
adds: it runs the turn in its own process, as `hermes -z` does, rather than handing it to a Hermes gateway, so the turn
ends with that process and a timeout or cancel that kills it, and everything it started, stops the turn. A context's
first task starts a Hermes session, and each later task resumes it with `--resume <session_id>`. Text parts are the
prompt, and file parts are written under `/app/inputs/<task>/` and named in it, for Hermes' file and vision tools; a
PDF goes with its pages' PNGs, rendered with `pdftoppm`. Turns of different contexts run at once, as many as memory fits
at about 400 MB each past the first 1 GB (`HERMES_MAX_CONCURRENT_TURNS` sets the number), and a context's turns one at
a time; a task's files are downloaded and rendered only once it has a turn's slot, so they count against that number.

The profile's `config.yaml` is written before each turn:

- the model endpoint, `$LITELLM_BASE_URL/v1`, as a custom OpenAI-compatible provider with its key from
`LITELLM_API_KEY`, `[model.headers]` (`LITELLM_EXTRA_HEADERS`) and the run's `project_id` and `task_id` as
`x-litellm-customer-id` and `x-litellm-tags` on every model call, Hermes' auxiliary calls (titles, compression,
vision) included. The run's tags join an `x-litellm-tags` that `[model.headers]` names, whose own keys, such as
`taskId`, win;
- the model: the task's, else `HERMES_MODEL`, else `anthropic/claude-sonnet-4-6`;
- every registered MCP server, the task's `peer_agents` tools included, as a remote server. Its `AgentEnv-Role` header
is the role its registration names, unless the task has a role of its own;
- the skills agent-env installed, as an external skills folder, and the `agentenv` plugin enabled.

The task's `system_prompt` is the profile's `SOUL.md`, the slot Hermes' own identity fills, so it reaches the model as
the system prompt; a session keeps the system prompt it started with, as Hermes does. A turn with no system prompt gets
Hermes' own back. Hermes runs non-interactively: dangerous-command approvals are bypassed, and its `clarify` tool tells
the model no one is there to answer.

| Extension | Implementation |
|---|---|
| `agent-config/v1` | `HermesConfig`: `model`, `effort` (Hermes' `--reasoning`), `system_prompt`, `output_format` (a JSON schema the answer is checked against), `project_id`, `task_id`, `timeout_seconds` (1800 by default) |
| `mcp-config/v1` | SDK default; each server written to the turn's profile config |
| `skill-config/v1` | inline and bundle skills installed under `$HERMES_ROOT/agentenv-skills/<name>/`, which every profile reads as `skills.external_dirs` |
| `trajectory/v1` | the turn's Hermes plugin hook events (`on_session_start`, `pre_tool_call`, `post_tool_call`, `post_api_request` with each model response, its tool calls and usage, `api_request_error`, `post_llm_call`, `on_session_end`), live while it runs (format `hermes-hook-events/1`) |
| `snapshot/v1` | `save` archives the context's profile (its sessions database, memory and `SOUL.md`; not the config, caches or logs Hermes rebuilds) with its session id, and `/app`, once no task runs; `load` restores the profile into the target context and replaces `/app`'s contents, both or neither, so a failed load leaves the context's profile, session and workspace as they were. No changelog |
| `peer-agents/v1` | SDK default: Hermes reaches the task's other agents through the SDK's `peer_agents` MCP tools |
| `attribution-probe/v1` | the `project_id` and `task_id` the last turn sent |

A turn's usage is Hermes' own report for its main model calls: tokens, `api_calls`, and its estimated cost where the
endpoint's model list prices the model. The image is `python:3.11-slim` with Hermes, `pdftoppm` and the agent, running
as the `agent` user. Hermes' runtime (its own Python, Node and ripgrep) and the profiles are under `/data/.hermes`,
whatever the uid. It has no `install/v1`.
Loading
Loading