Skip to content

feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 - #280

Merged
earakely-scale merged 6 commits into
mainfrom
feat/stand-in-protocol
Oct 23, 2026
Merged

earakely-scale merged 6 commits into
mainfrom
feat/stand-in-protocol

Conversation

@earakely-scale

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

Copy link
Copy Markdown
Collaborator

Summary

This adds urn:agentenv:proxy/v1 and ProxyAgent, an A2A agent with no model. Another program, the driver, plays it. The proxy is deployed where the played agent would run, and every action the driver's model takes runs in it: a call to an env's tools, or a bash command. The proxy records the turn as the agent's trajectory.

This PR ships the protocol half. #270 uses it: it adds the proxy to the agents bundle as an agent folder, like claude-code and pi, and plays it from the Episode API. A bundled agent installs the protocol from PyPI, so #270's proxy needs this release (0.1.367).

proxy/v1

Every operation is a POST under /ext/proxy/ whose body carries the token.

Operation Request What it does
claim {token} The first caller sets the token (22 characters or more). Another token gets 409; a wrong one on any later call gets 401.
next {token, after} Waits up to 25 s for the turns' version to pass after, then returns {version, turns}. Each turn has its task id, context id, parts (text only for now), its system_prompt and its skills.
tools {token, task_id} What the turn can call: bash when the proxy has a shell, then each registered env server's tools, with server. Two servers offering one name get 409.
act {token, task_id, action_id, action} Runs a tool or bash action in the turn. It answers {done, result} if the action finishes within the long-poll, else {done: false}. A retry with the same action_id returns the action already started.
result {token, action_id} Waits up to 25 s for an action's result.
record {token, task_id, message, model?, usage?} Records one model call: an OpenAI chat message, an Anthropic message or a string; any other shape gets 400. From then on, each action must be one that call made (name and arguments), else 409. Calls to tools the turn doesn't offer aren't checked.
reply {token, task_id, parts or error} Ends the turn. The parts are the task's result, and an error fails it with proxy.driver_error. While a recorded call hasn't run, a reply with parts gets 409.

A bash result is {exit_code, stdout, stderr, timed_out}; a tool result is {content, is_error}.

ProxyAgent

  • Turns. Each A2A message is an open turn until the driver replies. A sender's tasks/cancel withdraws it. A message with a part that isn't text fails as proxy.unsupported_part before the driver sees it.
  • Env tools. The env servers come through mcp-config, as for any agent. The proxy calls each one at the URL, and with the headers, it was registered with, so a role header reaches the env and never the driver. A turn holds one connection per server, shared by its actions and closed with the turn.
  • bash. It runs bash -c in the proxy's folder, as its user, in a process group of its own. On a timeout (3600 s by default), or when its turn closes, the whole group is killed. shell=False is a proxy for an agent that works in no sandbox: it offers no bash and keeps no skills.
  • Deadlines. A tool action's timeout_seconds (600 s by default) covers connecting to the server and the call together. Listing a turn's tools is bounded the same way.
  • Actions belong to their turn. When it closes, its running actions are cancelled and their results forgotten. The app's lifespan (stopping_actions) stops every turn's actions when the app stops; a subclass declaring its own definition passes it on.
  • The record. The trajectory is format agentenv-proxy/v1, as OTel GenAI spans:
    • a chat span per recorded call, with its model and token usage when given;
    • an execute_tool span per action;
    • a chain span per turn, with the prompt, the system prompt and the model.
      It is served live through trajectory/v1.
  • Skills from skill-config are written to ~/skills/<name>/, and a turn names each skill's folder. A write that fails leaves no folder, and a name already added gets 409.
  • agent-config takes system_prompt.
  • The token is claimed on first use, compared as bytes, and held in memory only, so a command can't read it.
  • The README documents the extension and the agent.

Verification

  • test_a2a_proxy.py, 31 tests, cover the proxy app in process. Each passed 3 times in a row:
    • the claim and the token, a non-ASCII one included;
    • the turn round trip, withdrawal, a non-text part, an error reply, and reply validation;
    • tool listing, a name clash, a tool call carrying the registered role, and a tool error;
    • bash's structured result, the fast path, an idempotent retry, a timeout with a background child, and a turn closing mid-action;
    • a tool's deadline covering a connect that never answers;
    • one connection per server under concurrent first actions, closed with the turn;
    • record: the call check, two calls recorded at once, the message shapes and refused ones, span order and usage;
    • skills, a failed bundle, shell=False, and the app stopping.
  • Protocol suite: 526 passed. Ruff clean.
  • End to end on feat(task): play an outside agent through a ProxyAgent in its place #270's branch: see feat(task): play an outside agent through a ProxyAgent in its place #270.

🤖 Generated with Claude Code

…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 <[email protected]>
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.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 <[email protected]>
… 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 <[email protected]>
@earakely-scale earakely-scale changed the title feat(protocol): StandInAgent, an agent with no model that a driver plays through driver/v1 feat(protocol): ProxyAgent, an agent with no model whose turns a driver answers through proxy/v1 Oct 21, 2026
earakely-scale and others added 2 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 <[email protected]>
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py
Comment thread packages/agentenv-protocol/src/agentenv_protocol/a2a_agent/proxy.py Outdated
…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 <[email protected]>
try:
called = await asyncio.wait_for(self._called(turn, action), timeout)
result = ProxyToolResult(content=called.content, is_error=called.isError)
except TimeoutError:

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 Python 3.10 timeouts escape

On supported Python 3.10 installations, asyncio.wait_for raises asyncio.TimeoutError, which is distinct from built-in TimeoutError. The new catches in _perform and _tools miss it. A timed-out action then fails the act or result request instead of returning a tool-error result, and a timed-out listing skips its intended 502 response.

Catch asyncio.TimeoutError at both boundaries.

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: 342

Comment:
**Python 3.10 timeouts escape**

On supported Python 3.10 installations, `asyncio.wait_for` raises `asyncio.TimeoutError`, which is distinct from built-in `TimeoutError`. The new catches in `_perform` and `_tools` miss it. A timed-out action then fails the `act` or `result` request instead of returning a tool-error result, and a timed-out listing skips its intended 502 response.

Catch `asyncio.TimeoutError` at both boundaries.

---

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

@earakely-scale
earakely-scale merged commit 0450817 into main Oct 23, 2026
14 of 15 checks passed
@earakely-scale
earakely-scale deleted the feat/stand-in-protocol branch October 23, 2026 21:58
earakely-scale added a commit that referenced this pull request Oct 23, 2026
…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 <[email protected]>
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