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("/"): "-"}