From 4717896d42e420fb06e4c4cf810bad7820d19d1e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Fri, 23 Oct 2026 10:36:38 -0700 Subject: [PATCH] fix(agents): pi and OpenHands installed into a task container work in its working directory Both started from their install folder and fell back to /workspace, so an install_agent into a task container left them outside the task's files, where Claude Code works in the container's working directory. start.sh now names the directory it's started in as the workspace, unless that's the root; the image keeps /workspace. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../agents/agents/openhands/README.md | 5 +-- .../agents/agents/openhands/start.sh | 2 ++ src/agent_env/agents/agents/pi/README.md | 5 +-- src/agent_env/agents/agents/pi/a2a_server.py | 5 ++- src/agent_env/agents/agents/pi/start.sh | 2 ++ tst/unit/agents/pi/test_pi_agent.py | 13 ++++++- tst/unit/agents/test_installed_workspace.py | 36 +++++++++++++++++++ 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 tst/unit/agents/test_installed_workspace.py diff --git a/src/agent_env/agents/agents/openhands/README.md b/src/agent_env/agents/agents/openhands/README.md index 13c32d6e..140632c0 100644 --- a/src/agent_env/agents/agents/openhands/README.md +++ b/src/agent_env/agents/agents/openhands/README.md @@ -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/`, 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 @@ -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`. diff --git a/src/agent_env/agents/agents/openhands/start.sh b/src/agent_env/agents/agents/openhands/start.sh index bd33facb..3d51b5bb 100644 --- a/src/agent_env/agents/agents/openhands/start.sh +++ b/src/agent_env/agents/agents/openhands/start.sh @@ -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 diff --git a/src/agent_env/agents/agents/pi/README.md b/src/agent_env/agents/agents/pi/README.md index d1772a3a..c80c211c 100644 --- a/src/agent_env/agents/agents/pi/README.md +++ b/src/agent_env/agents/agents/pi/README.md @@ -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: @@ -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: diff --git a/src/agent_env/agents/agents/pi/a2a_server.py b/src/agent_env/agents/agents/pi/a2a_server.py index c9b0bd16..77a4c146 100644 --- a/src/agent_env/agents/agents/pi/a2a_server.py +++ b/src/agent_env/agents/agents/pi/a2a_server.py @@ -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 diff --git a/src/agent_env/agents/agents/pi/start.sh b/src/agent_env/agents/agents/pi/start.sh index 1976da27..dfd19932 100644 --- a/src/agent_env/agents/agents/pi/start.sh +++ b/src/agent_env/agents/agents/pi/start.sh @@ -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 diff --git a/tst/unit/agents/pi/test_pi_agent.py b/tst/unit/agents/pi/test_pi_agent.py index 3238cc3f..d8ee8027 100644 --- a/tst/unit/agents/pi/test_pi_agent.py +++ b/tst/unit/agents/pi/test_pi_agent.py @@ -1,4 +1,5 @@ import base64 +import importlib.util import io import json import os @@ -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 @@ -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) diff --git a/tst/unit/agents/test_installed_workspace.py b/tst/unit/agents/test_installed_workspace.py new file mode 100644 index 00000000..168f36d0 --- /dev/null +++ b/tst/unit/agents/test_installed_workspace.py @@ -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("/"): "-"}