Skip to content

feat(task): play an outside agent through a ProxyAgent in its place - #270

Open
earakely-scale wants to merge 31 commits into
mainfrom
feat/stand-in-agent
Open

earakely-scale wants to merge 31 commits into
mainfrom
feat/stand-in-agent

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

An episode's played agent is now played through a proxy: an A2A agent with no model, which the task's own deploy_agent or install_agent deploys in the played agent's place. Every action the caller's model takes runs in the proxy, which records the turn:

  • calls to the env's tools, through the servers registered with the proxy, as the agent's role;
  • bash, in the agent's sandbox, as its user, in its folder.

Before this PR, EpisodeSpec.from_task swapped the played agent's steps for steps that deployed nothing. So it refused every task that uses that agent's sandbox: install_agent, and load_artifact, collect_artifacts, run_code, verify_sandbox and add_skills aimed at the agent. With a proxy deployed there, those steps run unchanged.

The caller's API keeps its shape: from_task → start() → next_turn() → Turn → end() → result(). Turn gains record_model_call().

Builds on #280, merged and released as protocol 0.1.370, which added ProxyAgent and proxy/v1. The proxy's folder installs the protocol from PyPI at that floor, as every bundled agent does.

How a turn works

prompt_agent ──A2A message──> proxy (holds it as an open turn)
                                ^  │  the episode long-polls "next"
                                │  v
     caller <── Turn ── Episode ─┤  turn.tools(): "tools", then "act" per call ──> the proxy runs it:
       │                         │      an env tool, through mcp-config, as the role │ bash, where the agent works
       ├── turn.record_model_call(message) ──> "record": a chat span; later calls must be the ones it made
       └── turn.end(reply) ──────> "reply": the turn's result
prompt_agent <── an ordinary A2A reply: it records the response and the proxy's trajectory as for any agent

Where the proxy runs

The task… The proxy… Its tools
uses only envs is served in this process, on loopback, with each env registered as deploy_agent registers them the envs' tools
deploys the agent as a container (deploy_agent) is the container, from its own image the envs' tools, and bash in /app as agent
installs the agent on a VM's host (install_agent) is installed where the agent it replaces says it works bash, in that folder as that user
installs the agent into a container (install_agent with container_name) is installed in that container, with its own uv and Python bash, as root in the container's working directory

rules.works_in_sandbox decides which: install_agent, load_artifact, run_code or add_skills aimed at the played agent, verify_sandbox or collect_artifacts aimed at its sandbox, or skills on its deploy_agent.

Changes

agentenv-protocol: in #280, released as 0.1.370, plus fixes here from a review of what #280 shipped. Each has a test that fails on 0.1.370's code:

  • Python 3.10. The proxy's tool and listing deadlines caught the built-in TimeoutError, which isn't what asyncio.wait_for raises on 3.10, so on a 3.10 VM host a timeout escaped as a server error. They now catch asyncio.TimeoutError, and the protocol suite passes on 3.10.
  • A stopped action. An action polled while its turn closes is answered 404 ("its turn is over"), not a bare 500 from the CancelledError. A command that can't start (an argument list too long) is a failed bash result with exit code 126 and its span, not a 500 on every poll.
  • exit_code. ProxyBashResult.exit_code defaults to None: a stopped command's result leaves it out, which the published model refused to parse.
  • Rendering. The proxy's spans carry an OTel status, ERROR for a failed action or turn. The explorer, which threw on every proxy trajectory with an action because it read status.status_code unguarded, now reads a span with none; a smoke test covers a proxy trajectory.
  • Call matching. A recorded call to the proxy's own bash is matched by its command alone, since its action carries nothing else, and a whole float equals the int a driver writes for it. Either mismatch left the turn stuck. An env tool that is itself named bash is still held to all its arguments.

These ship in #270's protocol release, which the proxy picks up: its floor stays 0.1.370, and an install takes the newest protocol at or above it.

