Repository navigation
feat(task): play an outside agent through a ProxyAgent in its place - #270
earakely-scale wants to merge 31 commits into
Conversation
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>
…, 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>
…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>
…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>
|
🤖 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.
Worth noting that the agent folder won't surface this on rebase: #280 touches neither Generated by Claude Code |
…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>
…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>
…wn through Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 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>
…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>
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>
| 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" |
There was a problem hiding this 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
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.| if self.container_name is None: | ||
| place = await self._place_of(sandbox, agent) |
There was a problem hiding this 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.
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.… 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>
Summary
An episode's played agent is now played through a proxy: an A2A agent with no model, which the task's own
deploy_agentorinstall_agentdeploys in the played agent's place. Every action the caller's model takes runs in the proxy, which records the turn:bash, in the agent's sandbox, as its user, in its folder.Before this PR,
EpisodeSpec.from_taskswapped the played agent's steps for steps that deployed nothing. So it refused every task that uses that agent's sandbox:install_agent, andload_artifact,collect_artifacts,run_code,verify_sandboxandadd_skillsaimed 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().Turngainsrecord_model_call().Builds on #280, merged and released as protocol 0.1.370, which added
ProxyAgentandproxy/v1. The proxy's folder installs the protocol from PyPI at that floor, as every bundled agent does.How a turn works
Where the proxy runs
deploy_agentregisters themdeploy_agent)bashin/appasagentinstall_agent)bash, in that folder as that userinstall_agentwithcontainer_name)bash, as root in the container's working directoryrules.works_in_sandboxdecides which:install_agent,load_artifact,run_codeoradd_skillsaimed at the played agent,verify_sandboxorcollect_artifactsaimed at its sandbox, or skills on itsdeploy_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:
TimeoutError, which isn't whatasyncio.wait_forraises on 3.10, so on a 3.10 VM host a timeout escaped as a server error. They now catchasyncio.TimeoutError, and the protocol suite passes on 3.10.CancelledError. A command that can't start (an argument list too long) is a failedbashresult with exit code 126 and its span, not a 500 on every poll.exit_code.ProxyBashResult.exit_codedefaults toNone: a stopped command's result leaves it out, which the published model refused to parse.ERRORfor a failed action or turn. The explorer, which threw on every proxy trajectory with an action because it readstatus.status_codeunguarded, now reads a span with none; a smoke test covers a proxy trajectory.bashis 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 namedbashis 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 likeclaude-codeandpi, named@local/agentenv-framework/agents/proxy. The release publishes its image asagentenv-proxy.a2a_server.pyserves the protocol'sProxyAgent, passes on its lifespan, and declaresinstall/v1for a VM's host and for a container.install.sh hostinstalls it on a VM's host, with the host's own Python in a venv.install.sh containerinstalls it into a running container with a checksum-verified uv and a private Python 3.12, so the container's own are never touched./appas useragent, with the tools that the steps acting on an agent's sandbox use.requirements.txtinstalls 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.tomlsetstakes_credentials = false.IMAGESand_SMOKESlistagentenv-proxy, and Dependabot watches the folder.write_installed(id)writes one entity of an installed bundle, with what it references, as a bundle run naming it would. It builds onplan_entities, which makesplan_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 setstakes_credentialsto 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 nothingbashruns can read a credential. Anagent.toml's[metadata]now takestakes_credentials, a boolean.verify_sandboxandcollect_artifacts: aimed at an agent by name on a VM, they work on the host wheninstall_agentput the agent there (on_host), asrun_codealready did. Before, they looked for a container such an agent doesn't have, for every agent, not only the proxy.install_agent: a per-stepproxyparam.install/v1params,workdir_hostanduser_host, and installs the proxy there as{workdir}and{user}.container_name) it reads no place: the proxy works there as root in the container's working directory, as an agent installed there does.workdir_host="/app", Gemini"/workspace", each withuser_host="root"._EpisodeAgentStepwraps the played agent's realdeploy_agentorinstall_agent: it writes the bundle's proxy withwrite_installed, then swaps it in throughstep_params. For an env-only task, it servesProxyAgent(shell=False) in this process instead and registers each env with it.prompt_agentsteps run for real.ProxyClient) and drains its turns. A message maps to the firstprompt_agentstep of its conversation not yet played.Turnhasprompt,system_promptandskills.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. Abashresult 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, orend()while its calls haven't run, raisesEpisodeError, leaving the turn open.Turnno longer hasroleormcp_urls: the proxy holds both.Skillis exported besideTurn.Rules
max_conversation_turns> 1): each messageprompt_agentsends it is a turn of the same step, and the caller continues its own conversation across them;install_agentfor the played agent;load_artifact,collect_artifacts,run_codeandverify_sandboxaimed at it;add_skills, and skills on itsdeploy_agent;retry_config;output_format,agentenv_toolsandsnapshot_configon its prompt;register_agent_triggers;Notes for review
agent-env rundoes, so the@localproxy 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.agentsbundle, 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 toproxy/v1needs a protocol release before the proxy can use it, as for every bundled agent.agent_env.taskimports the episode after the stores: the episode importsagent_env.bundle, which importsagent_env.task.deployed_agents. Served in this process, it has no sandbox, as a human agent has none.system_promptcarries to later prompts that set none, as for every agent:prompt_agentsends 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).prompt_agent's own error ("A2A task … did not complete within Ns").turn_timeoutis rounded up to a whole second, since agent-config'stimeout_secondsis an integer.prompt_agentpolls every 2 s, so a turn's reply can take up to 2 s to reach it.prompt_agentstep 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 throughnext_turn()orresult().Verification
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:bashthrough 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;install_agent'sproxyparam; the credentials opt-out;plan_entitiesandwrite_installed.88f94827, the head, withmainmerged in (and atef0e14a9before 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'sCOPY, andinstall.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.record_model_callholds the turn to its calls, and the stored trajectory ischat,items_add_item,items_whoami,chat,turn. Played, unplayed, abandoned and timed-out episodes leave nothing behind.bashruns asagentin/appwith noLITELLM_variable, and an env tool called from the container carries the role.bashand tool calls run;verify_sandboxand the env's verifier both score 1.0.httpx;requirements.txtpatch.proxy_modal_vm_test.pypassed (88 s), including averify_sandboxaimed at the host-installed agent by name.88f94827(the same box-only wheel patch, with this machine as the Modal client):proxy_modal_vm_test.pypassed (87 s). The proxy was installed on amodal_vmhost in the place the replaced agent declares (/opt/agent-solver/workspace, asagent-solver).verify_sandboxon the VM scored 1.0, and the VM stopped afterwards.test_a_modal_load_is_timed_by_its_payload…, failed onmainwhenever Modal keys were present. It built its image intolocalhost: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.install_agentwithcontainer_name), loads the brief's inputs, prompts the agent, cleans the outputs withrun_codeand 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.npm ciandnpm run build. The proxy run delivered 39 files to the baseline's 58, for $1.72 of model spend to $7.93.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.proxy/v1doesn't change.4a674fe2sends a call again when it doesn't reach the proxy, except a recorded model call.🤖 Generated with Claude Code
The retry changes introduce no new blocking issue, but the two earlier container-install problems still need fixes before merging.
Fix with agent prompt
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.
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 trajectoryReviews (16) · Last reviewed commit: "fix(task): the episode sends a proxy cal..." · Reviewed by Greptile