Skip to content

Commit b2e8419

Browse files
committed
feat(runner): client tools on Claude in remote sandboxes
Advertise browser-fulfilled client tools to the in-sandbox stdio MCP shim and add a paused relay answer so a parked client tool ends the shim's tools/call cleanly while the runner ends the turn. The browser result returns on the cold-replay resume. Deletes the interim #5366 client-only Daytona refusal. Closes #5256; closes the residual mixed-set drop of #4984. The client-tool pause disposition is a closed set (pi-native / cold-acknowledge, with warm-hold reserved) at the client-tool boundary; the relay consumes the derived writePausedAnswer switch. Claude-Session: https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW
1 parent 2e59e7c commit b2e8419

21 files changed

Lines changed: 450 additions & 154 deletions

docs/design/agent-workflows/documentation/tools.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,11 @@ natively. Today that splits cleanly into two paths.
204204
(`services/runner/src/tools/tool-mcp-stdio.ts`, bundled by `build:extension`) plus a
205205
public-specs JSON file into an ephemeral in-VM dir and advertises them as a stdio entry the
206206
harness spawns; the shim's `tools/call` writes relay request files that the runner-side
207-
relay loop executes server-side. The shim carries executable (gateway/callback) tools only;
208-
client tools on a non-Pi remote run are refused up front, and a non-Daytona remote provider
209-
fails closed (see "Client tools" below). The full transport and env contract lives in the
207+
relay loop executes server-side. The shim carries BOTH executable (gateway/callback) AND
208+
`client` tools: an executable call relays inline, and a `client` call parks — the relay writes
209+
a benign "paused" answer so the shim ends its `tools/call` cleanly while the runner ends the
210+
turn (see "Client tools" below). A non-Daytona remote provider still fails closed. The full
211+
transport and env contract lives in the
210212
[runner-to-MCP interface page](../interfaces/cross-service/runner-to-mcp-server.md).
211213

212214
Both paths funnel execution through one function, `runResolvedTool` in
@@ -356,19 +358,23 @@ The pause itself is shared by both delivery paths through one seam
356358

