Skip to content

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

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

earakely-scale wants to merge 36 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.
  • No bash. In a place with no bash on the PATH, an Alpine or BusyBox image say, every command failed with exit code 126. The proxy now runs sh -c there, and the bash tool's description says the commands run with POSIX sh. The tool's name, the action kind, spans and call matching are unchanged, and nothing changes where bash exists. Other harnesses (verifiers, Inspect AI, mini-SWE-agent, SWE-ReX, Harbor) run the model's commands with bash and fail without it; the alternatives were reviewed in a design doc.
  • Background commands. A command that left a process in the background holding its output (npm run dev &) kept its action waiting until the 3,600 s deadline, which then killed the process too. A command is now done when its shell exits: what it started keeps running, output arriving within a second is returned, and later output is read and dropped, so a server never blocks on a full pipe. A command whose shell outlives its deadline is still stopped with everything it started.
  • 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.
  • An agent installed into a container: DeployedAgent.container_name records the container install_agent put it in. Every step that reaches an agent by name uses it: run_code (where the script runs and where its files are written), verify_sandbox, collect_artifacts, load_artifact (files, universes, URLs, environment payloads, CLIs) and rubrics_verifier's trajectory staging for a judge named by agent. Before, they went to the sandbox's default agent container, agent-api, whatever the install's container was. VmSandbox's write methods (write_file_from_text, _from_object, _from_url) take an optional container, which these paths pass only for an installed agent, so a deployed agent's calls are unchanged.
  • 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().
  • No regression from the container routing. Every task in our hosted deployment that aims one of those steps at an agent installed into a container (1,611 steps across 611 tasks) installs it into agent-api, the default, so each reaches the container it reached before, with the same commands. None in our dev deployment does.
  • 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.
  • The shell and the container routing (8de2c27c, 20b694fe):
    • Protocol: the bash tool's listing is pinned to today's text, a command shows it ran under bash, and with no bash on the PATH the tool says POSIX sh and a command runs under sh. 36 passed on Python 3.14 and 3.10.
    • tst/integration/agent/proxy_container_install_test.py, new, against local Docker: install.sh container installs this checkout's protocol into an alpine:3.20 container (no bash, musl) and a debian:12-slim one. Each serves, lists bash, and runs echo $0 as sh and bash respectively, with the description to match. 2 passed in 10 s.
    • Routing (20b694fe, 07627abd): each write and each step reaches an agent's install container. For an agent installed into agent-api, the recorded host commands of every write, file load, URL load and CLI install equal a deployed agent's; install_agent records the container. Existing tests whose fakes take the old signatures pass unchanged.
    • Background commands (dc96da41): a command that leaves sleep 60 & returns at once with the process still running; one whose background process writes 200 KB after it's done never blocks it; one that outlives its deadline is still stopped with what it started. 38 passed on Python 3.14 and 3.10; the container install test passes again on Alpine and Debian.
    • The full unit suite, with main merged in: 10,474 passed, 63 skipped.
    • Our dev deployment, Modal VMs: a task that installs the played agent (through the proxy, no model) into a container and aims load_artifact, run_code, verify_sandbox and collect_artifacts at it by name. Installed into agent-api, the branch before these commits (4a674fe2) and after both pass all eight steps with identical results: the script reads hello, the check scores 1.0, the file is collected. Installed into a container of its own, the four steps fail before ("No such container: agent-api", "No agent container found") and pass after.
  • A real prod task shape, on our dev deployment, main against this branch (Modal VMs, the dev stores): a copy of a Software Tutorials task that installs codex-cli into the creative image's agent-api and aims load_artifact, collect_artifacts twice, verify_sandbox and rubrics_verifier (judging in that agent, with its trajectory) at it by name, plus a run_code aimed at it as other prod tasks have. Its prompt and checks were swapped for a short task, and the image left out three app downloads that failed that day (Blender, Godot, the GIMP AppImage). Both runs passed all 11 steps with identical results: the same two files collected, the script read hello, and the file check, the judge and the aggregate each scored 1.0. An earlier pair failed identically at the judge's own model call (provider "high demand"); this pair ran the judge on gpt-5.5.
  • 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

Fix host-targeted artifact loads and the outstanding command-cancellation cleanup before merging.

Fix All in CursorFindings

  1. P1 Host inputs reach another container ▶
  2. P1 Cancelled commands keep collecting logs ▶
Fix with agent prompt
### Issue 1
src/agent_env/task_step/task_steps/load_artifact.py:520-521
`load_artifact` now accepts a played agent installed on a VM host, but its agent-targeted loads still use the container path. A host install has `on_host=True` and `container_name=None`, so this call falls back to the VM's default agent container. If that container is absent, the load fails. If it exists, the files land there instead of where the player works.

Honor `on_host` for file, URL, environment-payload and CLI loads, as `run_code`, `verify_sandbox` and `collect_artifacts` do.

### Issue 2
packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py:undefined-559
If a turn times out during this output grace wait, cancellation skips cleanup because the wait is outside the `try`. For example, `npm run dev &` can leave a server writing output after its shell exits. The readers are neither cancelled nor added to `_draining`, and `_Output.taken()` never runs. They keep growing abandoned buffers instead of dropping later output, potentially exhausting the proxy's memory.