Core

  • agents/proxy/: the proxy, an agent folder like claude-code and pi, named @local/agentenv-framework/agents/proxy. The release publishes its image as agentenv-proxy.
    • a2a_server.py serves the protocol's ProxyAgent, passes on its lifespan, and declares install/v1 for a VM's host and for a container. install.sh host installs it on a VM's host, with the host's own Python in a venv. install.sh container installs it into a running container with a checksum-verified uv and a private Python 3.12, so the container's own are never touched.
    • Its image works in /app as user agent, with the tools that the steps acting on an agent's sandbox use.
    • requirements.txt installs the protocol from PyPI, with the floor at feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280's release, 0.1.370 (0.1.367 to 0.1.369 came out before it).
    • agent.toml sets takes_credentials = false.
    • The release script's IMAGES and _SMOKES list agentenv-proxy, and Dependabot watches the folder.
  • Bundles: write_installed(id) writes one entity of an installed bundle, with what it references, as a bundle run naming it would. It builds on plan_entities, which makes plan_bundle's checks for envs, agents or artifacts by id and runs nothing. The ledger reuses what's written until the folder changes.
  • A2AAgent: an agent whose metadata sets takes_credentials to False (TAKES_CREDENTIALS) gets no model key, endpoint or headers, from whatever set them, and none of the object store's shared credentials. The proxy sets it, so nothing bash runs can read a credential. An agent.toml's [metadata] now takes takes_credentials, a boolean.
  • verify_sandbox and collect_artifacts: aimed at an agent by name on a VM, they work on the host when install_agent put the agent there (on_host), as run_code already did. Before, they looked for a container such an agent doesn't have, for every agent, not only the proxy.
  • install_agent: a per-step proxy param.
    • It reads where the agent it replaces works from that agent's card, through two new install/v1 params, workdir_host and user_host, and installs the proxy there as {workdir} and {user}.
    • Into a container (container_name) it reads no place: the proxy works there as root in the container's working directory, as an agent installed there does.
    • On a VM's host it refuses a sandbox that runs on this machine, where the player's commands would run.
  • The shipped agents that install on a VM host declare where: Claude Code, Codex and OpenCode workdir_host="/app", Gemini "/workspace", each with user_host="root".
  • The Episode API:
    • _EpisodeAgentStep wraps the played agent's real deploy_agent or install_agent: it writes the bundle's proxy with write_installed, then swaps it in through step_params. For an env-only task, it serves ProxyAgent (shell=False) in this process instead and registers each env with it.
    • The played agent's prompt_agent steps run for real.
    • The episode claims the proxy (ProxyClient) and drains its turns. A message maps to the first prompt_agent step of its conversation not yet played.
    • Turn has prompt, system_prompt and skills. turn.tools() lists what the proxy offers and has it run each call; a retry after a transport error reuses the call's id, so it runs once. A bash result shows stdout, then stderr; a command that exits nonzero is an error, with its exit code on a last line.
    • turn.record_model_call(message, model=, usage=) records one model call: an OpenAI chat message, an Anthropic message or a string. A call the last recorded one didn't make, or end() while its calls haven't run, raises EpisodeError, leaving the turn open.
    • Turn no longer has role or mcp_urls: the proxy holds both.
    • Skill is exported beside Turn.

Rules

  • Lifted:
    • a conversation with a user simulator or counterpart on its prompt (max_conversation_turns > 1): each message prompt_agent sends it is a turn of the same step, and the caller continues its own conversation across them;
    • install_agent for the played agent;
    • load_artifact, collect_artifacts, run_code and verify_sandbox aimed at it;
    • add_skills, and skills on its deploy_agent;
    • rubrics of its trajectory.
  • Still refused:
    • retry_config;
    • a user simulator, a trigger executor or a peer played from outside;
    • prompt parts, output_format, agentenv_tools and snapshot_config on its prompt;
    • snapshots and changelogs;
    • register_agent_triggers;
    • agent validators;
    • judging inside the played agent, which a proxy can't do.

