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
5 changes: 3 additions & 2 deletions src/agent_env/agents/agents/openhands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ them in a Python of uv's own, with tmux and git for the agent's terminal, both f

Instead of wrapping a CLI, the agent drives the OpenHands SDK in process. `openhands_agent.py` is the
A2A side and imports no SDK; `openhands_runtime.py` runs the conversation. Each task is one `arun()`
of an SDK conversation in `/workspace`, with the SDK's default tools (terminal, file editor, task
of an SDK conversation in its workspace, with the SDK's default tools (terminal, file editor, task
tracker; no browser) and its default condenser, which summarizes a conversation that outgrows the model's window.
The model is called through the `[model]` LiteLLM endpoint as `litellm_proxy/<model>`, with `LITELLM_EXTRA_HEADERS`
sent on every call but kept out of the persisted conversation, which a snapshot uploads. Each turn runs on a thread
Expand All @@ -32,7 +32,8 @@ carry it.
| `triggers/v1` | SDK default |

`OPENHANDS_A2A_MODEL` sets the model when agent-config sets none, as for a peer that other agents
message directly, and `OPENHANDS_A2A_WORKSPACE` the workspace (default `/workspace`). `a2a_server.py` reads
message directly, and `OPENHANDS_A2A_WORKSPACE` the workspace (default `/workspace`, and the task container's working directory
when it's installed there). `a2a_server.py` reads
these and the `LITELLM_*` variables once, at startup: a `LITELLM_EXTRA_HEADERS` that is not a JSON object stops
the agent there, while a missing `LITELLM_BASE_URL` fails each task as `openhands.no_endpoint`.

Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/agents/agents/openhands/start.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/bin/sh
# Starts the installed agent (install/v1) with the credentials install wrote to agent.env; the caller sets A2A_PORT.
here=$(cd "$(dirname "$0")" && pwd)
# The agent works where it's started, the task container's working directory, unless that's the root.
[ "$(pwd)" = / ] || export OPENHANDS_A2A_WORKSPACE="$(pwd)"
set -a
. "$here/agent.env"
set +a
Expand Down
5 changes: 3 additions & 2 deletions src/agent_env/agents/agents/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ with the `agentenv_protocol.a2a_agent` framework, shipped in the `agents` bundle
protocol package comes from PyPI (`requirements.txt`), and `install.sh` installs Node, pi and the
agent's environment both for the image and for `install/v1`.

Each task runs `pi --mode json` once in `/workspace`. The prompt goes in on stdin. Images and text
Each task runs `pi --mode json` once in its workspace: `PI_A2A_WORKSPACE`, default `/workspace`, and the task
container's working directory when it's installed there. The prompt goes in on stdin. Images and text
files are attached as `@path`; any other file is saved to disk and its path is added to the prompt,
for pi's tools to use. A file fetched from a URL may be at most 256 MiB. The pi config dir is built fresh for each task:

Expand Down Expand Up @@ -36,7 +37,7 @@ reserves some request fields, such as `user` and `metadata`, and does not forwar
attribution, sets it at registration through `PI_A2A_MODEL_PARAMS`, a JSON object that config
values override. Two more registration variables: `PI_A2A_MODEL`, the model when agent-config sets
none (as for a peer that other agents message directly), and `PI_A2A_CHANGELOG_ROOTS`, the
colon-separated changelog roots (default `/workspace:/app`). Roots an enable request names must lie
colon-separated changelog roots (default the workspace and `/app`). Roots an enable request names must lie
within them, replay writes only under them, and a replaying agent must share them with the capturing
one. While a changelog is enabled, tasks run one at a time:

Expand Down
5 changes: 4 additions & 1 deletion src/agent_env/agents/agents/pi/a2a_server.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Serves the pi agent. ``AGENT_CARD`` is what agent-env's install/v1 reads from ``/app/a2a_server.py``."""

import os
from pathlib import Path

from pi_agent import PiAgent

agent = PiAgent()
agent = PiAgent(workspace=Path(os.environ.get("PI_A2A_WORKSPACE", "/workspace")))
app = agent.create_app()
AGENT_CARD = app.state.agentenv_a2a.card

Expand Down
2 changes: 2 additions & 0 deletions src/agent_env/agents/agents/pi/start.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/bin/sh
# Starts the installed agent (install/v1) with the credentials install wrote to agent.env; the caller sets A2A_PORT.
here=$(cd "$(dirname "$0")" && pwd)
# The agent works where it's started, the task container's working directory, unless that's the root.
[ "$(pwd)" = / ] || export PI_A2A_WORKSPACE="$(pwd)"
set -a
. "$here/agent.env"
set +a
Expand Down
13 changes: 12 additions & 1 deletion tst/unit/agents/pi/test_pi_agent.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import importlib.util
import io
import json
import os
Expand All @@ -12,7 +13,7 @@
import pytest
from starlette.testclient import TestClient

from tst.unit.agents.pi.agent import a2a_server, changelog, peers, pi_agent
from tst.unit.agents.pi.agent import AGENT_DIR, a2a_server, changelog, peers, pi_agent

PiAgent = pi_agent.PiAgent

Expand Down Expand Up @@ -753,6 +754,16 @@ def test_unattachable_files_are_left_on_disk_for_tools(client: TestClient, recor
assert "[Attached file (audio/mpeg) saved at " in invocation["stdin"]


def test_a2a_server_serves_the_workspace_its_environment_names(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("PI_A2A_WORKSPACE", str(tmp_path))
spec = importlib.util.spec_from_file_location("pi_a2a_server_workspace", AGENT_DIR / "a2a_server.py")
server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server)

assert server.agent.workspace == tmp_path
assert a2a_server.agent.workspace == Path("/workspace")


def test_a2a_server_exposes_the_card_install_reads() -> None:
assert a2a_server.AGENT_CARD.name == "pi"
assert any(e.uri == "urn:agentenv:install/v1" for e in a2a_server.AGENT_CARD.capabilities.extensions)
Expand Down
36 changes: 36 additions & 0 deletions tst/unit/agents/test_installed_workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""An agent installed into a task container (install/v1) works in the container's working directory, where its
``start.sh`` is started, as Claude Code does; one started at the root keeps its image's workspace."""

import os
import subprocess
import sys
from pathlib import Path

import pytest

import agent_env

AGENTS = Path(agent_env.__file__).parent / "agents" / "agents"


@pytest.mark.parametrize("agent, variable", [("pi", "PI_A2A_WORKSPACE"), ("openhands", "OPENHANDS_A2A_WORKSPACE")])
def test_an_installed_agent_works_in_the_directory_it_is_started_in_unless_that_is_the_root(tmp_path, agent, variable):
here = tmp_path / f"{agent}-a2a"
here.mkdir()
# The agent's start.sh, logging into this test's folder rather than /tmp.
(here / "start.sh").write_text((AGENTS / agent / "start.sh").read_text().replace("/tmp/", f"{tmp_path}/"))
(here / "agent.env").write_text("")
# In place of the server: the workspace it would be served with, written beside it.
(here / "a2a_server.py").write_text(
f"import os, pathlib\npathlib.Path(__file__).with_name('seen').write_text(os.environ.get({variable!r}, '-'))\n")
task = tmp_path / "task_repo"
task.mkdir()
env = {**os.environ, "PATH": f"{Path(sys.executable).parent}{os.pathsep}{os.environ['PATH']}"}
env.pop(variable, None)

seen = {}
for cwd in (task, Path("/")):
subprocess.run(["sh", str(here / "start.sh")], cwd=cwd, env=env, check=True, timeout=60)
seen[cwd] = (here / "seen").read_text()

assert seen == {task: os.path.realpath(task), Path("/"): "-"}
Loading