Repository navigation
feat(task): play an outside agent through a stand-in agent in its place - #270
Open
earakely-scale wants to merge 7 commits into
Open
earakely-scale wants to merge 7 commits into
earakely-scale wants to merge 7 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>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An episode's played agent is now played through a stand-in: an A2A agent with no model, which the task's own
deploy_agentorinstall_agentdeploys in the played agent's place.Before this PR,
EpisodeSpec.from_taskswapped the played agent's steps for stand-in 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 stand-in deployed there:
bashtool, which runs in the sandbox, as the agent's user, in the agent's folder;The caller's API keeps its shape:
from_task→start()→next_turn()→Turn→end()→result().How a turn works
Where the stand-in runs
bashruns…deploy_agent)/app, as useragentinstall_agent)rules.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
a2a_agent/stand_in.py:StandInAgent.tasks/cancelwithdraws the turn.~/skills/<name>/.urn:agentenv:driver/v1, inextensions.py:claim: the first caller sets a token, which every later call carries in its body.next: a long-poll for the open turns.reply: a reply's text or error, its trajectory and its tool-call count.execandoutput: start a command, then long-poll for its result.bash -cin the stand-in's folder, as its user, with a 3600 s timeout. They return stdout, then stderr, uncapped, as verifiers'bashtool does. On a timeout or cancellation, the command's whole process group is killed, so a background child holding its output can't keep it waiting.Core
a2a_agent/stand_in/: the stand-in's Dockerfile and server.@local/agentenv-stand-in, an image known by its build context alone. The context holds the installedagentenv_protocolpackage and itsagentrequirements, pinned to the versions installed here.install/v1installs it on a VM's host, with the host's own Python in a venv.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 stand-in sets it, so nothingbashruns can read a credential. On a VM host, its install commands name none of thelitellm_*params.install_agent: a per-stepstand_inparam.install/v1params,workdir_hostanduser_host. It then installs the stand-in there as{workdir}and{user}.workdir_host="/app"anduser_host="root"._EpisodeAgentStepnow wraps the played agent's realdeploy_agentorinstall_agent, swapping the stand-in in throughstep_params. For an env-only task, it serves the stand-in in this process instead.prompt_agentsteps run for real._EpisodeTurnStepandEpisode._playare gone.prompt_agentstep of its conversation not yet played.Turngainsskills, andturn.tools()gainsbash.Skillis exported besideTurn.Rules
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@localstand-in 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:env/bootstrap.pybuilds, not an agent a user picks.driver/v1, which the installed protocol package guarantees and a PyPI floor wouldn't.deployed_agents. Served in this process, it has no sandbox, as a human agent has none.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 stand-in 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()still never raises. A reply that can't reach the stand-in is logged, and the run's own failure reaches the caller throughnext_turn()orresult().Verification
bashand the trajectory through the in-process stand-in;install_agent'sstand_inparam;bashruns asagentin/app, with noLITELLM_variable in its environment.verify_sandboxscores what it left.modal_vmtest installs an agent on the VM's host and plays it through the stand-in installed in its place. The stand-in works as the declared user in the declared folder,verify_sandboxon the VM scores 1.0, and the VM is stopped afterwards. It ran after the rebase too.mainat42b65fa, with the open-file limit raised, since the box's 1,024 failsmain's unit suite too):mainint-test-fastint-test-slowThe 2 unit failures are environmental: the MCP CLI codegen tests run generated scripts on the box's system Python, which has no
httpx.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge.Summary
Episodes now play outside agents through stand-ins, keeping sandbox tools, skills, and recorded trajectories available.
Turn.Diagram
sequenceDiagram participant Task participant StandIn as Stand-in participant Episode participant Caller Task->>StandIn: Send prompt Episode->>StandIn: Read open turns Episode-->>Caller: Return Turn Caller->>StandIn: Run tools through Turn Caller->>Episode: End Turn Episode->>StandIn: Send reply and trajectory StandIn-->>Task: Complete promptReviews (3) · Last reviewed commit: "test(stand-in): count a killed child as ..." · Reviewed by Greptile