Notes for review

  • Episodes run with namespace routing on, as agent-env run does, so the @local proxy resolves. Under routing the object store shares no credentials with any agent. So the other agents of an episode (a judge, say) get none either, and use grants as they do in bundle runs.
  • The proxy is an agent of the agents bundle, though nobody picks it for a task. It has no smoke task: with nobody answering its turns, a prompt only waits out its deadline. A change to proxy/v1 needs a protocol release before the proxy can use it, as for every bundled agent.
  • agent_env.task imports the episode after the stores: the episode imports agent_env.bundle, which imports agent_env.task.
  • The played agent appears in deployed_agents. Served in this process, it has no sandbox, as a human agent has none.
  • A prompt's system_prompt carries to later prompts that set none, as for every agent: prompt_agent sends it only when set, and agent config persists. A turn shows what the played agent would run with. Kept deliberately, with no fallback to the deploy's (D10).
  • Deadlines:
    • A missed one fails with prompt_agent's own error ("A2A task … did not complete within Ns").
    • turn_timeout is rounded up to a whole second, since agent-config's timeout_seconds is an integer.
    • prompt_agent polls every 2 s, so a turn's reply can take up to 2 s to reach it.
  • A turn whose prompt_agent step has failed is closed at once, before the proxy drops it a long-poll later, so it's never handed out once the run's record says failed. abandon() closes the turns of the run it cancels.
  • Turn.end() raises only for a turn whose recorded calls haven't run. A reply that can't reach the proxy is logged, and the run's own failure reaches the caller through next_turn() or result().
  • The in-process proxy is one uvicorn server per episode, on loopback in a daemon thread. Its long-polls are 1 s, so one in flight ends within the server's shutdown.