Keep the grace wait inside the cancellation cleanup and ensure every reader is either stopped or handed over.

---

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

Summary

The PR lets outside callers play agents through ProxyAgent, while normal task steps deploy, prompt and score them.

  • Outside models can play task agents through proxy turns.
  • The proxy can take an agent’s place on its host or in its container.
  • Agent-targeted task steps reach the place the agent was installed.
  • Agent metadata can keep model and store credentials away.
  • Proxy actions work across shells and show up in task trajectories.

Diagram

sequenceDiagram
    participant Task
    participant Proxy as ProxyAgent
    participant Caller
    Task->>Proxy: prompt_agent message
    Caller->>Proxy: next turn
    Proxy-->>Caller: prompt, skills and tools
    Caller->>Proxy: record model call
    Caller->>Proxy: run tool or shell command
    Proxy-->>Caller: action result
    Caller->>Proxy: end turn
    Proxy-->>Task: reply and trajectory
Loading

Reviews (20) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." · 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 thread src/agent_env/agents/agents/proxy/install.sh
Comment thread src/agent_env/task_step/task_steps/install_agent.py
earakely-scale and others added 5 commits October 24, 2026 11:05
… 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>
The proxy's bash tool ran every command with `bash -c`, so in a task container without bash (an Alpine or
BusyBox image) every command failed with exit code 126. Where there is no bash on the PATH, it now runs
`sh -c` and the tool's description says so; the tool's name, the action kind, spans and call matching are
unchanged, and so is everything where bash exists.

An integration test installs the proxy into an Alpine and a Debian container with install.sh, from this
checkout's protocol, and checks which shell its bash runs.

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

install_agent didn't record the container it put an agent in, so run_code, verify_sandbox and
collect_artifacts aimed at that agent by name went to the sandbox's default agent container. DeployedAgent
now carries `container_name`, which install_agent sets for a container install, and the three steps use it
before their existing lookup. An agent installed into the default container, `agent-api`, reaches the same
container as before.

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

run_code wrote its script and input into the sandbox's default agent container while running them in the
agent's install container, and load_artifact (files, universes, URLs, environment payloads, CLIs) and
rubrics_verifier's trajectory staging for a judge named by agent wrote there too. VmSandbox's write methods
take an optional container, which those paths pass only for an agent install_agent put in a container, so a
deployed agent's calls are unchanged and an agent installed into the default container, agent-api, runs the
same commands as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A command that left a process in the background holding its output, `npm run dev &` say, kept its action
waiting until the 3600 s deadline, which then killed the process too. The proxy now takes a command as done
when its shell exits: what it started keeps running, output that arrives within a second is returned, and
later output is read and dropped so a server never blocks on a full pipe. A command whose shell outlives its
deadline is still stopped with everything it started.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
raise
return ProxyBashResult(exit_code=process.returncode, stdout=stdout.decode(errors="replace"),
stderr=stderr.decode(errors="replace"), timed_out=False)
await asyncio.wait(readers, timeout=OUTPUT_GRACE_SECONDS)

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 Cancelled commands keep collecting logs

If a turn times out during this output grace wait, cancellation skips cleanup because the wait is outside the try. For example, npm run dev & can leave a server writing output after its shell exits. The readers are neither cancelled nor added to _draining, and _Output.taken() never runs. They keep growing abandoned buffers instead of dropping later output, potentially exhausting the proxy's memory.

Keep the grace wait inside the cancellation cleanup and ensure every reader is either stopped or handed over.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py
Line: 559

Comment:
**Cancelled commands keep collecting logs**

If a turn times out during this output grace wait, cancellation skips cleanup because the wait is outside the `try`. For example, `npm run dev &` can leave a server writing output after its shell exits. The readers are neither cancelled nor added to `_draining`, and `_Output.taken()` never runs. They keep growing abandoned buffers instead of dropping later output, potentially exhausting the proxy's memory.

Keep the grace wait inside the cancellation cleanup and ensure every reader is either stopped or handed over.

---

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 +520 to +521
files = await A2AAgent.load_file_artifact_universe(
deployed_agent, artifact, destination, **into_container(agent.container_name))

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 Host inputs reach another container

load_artifact now accepts a played agent installed on a VM host, but its agent-targeted loads still use the container path. A host install has on_host=True and container_name=None, so this call falls back to the VM's default agent container. If that container is absent, the load fails. If it exists, the files land there instead of where the player works.

Honor on_host for file, URL, environment-payload and CLI loads, as run_code, verify_sandbox and collect_artifacts do.

Knowledge Base Used: Provider integrations and sandbox deployment

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

Comment:
**Host inputs reach another container**

`load_artifact` now accepts a played agent installed on a VM host, but its agent-targeted loads still use the container path. A host install has `on_host=True` and `container_name=None`, so this call falls back to the VM's default agent container. If that container is absent, the load fails. If it exists, the files land there instead of where the player works.

Honor `on_host` for file, URL, environment-payload and CLI loads, as `run_code`, `verify_sandbox` and `collect_artifacts` do.

**Knowledge Base Used:** [Provider integrations and sandbox deployment](https://app.greptile.com/scale-ai/-/custom-context/knowledge-base/scaleapi/agentenv-framework/-/docs/provider-integrations.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

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