357359
- **Pi** calls the tool through its extension; the runner's file relay pauses it (writes no
358360
response file) and the seam emits the interaction.
359-
- **Claude** calls the tool over the internal `agenta-tools` MCP server, and the runner pauses it
360-
inside the `tools/call` handler: it emits NO JSON-RPC result and aborts that in-flight request,
361-
so Claude cannot settle the call before the turn ends `paused`. The browser result resumes it
362-
next turn (the MCP handler returns the stored output if the model re-calls). This pause is
363-
local-only: the Daytona stdio shim has no pause path (the relay loop parks a client call and
364-
writes no response file, so the shim would hang until the relay timeout and teach the model
365-
the tool is broken), so a non-Pi remote run carrying a client tool is refused up front
366-
(`REMOTE_CLIENT_TOOLS_UNSUPPORTED_MESSAGE`), never delivered silently. Executable tools DO
367-
deliver on Claude+Daytona through the shim; a remote provider that is not Daytona still
368-
refuses ANY custom tool (`REMOTE_TOOLS_UNSUPPORTED_MESSAGE`) until in-sandbox delivery is
369-
proven there. (The ACP permission gate in `acp-interactions.ts` keeps its own
370-
`kind: "client"` pause branch as a live fallback for a harness that raises a permission gate
371-
carrying a resolved client spec.)
361+
- **Claude on the LOCAL sandbox** calls the tool over the internal `agenta-tools` loopback MCP
362+
server, and the runner pauses it inside the `tools/call` handler: it emits NO JSON-RPC result
363+
and aborts that in-flight request, so Claude cannot settle the call before the turn ends
364+
`paused`. The browser result resumes it next turn (the MCP handler returns the stored output if
365+
the model re-calls).
366+
- **Claude on DAYTONA** calls the tool over the in-sandbox stdio shim. The shim blocks on a relay
367+
answer file, so the runner cannot simply drop the response the way the local path aborts its
368+
request. Instead the relay loop writes a benign "paused" answer (`{ ok: true, paused: true }`,
369+
gated by the run plan's `writePausedAnswer` flag), and the shim returns a non-error wait result
370+
that names the tool and tells the model not to retry. Claude's turn ends cleanly while the
371+
runner ends the turn on the shared pause seam; the browser result returns on the cold-replay
372+
resume turn, exactly as on the local and Pi paths. This closes the Claude+Daytona gap (#5256)
373+
and the residual mixed-set silent drop (#4984). A remote provider that is NOT Daytona still
374+
refuses ANY custom tool (`REMOTE_TOOLS_UNSUPPORTED_MESSAGE`) until in-sandbox delivery is proven
375+
there. (The ACP permission gate in `acp-interactions.ts` keeps its own `kind: "client"` pause
376+
branch as a live fallback for a harness that raises a permission gate carrying a resolved client
377+
spec.)
372378

373379
A client tool's `render` hint can be `{ kind: "connect" }` (e.g. `request_connection`), the typed
374380
member of `RenderHint` that asks the frontend to draw the connect widget.

docs/design/agent-workflows/interfaces/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ page. `Status` is read from each page's prose: **stable** (wired and unlikely to
4747
| [Agent config schema](public-edge/agent-config-schema.md) | public | `agent/schemas.py`, `sdk/utils/types.py`, `agents/dtos.py` (`HARNESS_IDENTITIES`) | stable | `unit/agents/test_dtos_agent_config.py`, `unit/agents/test_harness_identity.py` |
4848
| [`/run`](cross-service/service-to-agent-runner.md) | cross-service (the spine) | `protocol.ts`, `utils/wire.py`, `utils/ts_runner.py`, `server.ts`/`cli.ts` | stable (pinned by golden) | `unit/agents/test_wire_contract.py` + `golden/`, `services/agent/tests/unit/wire-contract.test.ts` |
4949
| [Runner to harness](cross-service/runner-to-harness.md) | cross-service (ACP) | `engines/sandbox_agent.ts` + `sandbox_agent/{run-plan,capabilities,permissions}.ts` | evolving | `services/agent/tests/unit/sandbox-agent-*.test.ts` |
50-
| [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/{mcp,tool-mcp-assets,relay-guard}.ts`, `tools/{mcp-bridge,tool-mcp-http,tool-mcp-stdio,tool-mcp-env,relay,relay-client,relay-protocol,relay-watch}.ts` | evolving (internal channel delivered locally over loopback HTTP and on Daytona via the in-sandbox stdio shim; user stdio disabled; remote client tools deferred) | `services/runner/tests/unit/{mcp-servers,session-mcp-layering,tool-mcp-assets,tool-mcp-stdio,tool-relay-guard}.test.ts` |
50+
| [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/{mcp,tool-mcp-assets,relay-guard}.ts`, `tools/{mcp-bridge,tool-mcp-http,tool-mcp-stdio,tool-mcp-env,relay,relay-client,relay-protocol,relay-watch}.ts` | evolving (internal channel delivered locally over loopback HTTP and on Daytona via the in-sandbox stdio shim, `client` tools included — a client call parks via a paused relay answer; user stdio disabled) | `services/runner/tests/unit/{mcp-servers,session-mcp-layering,tool-mcp-assets,tool-mcp-stdio,tool-relay-guard}.test.ts` |
5151
| [Runner to tool callback](cross-service/runner-to-tool-callback.md) | cross-service | `tools/{callback,dispatch,direct}.ts`, `apis/fastapi/tools/router.py` (`/tools/call`, `/tools/discover`, `_call_reserved_agenta_tool`), `core/tools/{discovery,service,platform_handlers}.py`, `agent/tools/resolver.py` | evolving (the `call` descriptor is wired and platform ops emit it; the legacy `tools.agenta.find_capabilities` route is deleted; reserved refs now dispatch server handlers, resolution flag-gated off until the runner half lands) | `services/agent/tests/unit/{code-tool,extension-tools}.test.ts`, `api unit/tools/{test_workflow_tool_call,test_discovery,test_platform_handlers}.py`, `unit/agents/platform/test_op_catalog.py` |
5252
| [Service and runner trace export](cross-service/service-and-runner-trace-export.md) | cross-service | `agent/tracing.py`, `tracing/otel.ts`, `extensions/agenta.ts` | stable | `services/agent/tests/unit/` |
5353
| [Service to vault and tool providers](cross-service/service-to-vault-and-tool-providers.md) | cross-service (external) | `agent/app.py`, `platform/{resolve,connections}.py`, `agents/capabilities.py`, `tools/router.py` | stable | `unit/agents/connections/`, `unit/agents/platform/`, `unit/agents/tools/` |

docs/design/agent-workflows/interfaces/cross-service/runner-to-mcp-server.md

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ and `buildSessionMcpServers` repeats the check at session materialization
3030
(`assertNoReservedUserMcpName`) as defense in depth.
3131

3232
**The internal channel, local transport (HTTP on loopback).** For a non-Pi harness with
33-
executable tool specs on the LOCAL sandbox, `buildToolMcpServers` starts a tiny MCP
33+
tool specs (executable and `client` alike) on the LOCAL sandbox, `buildToolMcpServers` starts a tiny MCP
3434
server on `127.0.0.1:<ephemeral>` and returns one ACP `type: "http"` entry
3535
(`{name: "agenta-tools", url, headers: []}`). The server speaks JSON-RPC 2.0 over Streamable-HTTP
3636
(stateless JSON mode) and answers three methods:
@@ -57,19 +57,21 @@ closed for user stdio MCP. The run end closes it (releases the port).
5757

5858
**The internal channel, Daytona transport (in-sandbox stdio shim).** The loopback URL is a
5959
runner-host address; on Daytona the harness runs IN the sandbox, where `127.0.0.1` is the
60-
sandbox's own loopback, not the runner's, so the HTTP transport is unusable. Instead, for a
61-
non-Pi harness with executable tool specs, the engine uploads two files into an ephemeral
62-
in-VM dir (`/home/sandbox/agenta/tool-mcp/<key>`, a sibling of the relay dir, keyed the same
60+
sandbox's own loopback, not the runner's, so the HTTP transport is unusable. Instead, for any
61+
non-Pi harness with tool specs (executable and `client` alike), the engine uploads two files
62+
into an ephemeral in-VM dir (`/home/sandbox/agenta/tool-mcp/<key>`, a sibling of the relay dir, keyed the same
6363
way — never inside the relay dir, which the relay loop sweeps, and never on the durable
6464
geesefs cwd):
6565

6666
- `tool-mcp-stdio.js` — the esbuild bundle of `tools/tool-mcp-stdio.ts`, built by
6767
`pnpm run build:extension` alongside the Pi extension. `SANDBOX_AGENT_RELAY_MCP_BUNDLE`
6868
overrides the bundle path on the runner (trusted deployment configuration, never run or
6969
request configuration; tests point it at a fixture).
70-
- `tool-mcp-specs.json` — the run's `AdvertisedToolSpec` array (public fields only). A file,
71-
not an env value, because the env is copied through four exec layers and tool JSON Schemas
72-
are unbounded.
70+
- `tool-mcp-specs.json` — the run's `AdvertisedToolSpec` array (public fields only). `client`
71+
specs ride this file too now, so the model sees browser-fulfilled tools and can call them; a
72+
paused `tools/call` returns a benign non-error wait result via the `{ ok: true, paused: true }`
73+
relay answer (below). A file, not an env value, because the env is copied through four exec
74+
layers and tool JSON Schemas are unbounded.
7375

7476
`buildInternalToolMcpEntry` then advertises one ACP stdio entry in `sessionInit.mcpServers`:
7577
`{name: "agenta-tools", command: "node", args: [bundlePath], env}` with NO `type` field — the
@@ -93,15 +95,20 @@ server-side. The shim runs under the sandbox's own confinement, not on the runne
9395
does not reopen the #4831 user-stdio hole (that hole is a runner-host concern, and this entry
9496
is synthesized by the runner, never user-declared).
9597

96-
Two refusals remain on the remote path, both loud in `run-plan.ts` (never a silent tool drop):
97-
a remote provider that is not Daytona fails closed with `REMOTE_TOOLS_UNSUPPORTED_MESSAGE`
98-
(the shim's upload + spawn path is proven for Daytona only, and a new provider must not
99-
silently re-open the F1 zero-tools drop), and any `client` (browser-fulfilled) tool on a
100-
non-Pi remote run refuses with `REMOTE_CLIENT_TOOLS_UNSUPPORTED_MESSAGE` (the relay loop parks
101-
a client call and writes no response file, so through the shim it would hang until the relay
102-
timeout; the loopback channel's pause-by-abort has no stdio equivalent yet). Executable
103-
(gateway/callback) tools proceed. A user http MCP server (a remote URL the harness dials
104-
directly) is NOT loopback-bound and stays delivered on Daytona unchanged.
98+
One refusal remains on the remote path, loud in `run-plan.ts` (never a silent tool drop): a
99+
remote provider that is not Daytona fails closed with `REMOTE_TOOLS_UNSUPPORTED_MESSAGE` (the
100+
shim's upload + spawn path is proven for Daytona only, and a new provider must not silently
101+
re-open the F1 zero-tools drop). On Daytona both executable (gateway/callback) AND `client`
102+
(browser-fulfilled) tools now proceed. A client call PARKS through the shim rather than being
103+
refused: the runner-side relay loop (`startToolRelay`) writes a benign paused answer
104+
(`{ ok: true, paused: true }`) into the relay response file, gated by the run-plan
105+
`writePausedAnswer` capability flag (true for a non-Pi harness, false for Pi). The shim maps
106+
that answer to a benign, NON-error `tools/call` result whose text names the tool and tells the
107+
model not to retry, so Claude's turn ends cleanly on the shared client-tool pause seam instead
108+
of waiting out the per-tool relay timeout and emitting a late error frame. The browser result
109+
returns on the cold-replay resume turn, exactly as on the local and Pi paths. A user http MCP
110+
server (a remote URL the harness dials directly) is NOT loopback-bound and stays delivered on
111+
Daytona unchanged.
105112

106113
**The file relay.** A resolved tool may need to run privately rather than inside the harness
107114
process. The relay moves the call across that boundary: the child publishes a `<id>.req.json`
@@ -167,10 +174,10 @@ stdio does not exist in the run contract. Pi refuses external user MCP servers w
167174
on Daytona via `buildInternalToolMcpEntry`; the reserved-name check
168175
`assertNoReservedUserMcpName`; threads `clientToolRelay` + abort signal; `validateUserMcpUrl`
169176
SSRF guard).
170-
- `services/runner/src/engines/sandbox_agent/run-plan.ts`: the remote gates
171-
(`REMOTE_TOOLS_UNSUPPORTED_MESSAGE` for non-Daytona remotes,
172-
`REMOTE_CLIENT_TOOLS_UNSUPPORTED_MESSAGE` for client tools on a non-Pi remote run, the
173-
reserved-name refusal) and the `toolMcpDir` placement invariants.
177+
- `services/runner/src/engines/sandbox_agent/run-plan.ts`: the remote gate
178+
(`REMOTE_TOOLS_UNSUPPORTED_MESSAGE` for non-Daytona remotes, the reserved-name refusal), the
179+
`writePausedAnswer` capability flag (true for non-Pi so a client call parks rather than hangs;
180+
false for Pi), and the `toolMcpDir` placement invariants.
174181
- `services/runner/src/engines/sandbox_agent/tool-mcp-assets.ts`: the shim bundle location
175182
(`SANDBOX_AGENT_RELAY_MCP_BUNDLE` override) and the per-run upload (`uploadToolMcpAssets`,
176183
fail-loud `TOOL_MCP_UNAVAILABLE_MESSAGE`).
@@ -185,15 +192,19 @@ stdio does not exist in the run contract. Pi refuses external user MCP servers w
185192
- `services/runner/src/tools/tool-mcp-http.ts`: the internal loopback HTTP MCP server (the
186193
`client` pause: no JSON-RPC result + abort-the-request).
187194
- `services/runner/src/tools/tool-mcp-stdio.ts`: the in-sandbox stdio shim (newline-delimited
188-
JSON-RPC; `tools/call` writes relay request files through the shared relay client).
195+
JSON-RPC; `tools/call` writes relay request files through the shared relay client, and maps a
196+
`paused` relay answer to a benign, non-error wait result so a client call ends the turn cleanly).
189197
- `services/runner/src/tools/tool-mcp-env.ts`: the shim's env-name contract, a dependency-free
190198
module the server-side entry builder shares without importing the bundle entrypoint.
191199
- `services/runner/src/tools/spec-schema.ts`: the shared `specInputSchema` accessor + arg
192200
validation.
193201
- `services/runner/src/tools/relay.ts`: the runner-side relay loop and hosts
194-
(delete-on-pickup; idle-poll backoff in fallback mode).
202+
(delete-on-pickup; idle-poll backoff in fallback mode; `startToolRelay` writes the
203+
`{ ok: true, paused: true }` paused answer for a parked client call when `writePausedAnswer`
204+
is set).
195205
- `services/runner/src/tools/relay-client.ts` and `relay-protocol.ts`: the bundle-safe
196-
in-sandbox writer and wire protocol (atomic publication; the hop-1 response watch).
206+
in-sandbox writer and wire protocol (atomic publication; the hop-1 response watch; the
207+
optional `paused?: true` field on `ExecuteRelayResponse` for a parked client call).
197208
- `services/runner/src/tools/relay-watch.ts`: the hop-2 wake sources (local `fs.watch`; the
198209
flagged Daytona watch exec).
199210

@@ -209,15 +220,25 @@ stdio does not exist in the run contract. Pi refuses external user MCP servers w
209220
the MCP client the installed Claude harness uses; re-verify it if that version moves. The
210221
Daytona stdio shim answers the same three methods over newline-delimited JSON-RPC; it copies
211222
the `specInputSchema` fallback locally so its bundle's import surface stays exactly
212-
relay-client + relay-protocol + types.
213-
- **The client-tool pause is no-result-before-finish.** A paused `tools/call` must never write a
214-
JSON-RPC result (a result lets the harness settle and clobber the pending widget); the handler
215-
aborts its own request and the engine fires an `AbortSignal` on pause/teardown. The stdio shim
216-
has NO pause path, which is exactly why client tools are refused on the remote non-Pi path.
217-
- **The remote-tools gates.** A non-Pi run on a non-Daytona remote provider carrying ANY custom
218-
tool is refused in `run-plan.ts` (fail closed until in-sandbox delivery is proven there); a
219-
non-Pi remote run carrying a `client` tool is refused separately. Executable tools proceed on
220-
Daytona via the shim. Do not widen either gate without a proven delivery path.
223+
relay-client + relay-protocol + types. On the shim a client `tools/call` parks instead of
224+
aborting: it returns the benign non-error wait result mapped from the `{ ok: true, paused: true }`
225+
relay answer.
226+
- **The client-tool pause seam has two transports, one outcome.** Both end the turn on the
227+
shared client-tool pause seam; they differ only in how the paused `tools/call` gets there. On
228+
the loopback HTTP channel a paused call must never write a JSON-RPC result (a result lets the
229+
harness settle and clobber the pending widget); the handler aborts its own request and the
230+
engine fires an `AbortSignal` on pause/teardown. Over stdio there is no socket to abort, so the
231+
runner instead writes a benign `{ ok: true, paused: true }` relay answer (gated by the run-plan
232+
`writePausedAnswer` flag) and the shim returns a benign, non-error wait result — the turn still
233+
ends on the pause seam, and client tools now deliver on Daytona. The pause models a CLOSED set
234+
of outcomes — answered / error / paused-cold (the current Daytona behavior) — with a
235+
`paused-hold` outcome RESERVED for a future warm-path native hold (keeping the shim's call open
236+
inside a live turn, like an ACP approval); no warm behavior is built yet.
237+
- **The remote-tools gate.** A non-Pi run on a non-Daytona remote provider carrying ANY custom
238+
tool is refused in `run-plan.ts` (fail closed until in-sandbox delivery is proven there). On
239+
Daytona both executable AND `client` tools proceed via the shim: executable tools run
240+
server-side, and a client call parks through the `writePausedAnswer` relay answer. Do not widen
241+
the gate to another remote provider without a proven delivery path.
221242
- **The reserved server name.** `agenta-tools` must stay in lockstep with the Python adapter's
222243
rendered `mcp__agenta-tools__<tool>` rules, and a user server may never claim it (checked at
223244
declaration time and again at materialization).

0 commit comments

Comments
 (0)