Verification

  • Unit: 10,430 passed, 63 skipped, on macOS at the head, with main (and feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280's release) merged in; ruff clean. New and reworked tests cover:
    • turns, env tools and their registered headers (the role), a name clash, an unknown tool and a failing one, through the in-process proxy;
    • bash through a sandboxed proxy: output, a failed exit, a timeout, an unreachable reply;
    • record_model_call: calls held to the ones recorded, a refused shape, and the stored trajectory's spans;
    • the rules and the copy of the task; install_agent's proxy param; the credentials opt-out;
    • the proxy folder's card, host install and image, and writing it from the installed bundle;
    • plan_entities and write_installed.
  • End to end on a Linux EC2 box (Docker 29.9.2), at 88f94827, the head, with main merged in (and at ef0e14a9 before that merge, with the same results). feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280 wasn't released yet, so on the box only, the proxy folder installs the checkout's own protocol wheel (requirements.txt, the Dockerfile's COPY, and install.sh). The proxy's image is then built from its folder, as any unpublished bundled agent's is.
    • server_env_local_test.py: 5 passed, among them the two episode tests.
    • In process (30 s): the env's tools carry the role, record_model_call holds the turn to its calls, and the stored trajectory is chat, items_add_item, items_whoami, chat, turn. Played, unplayed, abandoned and timed-out episodes leave nothing behind.
    • Container (20 s with the image built, 8 s cached): the proxy is deployed in the agent's place with the items env registered.
      • bash runs as agent in /app with no LITELLM_ variable, and an env tool called from the container carries the role.
      • After a recorded model call, its bash and tool calls run; verify_sandbox and the env's verifier both score 1.0.
      • The trajectory holds every action and both model calls, and no container is left.
    • Unit on the box: 9,973 passed, 3 failed, all environmental:
      • the two MCP CLI codegen tests run generated scripts on the box's system Python, which has no httpx;
      • the protocol-pins test reads the box-only requirements.txt patch.
  • Modal VM, at the head, with the proxy installing protocol 0.1.370 from PyPI: proxy_modal_vm_test.py passed (88 s), including a verify_sandbox aimed at the host-installed agent by name.
  • Modal, at 88f94827 (the same box-only wheel patch, with this machine as the Modal client):
    • proxy_modal_vm_test.py passed (87 s). The proxy was installed on a modal_vm host in the place the replaced agent declares (/opt/agent-solver/workspace, as agent-solver). verify_sandbox on the VM scored 1.0, and the VM stopped afterwards.
    • The Modal episode test passed (42 s): the env on Modal, played through the in-process proxy over its tunnel as the role, with played, abandoned and timed-out episodes each leaving the sandbox stopped.
    • The module's other test, test_a_modal_load_is_timed_by_its_payload…, failed on main whenever Modal keys were present. It built its image into localhost:5000, which the Modal provider refuses, and CI has no Modal keys, so it never ran there. It now builds from a context, as the episode test does, and passes (e1aef46d); the module passed 2 of 2.
  • Played by a real harness, on hosted tasks. The outside agent was the Claude Code CLI on a laptop, with its built-in tools off and only the turn's tools, served over a local MCP bridge: each call it made was an action the proxy ran. Each task's other steps ran unchanged, on Modal sandboxes, under scratch task ids. The CLI was 2.1.310; the agent it stood in for pins 2.1.280.
    • An artifact-generation task: a freelancer's brief for a bilingual portfolio site with a product video and posters. The task installs its agent into a container on a VM (install_agent with container_name), loads the brief's inputs, prompts the agent, cleans the outputs with run_code and collects them. One run per arm, with Claude Sonnet 5.5 (1M context) at high effort: the real Claude Code agent in that container, or the proxy installed there in its place.
      • Both runs did every step, and both sites build with npm ci and npm run build. The proxy run delivered 39 files to the baseline's 58, for $1.72 of model spend to $7.93.
      • The gap is images: the proxy has no way to show the model one. The baseline opened 17 of its own screenshots and video frames with Claude Code's Read. The proxy run checked its layout by measurement, and said it couldn't look at the pages. It shipped two defects one look would have caught, a badge drawn over the poster's title and a bar over the name in the video's closing scene, and no social-share images.
      • Two graders scored both outputs blind against the task's own 61-criterion rubric for this brief, each set up as the task's rubric agent (Claude Code 2.1.280, Claude Opus 5 with a 1M window, medium effort, its system prompt and scoring rules), with the letters in a different order each time. Out of 240: baseline 151 and 149, proxy 149 and 147. They differ on two criteria only: the poster defect, a 7-point penalty to the proxy, and a paper-grain background, 5 points to the proxy.
      • Screenshots side by side, the video frames, the defects and the scores: Claude artifact.
      • Not in this PR: a way for a proxy run to see images. The plan is an opt-in file toolset, served as an MCP server in the agent's place and relayed like an env's, so proxy/v1 doesn't change.
    • A multi-turn task with scripted follow-ups and a judge: a cooperation task whose triggers send three follow-up messages, so the outside agent plays four turns of one conversation, and a rubric judge scores the trajectory.
      • Claude Opus 5.5, one run per arm: the judge scored 0.85 for the real agent and 0.75 through the proxy, a difference on three criteria that came from the model's own choices. Each of the 28 tool calls the model made ran in the proxy as made and came back unchanged, checked against the CLI's transcript, and no action took over 0.43 s in the proxy.
      • Claude Haiku 5.5, five runs per arm: the real agent finished none ("Autocompact is thrashing", with about 300 tools in a 200K window), and all five proxy runs finished. Claude Sonnet 5.5 (1M context), four and five runs: every run scored 0 both ways.
      • About 1 run in 4, in both arms, failed because the env's services didn't start under about ten concurrent runs. That's the env, not the proxy.
      • One proxy reply was lost when a tunnel dropped an idle connection. 4a674fe2 sends a call again when it doesn't reach the proxy, except a recorded model call.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 3/5

The retry changes introduce no new blocking issue, but the two earlier container-install problems still need fixes before merging.

Fix All in CursorFindings

  1. P1 Shell commands need missing bash ▶
  2. P1 Checks use the wrong container ▶
Fix with agent prompt
### Issue 1
src/agent_env/agents/agents/proxy/install.sh:60-64
If the task container has `sh` but no `bash`, as Alpine can, this installer starts the proxy without adding the shell its actions require. Every command then fails with exit code 126 because the proxy runs `bash -c`. Check for `bash` and install it before starting the proxy, or reject the target during installation.

### Issue 2
src/agent_env/task_step/task_steps/install_agent.py:253-254
Container installs are now allowed for outside agents, but `DeployedAgent` does not save the target container. Later `run_code`, `verify_sandbox`, and `collect_artifacts` steps aimed at that agent still use the sandbox's default agent container instead of the task container where the proxy runs. These steps therefore fail or read another container's files.

Save the installed container name and use it in these steps, or keep this combination refused until that routing works.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR lets outside players act through a proxy deployed in an agent’s place. The latest changes extend connection retries without sending a recorded model call twice.

  • Episode turns run through a proxy that records the caller’s model actions.
  • Sandbox tasks run the proxy where the played agent works.
  • Proxy actions match recorded calls and leave readable results in trajectories.
  • Agents can opt out of model and shared store credentials.
  • A bundle can write one installed entity without running the task.

Diagram

sequenceDiagram
    participant Caller
    participant Episode
    participant Proxy
    participant Task
    Task->>Proxy: Prompt agent
    Episode->>Proxy: Poll open turns
    Episode-->>Caller: Turn
    Caller->>Proxy: Record model call through Turn
    Caller->>Proxy: Run tools through Turn
    Caller->>Proxy: End Turn
    Proxy-->>Task: Reply and trajectory
Loading

Reviews (16) · Last reviewed commit: "fix(task): the episode sends a proxy cal..." · Reviewed by Greptile

earakely-scale and others added 5 commits October 20, 2026 14:14
An episode's outside agent is now played through a model-less stand-in agent
that the task's own deploy_agent or install_agent deploys in its place, so the
steps that load into, run in, collect from or check the agent's sandbox run
unchanged, and the loop's bash runs there as the agent's user in its folder.
A task that uses no agent sandbox gets the same stand-in served in-process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilure to the run

Turn.end() never raised before the stand-in; a reply the stand-in can't take is
logged, and the run's own failure reaches the caller through next_turn() and
result(). Skill, the type of Turn.skills, is exported beside Turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s agent extra

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stand-in can take its place

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/task/episode/steps.py Outdated
Comment thread src/agent_env/task/episode/run.py Outdated
Comment thread src/agent_env/task/episode/run.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/stand_in.py Outdated
earakely-scale and others added 3 commits October 20, 2026 15:13
…, close turns their step gave up on

- An agent whose metadata sets takes_credentials False is given no model key, endpoint or
  headers, from any source, and none of the object store's shared credentials. The stand-in
  sets it and carries no placeholder keys.
- A tool call that raises is recorded in the turn's trajectory with its error.
- A message on a conversation maps to the first of its prompt steps not yet played.
- A command that times out or is cancelled has its whole process group killed, so a
  background child holding its output can't keep it waiting.
- A turn whose prompt_agent step has failed is closed at once, rather than a long-poll later
  when the stand-in drops it, so it is never handed out once the run's record says failed;
  abandon() closes the turns of the run it cancels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A killed command's background child outlives its parent as a zombie until whatever adopted it reaps
it, and signal 0 still reaches a zombie, so checking for its absence right after the timeout raced on
Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ays through driver/v1

urn:agentenv:driver/v1 hands an agent's turns to a driver outside its sandbox: claim sets the
token, next long-polls for the open turns, reply ends one with its text or error and trajectory,
and exec and output run a command where the agent works. StandInAgent serves it: each message is
an open turn until the driver replies, a cancelled one drops out, skills go to ~/skills/<name>/,
and commands run as bash -c in its folder, as its user, with a 3600 s timeout that kills their
process group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
earakely-scale and others added 2 commits October 21, 2026 09:22
…ten by the episode as a run would

The stand-in moves from a2a_agent/stand_in/, which staged the installed protocol package into its
own build context, to agents/stand-in/, an agent folder like claude-code's and pi's: a Dockerfile,
an a2a_server.py declaring its install/v1, an install.sh for a VM's host, the protocol from PyPI in
requirements.txt, which a release pins, and an agent.toml setting takes_credentials = false, which
[metadata] now takes. Its id is @local/agentenv-framework/agents/stand-in.

An episode writes it with write_installed, which plans one installed bundle's entity with what it
references (plan_entities, the checks plan_bundle makes, with nothing run) and materializes it, so
the ledger reuses it until its folder changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/stand_in.py Outdated
earakely-scale and others added 2 commits October 21, 2026 13:43
…t nothing uses

StandInAgent is ProxyAgent and driver/v1 is proxy/v1, with routes under /ext/proxy and its models
named Proxy*. Gone: the shell=False mode, whose refusals guarded calls no caller makes; mcp-config,
whose registrations nothing read, with role taken through agent-config instead so a deploy naming
one still hands it over; and the module's own serve entry point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… commit left out

The previous commit moved stand_in.py and its tests to their new names but not their new content,
nor the extension's, the exports' or the README's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale

Copy link
Copy Markdown
Collaborator Author

🤖 Automated Claude routine — Env Pod PR Context Review. This is a bot, not Edgar Arakelyan; not a human review.

Offering context from outside this diff: the protocol half this PR installs from PyPI has moved under it, in two separate ways.

src/agent_env/agents/agents/stand-in/a2a_server.py:5 imports StandInAgent / StandInConfig from agentenv_protocol.a2a_agent.stand_in, and the sibling requirements.txt floors the wheel at agentenv-framework-protocol[agent]>=0.1.356, commented as "the first that ships StandInAgent and driver/v1".

  1. That floor is already satisfied by a published wheel that has neither name. packages/agentenv-protocol/pyproject.toml on main is at 0.1.358, and main's a2a_agent/ directory contains no stand_in.py (nor proxy.py) — the protocol half exists only on this branch and on feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280. So pip install in the agent's Dockerfile resolves >=0.1.356 happily today, and the import fails at boot rather than at install.

  2. feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280 renames what the released wheel will export. Its head (c9cbae7) has the module at a2a_agent/proxy.py with ProxyAgent / ProxyConfig / PROXY_V1, extension uri urn:agentenv:proxy/v1 and endpoint /ext/proxy — against DRIVER_V1 / urn:agentenv:driver/v1 / /ext/driver in this branch's extensions.py:1235-1239. Since feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 #280 is the PR that publishes the protocol (it carries bump-version), the first release that actually ships this agent will export the Proxy* names, and a driver written against /ext/driver won't reach /ext/proxy.

Worth noting that the agent folder won't surface this on rebase: #280 touches neither stand-in/a2a_server.py nor its requirements.txt, so __init__.py and extensions.py will conflict while the import line and the version floor land quietly.


Generated by Claude Code

earakely-scale and others added 4 commits October 23, 2026 09:37
…xy, which records the turn

proxy/v1 gains tools, which lists what a turn can call (the env servers registered through
mcp-config, whose tools the proxy now calls with ToolSession, and bash), and act and result,
which run one action, a tool call or a bash command, by a driver-picked id, answer at once when
it ends within the long-poll, and return a structured result. record takes each model call, as an
OpenAI chat message, an Anthropic message or a string, and once a turn records one, every action
must be one the last recorded call made and all must run before the next record or the reply.
The proxy writes the turn's trajectory itself, live: a chat span per recorded call, an
execute_tool span per action and a chain span for the turn, so reply carries only parts or an
error. Content travels as parts, and a message with a part that isn't text fails before the
driver sees it. A turn's actions stop when it closes, and the app's lifespan stops what still
runs. Tokens are compared as bytes, a skill's folder is removed when writing it fails and a
skill name is taken once, and shell=False offers no bash and keeps no skills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/agent_env/agents/README.md
#	src/agent_env/bundle/installed.py
#	tst/unit/bundle/installed_entities_test.py
The episode deploys the agents bundle's proxy (agents/proxy, published as
agentenv-proxy) where the played agent would run, or serves one in process for
an agent that works in no sandbox, and claims it through proxy/v1. Every action
the driver's model takes, env tool calls through the servers registered with
mcp-config and bash, runs in the proxy, which records the turn: a chat span per
recorded model call, an execute_tool span per action and a chain span per turn.

Turn gains record_model_call (OpenAI chat messages, Anthropic messages or a
string); a call the last recorded one didn't make, or an end while its calls
haven't run, raises EpisodeError. Codex declares its place on a VM host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/task/episode/run.py Outdated
earakely-scale and others added 3 commits October 23, 2026 10:24
…hold one connection per server

- A tool action's timeout_seconds bounds connecting to its env server and the
  call together; the proxy's sessions set no deadline of their own, which cut a
  longer request off at 600 s. Listing a turn's tools is bounded the same way.
- A turn opens one connection per env server, under a lock, shared by its
  actions; none is opened or kept once the turn has closed.
- record checks and sets the turn's pending calls with no wait in between, so of
  two model calls recorded at once the second is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d is an error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale earakely-scale changed the title feat(task): play an outside agent through a stand-in agent in its place feat(task): play an outside agent through a ProxyAgent in its place Oct 23, 2026
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
earakely-scale and others added 3 commits October 23, 2026 13:42
… can build

Its image went to localhost:5000, which the Modal provider refuses, so the test failed before it ran whenever Modal
keys were present.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	.github/scripts/infra_build.py
#	src/agent_env/agents/README.md
#	tst/unit/test_infra_images_script.py
…e declare where they work on a VM host

#280 shipped in protocol 0.1.370; 0.1.367 to 0.1.369 came out before it and have no proxy/v1. Gemini and OpenCode,
new on main, install on a VM host as root, in /workspace and /app, so a proxy installed in their place works there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread src/agent_env/task/episode/proxy.py
Comment thread src/agent_env/task/episode/rules.py Outdated
earakely-scale and others added 3 commits October 23, 2026 15:38
…installed on a VM's host

Aimed at an agent by name on a VM, both looked for the agent's container, which an agent install_agent put on the
host doesn't have, so they failed. They now work on the host when the agent is on_host, as run_code does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 3.10, asyncio.wait_for raises asyncio.TimeoutError, which isn't the built-in TimeoutError until 3.11, so a tool
call or listing that timed out escaped as a server error. A proxy installed on a VM host runs on the host's own
Python, which can be 3.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ults, renders, and matches calls by what runs

- An action polled while its turn closes is answered 404, not a bare 500 from the CancelledError; a command that
  can't start (an argument list too long) is a failed bash result, exit code 126, with its span, not a 500 on every
  poll.
- ProxyBashResult.exit_code defaults to None: a stopped command's result leaves it out, which the published model
  refused.
- The proxy's spans carry an OTel status, ERROR for a failed action or turn, and the explorer reads a span with
  none; it threw on every proxy trajectory with an action.
- A recorded bash call is matched by its command alone, since an action carries nothing else, and a whole float
  equals the int a driver writes for it; either mismatch left the turn stuck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
earakely-scale and others added 3 commits October 23, 2026 23:08
An env tool that is itself named bash, which a proxy with no shell can offer, is held to all its recorded arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er simulator or counterpart

Each message prompt_agent sends it is a turn of the same step, so an episode plays a task whose played agent
converses over several turns, as Cooperation Bench tasks do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d be installed in

An install_agent with a container_name now puts the proxy there, as root in the container's working directory, as
an agent installed in a container works: install.sh's container mode brings uv, checked against its checksum, and a
private Python, so the container's own are untouched. Artifact-generation tasks install their creators this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +60 to +64
on "cd $root && export UV_PYTHON_PREFERENCE=only-managed UV_PYTHON_INSTALL_DIR=$root/python UV_CACHE_DIR=$root/cache \
&& bin/uv venv --quiet --python $PYTHON_VERSION venv && bin/uv pip install --quiet --python venv/bin/python \
-r requirements.txt && rm -rf cache"
docker exec -d -u 0 -e A2A_PORT="$port" "$target" \
sh -c "echo \$\$ > $root/proxy.pid; exec $root/venv/bin/python $root/a2a_server.py > $root/proxy.log 2>&1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shell commands need missing bash

If the task container has sh but no bash, as Alpine can, this installer starts the proxy without adding the shell its actions require. Every command then fails with exit code 126 because the proxy runs bash -c. Check for bash and install it before starting the proxy, or reject the target during installation.

Knowledge Base Used: Packaged A2A agent implementations

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/agents/agents/proxy/install.sh
Line: 60-64

Comment:
**Shell commands need missing `bash`**

If the task container has `sh` but no `bash`, as Alpine can, this installer starts the proxy without adding the shell its actions require. Every command then fails with exit code 126 because the proxy runs `bash -c`. Check for `bash` and install it before starting the proxy, or reject the target during installation.

**Knowledge Base Used:** [Packaged A2A agent implementations](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/packaged-a2a-agent-implementations.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +253 to +254
if self.container_name is None:
place = await self._place_of(sandbox, agent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Checks use the wrong container

Container installs are now allowed for outside agents, but DeployedAgent does not save the target container. Later run_code, verify_sandbox, and collect_artifacts steps aimed at that agent still use the sandbox's default agent container instead of the task container where the proxy runs. These steps therefore fail or read another container's files.

Save the installed container name and use it in these steps, or keep this combination refused until that routing works.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/task_step/task_steps/install_agent.py
Line: 253-254

Comment:
**Checks use the wrong container**

Container installs are now allowed for outside agents, but `DeployedAgent` does not save the target container. Later `run_code`, `verify_sandbox`, and `collect_artifacts` steps aimed at that agent still use the sandbox's default agent container instead of the task container where the proxy runs. These steps therefore fail or read another container's files.

Save the installed container name and use it in these steps, or keep this combination refused until that routing works.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

… the proxy

A reply posted over a connection a tunnel had dropped failed with 'Server disconnected', so its turn waited out its
deadline. Every call but record, which isn't idempotent, is now tried again on a transport error: a reply that
reached the proxy but whose answer was lost finds its turn ended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant