Skip to content

feat(runner): event-driven tool relay (fs.watch + Daytona long-poll watch, polling fallback)#5243

Merged
mmabrouk merged 6 commits into
big-agentsfrom
feat-event-driven-tool-relay
Jul 12, 2026
Merged

feat(runner): event-driven tool relay (fs.watch + Daytona long-poll watch, polling fallback)#5243
mmabrouk merged 6 commits into
big-agentsfrom
feat-event-driven-tool-relay

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 12, 2026

Copy link
Copy Markdown
Member

Context

Every tool call from a sandboxed agent pays a polling tax. The in-sandbox writer drops a request file and sleeps in 300ms steps until the response appears; the runner finds requests by listing the relay directory every 300ms (1.5s once idle). One tool call adds 0.3 to 1.8s of pure waiting, and a ten-tool turn wastes over ten seconds. On Daytona the poll is also traffic: about 200 remote ls calls per minute per active turn.

This PR makes the relay event-driven. A filesystem event wakes the waiter the moment a file lands, and polling stays underneath as the correctness fallback. If a watcher dies, the relay degrades to exactly today's polling behavior, never to a hang.

Design and open questions: docs/design/agent-workflows/projects/event-driven-tool-relay/ (reviewed on #5232).

Changes

Atomic publication. Both directions now write to a temp file and rename it into place, so a reader can never see half-written JSON. On Daytona the response rename is the daemon's moveFs, which is rename(2) (verified against the daemon source), atomic for a same-directory move.

Wake on events, poll as backup. The writer arms one filesystem watch on the relay directory before it first checks for the response, so the response wakes it in about a millisecond. The runner does the same for incoming requests. Every watch path has a poll racing underneath it as the safety net, and repeated watch failure demotes the turn back to plain polling with one log line.

Before: the response is noticed 0 to 300ms after it lands. After: one filesystem event, about 1ms, and the poll only runs if the watch fails.

At most once. The runner deletes each request file the instant it reads it, and its first list of a reused relay directory marks any leftover files as already seen instead of running them. Together these end crash-redelivery: a request runs at most once, and a request lost to a crash surfaces as a writer timeout and a tool error, never as a repeated side effect. (This closed a real storm: without the delete, an in-flight request re-triggered every watch window.)

Telemetry. One stage=relay_pickup id=... pickup_ms=... wake=... line per executed request.

Two knobs, both defaulting to safe: the hop-1 response watch is on (..._RESPONSE_WATCH_ENABLED, degrades to poll); the Daytona remote watch is off until its live pass (..._REMOTE_WATCH_ENABLED).

Request volume

Active Daytona polling costs about 200 requests/min today. A healthy watch costs about 4.4/min (window re-issues plus two safety polls), roughly 45x less. Each executed tool call adds two daemon calls: the delete on pickup and a stat for the timing log.

Tests

  • 71 relay unit and integration tests across four new suites, plus the real watch script spawned as a process (pre-existing file, event, window bound, awkward directory names, missing directory), the window clamp and jitter bounds, the storm regression, the orphan-snapshot rule, and telemetry ordering.
  • Full services/runner suite: 1011 passed. The 4 remaining failures are pre-existing on this stack, from other in-flight lanes, and byte-identical before and after.

Live QA

  • Local, verified: one real Claude turn in the playground with a workflow-reference tool. The tool ran through the relay and the runner logged pickup_ms=1.28 wake=activity. Event-driven pickup at 1.3ms against the 0 to 300ms polling baseline.
  • Daytona, pending and honest: the remote watch ships off. Its flag-on live pass, the capacity numbers at peak concurrency, and the pickup distributions are rollout gates before any default flip. Unit and fake-daemon coverage stand in until then.

What to QA

  • Playground, local sandbox, any agent with a tool: send a message that triggers the tool. The result renders and the runner log shows one stage=relay_pickup line with wake=activity and a single-digit pickup_ms.
  • Kill switch: set AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED=false and repeat. Behavior is identical, pickup_ms grows up to about 300, wake=poll.
  • Regression to watch: approval-gated tools ride ACP, not the relay. Approve a gated call and confirm it runs exactly once.

Notes

https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC

mmabrouk added 5 commits July 11, 2026 23:38
…lay writer

Slice 0 of the event-driven tool relay: move the in-sandbox writer (relayToolCall)
out of tools/dispatch.ts into tools/relay-client.ts, and the wire protocol (suffixes,
request/response types, sanitizeRelayId, request serialization, shared timing env
constants) into tools/relay-protocol.ts. Both new modules are bundle-safe (node
builtins only) so the Pi extension bundle and the future in-sandbox MCP shim (#5234)
can consume them without pulling in server-side runner code. dispatch.ts and relay.ts
re-export every moved symbol, so all call sites compile unchanged.

Zero behavior change; a golden test pins the byte-exact .req.json serialization as
the cross-writer contract. Side effect: the extension bundle drops the server-side
relay loop it previously pulled in through dispatch's constant imports.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
…op 1)

Slice 1 of the event-driven tool relay. Two mechanisms:

Atomic publication (plan decision 2), both directions: every relay file is now
published via a temp name (<final>.tmp.<nonce>) plus a same-directory rename, so a
reader or watcher can never observe partial JSON. The writer renames with fs.renameSync;
the runner-side response write goes through a new required RelayHost.rename capability
(localRelayHost: renameSync; sandboxRelayHost: daemon moveFs, verified rename(2)-atomic
against the daemon v0.4.2 source - open question 2 resolved). Temp names never match
the .req.json/.res.json suffix filters. Final on-disk bytes are unchanged (golden test).

Hop 1 response watch (decisions 3, 6, 7): waitForRelayResponse arms one coalescing
fs.watch on the relay dir BEFORE its first existsSync check, so a response file wakes
the writer instantly instead of after a 0-300 ms poll sleep. The existing RELAY_POLL_MS
cadence survives as the racing safety timer, and any watch failure degrades to the
plain poll - never a hang, never a rejection. Kill switch:
AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED (default true), forwarded to the
sandbox env only when the operator set it.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Slice 2 of the event-driven tool relay. The runner relay loop gains wake sources
(plan decisions 3, 4, 6, 7):

- RelayActivitySource (tools/relay-watch.ts): a coalesced, single-flight wait
  contract - wait({timeoutMs}) -> activity|timeout|closed - with sticky wakes, no
  listener growth, and errors that degrade instead of rejecting.
- Local backend: an in-process fs.watch source (no flag); the watch only shortens
  the existing poll sleeps, the cadence and idle backoff stay untouched.
- Daytona: a re-issued bounded watch exec behind
  AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_ENABLED (default false). One inline
  node -e script per window (relay dir passed argv-only, arm-watch-then-list,
  in-script 2 s readdir fallback, idempotent finish, no stdout, natural exit).
  While the watch is healthy the runner suspends its remote ls polling and keeps a
  30 s safety poll; repeated failure (exec rejection, daemon timeout, nonzero/null
  exit, runner-side outer bound, safety-poll misses) demotes the turn to classic
  polling with jittered exponential backoff and one demotion log line.
- Window config: AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS, default 25 s,
  clamped to [5s, 120s], downward-only ~20% jitter so a window always completes
  before the safety poll.
- Delete-on-pickup: the runner removes a request file right after reading it, so
  an in-flight execution cannot insta-complete every watch window (the rearm-storm
  found in review). This deliberately ends crash-redelivery-by-re-listing: a
  request executes at most once per publication.
- Runner-side outer bound: a generation-tagged local timer abandons a wedged exec
  request at window+grace+margin and counts the failure, so a blackholed HTTP
  request cannot starve demotion.

Deviations from the plan, both recorded in the workspace: close() abandons (not
aborts) the in-flight exec because the SDK's runProcess takes no per-call
AbortSignal (bounded by the script's own window timer); window jitter is
downward-only instead of +/-20% to keep windows shorter than the safety poll.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Slice 3 of the event-driven tool relay.

Orphan-request residue (accepted ownership of open question 4): a reused relay dir
on a warm-continued turn could hold a stale .req.json from a crashed prior turn,
and the next turn's fresh seen set would re-execute it (workspace.ts clears the dir
only on cold builds). The relay loop's first successful list is now a snapshot pass:
every request file already present is marked seen and best-effort deleted, never
executed. A turn only executes requests created after it started. The loop starts
before the prompt (and before respondPermission on resume), so no legitimate
request can predate the snapshot.

Telemetry: one '[relay] stage=relay_pickup id=... pickup_ms=... wake=...' log per
picked-up request, measuring publication (file mtime, via a new optional
RelayHost.statMtimeMs) to handler start, tagged with the wake outcome at discovery
(activity/timeout/closed/poll). Stat failures degrade to pickup_ms=-1 and never
affect execution. Costs one stat per executed tool call; on Daytona the number is
cross-clock and approximate, for QA distributions.

The engine now passes its turn logger into startToolRelay so the pickup, stale-file,
demotion, and watch-failure lines reach the run log.

Five existing tests moved their request-file seeding to after startToolRelay: the
snapshot deliberately inverts 'a request present at startup is served', and in
production the sandbox cannot write a request before the loop exists.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Update the tools page's file-relay description (writer in relay-client.ts, wire
protocol in relay-protocol.ts, atomic temp+rename publication, delete-on-pickup /
at-most-once, per-turn stale snapshot, event-driven pickup with the polling
fallback, the three new AGENTA_AGENT_TOOLS_RELAY_*WATCH* env vars and the
keep-the-window-below-30s guidance), fix the stale services/agent paths in its
where-this-lives table for the relay rows, refresh the runner-to-MCP-server
interface page's relay lifecycle (no more 'polls every 300ms'; atomic publication;
crash redelivery gone on both backends), and add the new relay modules to the
interface index row.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview, Comment Jul 12, 2026 3:01am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 89.19% which is sufficient. The required threshold is 60.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: an event-driven tool relay with watch-based wakeups and polling fallback.
Description check ✅ Passed The description is detailed and directly describes the relay, watcher, atomic publish, and fallback changes in the PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-event-driven-tool-relay

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Five verified findings from the high-effort review, fixed:

- The orphan snapshot raced the resume flow: the first successful list's READ
  time is unordered against respondPermission on Daytona, so a just-approved
  request could be swallowed as stale, and a transiently failing first list
  deferred the snapshot behind legitimate requests. The snapshot is now a
  dedicated pre-loop sweep (3 bounded list attempts; removes stale .req.json,
  .res.json, and relay tmp names concurrently; never touches other files) and
  startToolRelay exposes it as 'ready', which the engine awaits BEFORE
  respondPermission and the prompt - the causal ordering is now explicit, not
  a comment. Stale responses are swept too: a resumed approval reuses its
  original toolCallId, so a crashed attempt's response file could otherwise
  satisfy the new wait with stale bytes.
- A nullish runProcess result (or missing exitCode) classified as watch
  success: an insta-resolving broken daemon path stormed forever with the
  demotion counter pinned at zero. Success now requires exitCode === 0 and no
  daemon timeout; everything else counts a failure.
- Delete-on-pickup landed asynchronously after the loop re-armed the next
  watch window, so each pickup still bought an insta-complete/rearm burst,
  and a silently failed remove revived the full storm with counter-resetting
  exit-0 wakes. Pickup (read, then stat and remove concurrently) now completes
  before the next window arms; failed removes are retried on later list
  passes; execution still starts as soon as the read returns.
- A window failure while a wait was already parked left the source windowless
  until the 30 s safety timer and miscounted the safety-poll discovery as a
  watch miss. countFailure now schedules the deferred re-arm when a waiter is
  parked; the arm gate is one tryArm() used by all three arm sites; the
  inFlight flag collapsed into the generation counter.
- The safety-poll independence promised by the plan was implemented inside the
  watch source it guards. The loop now bounds every source.wait with its own
  racing timer, so a wedged wait can never stop the list pass.

Plus cheap wins: waitForRelayResponse fast-paths a pre-existing response
before arming a watcher and re-checks once at the deadline; the pickup stat is
skipped when no log sink is wired; one relayEnvFlag parser for both watch
flags; the extension build now FAILS if server-side relay symbols leak into
the sandbox bundle (negative-tested); the orchestration test pins the engine's
relay logger wiring.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
@mmabrouk

mmabrouk commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Reviewed with Codex (high effort). The event-driven loop is sound: no missed wakeups, no partial reads, no double execution, and local pickup is verified at about 1.3ms.

The review found two failure-path bugs and one parse nit: a hung file delete could wedge a turn, the demotion counter could fail to accumulate a real miss, and the window fallback accepted partial garbage. All three are fixed in #5248 on top of the stack.

Nothing here needs a decision from you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5bf685f-cfd5-4bfa-b9ae-f00f277fac23

📥 Commits

Reviewing files that changed from the base of the PR and between a8257c9 and a1ec038.

📒 Files selected for processing (20)
  • docs/design/agent-workflows/documentation/tools.md
  • docs/design/agent-workflows/interfaces/README.md
  • docs/design/agent-workflows/interfaces/cross-service/runner-to-mcp-server.md
  • services/runner/scripts/build-extension.mjs
  • services/runner/src/engines/sandbox_agent.ts
  • services/runner/src/engines/sandbox_agent/pi-assets.ts
  • services/runner/src/tools/dispatch.ts
  • services/runner/src/tools/relay-client.ts
  • services/runner/src/tools/relay-protocol.ts
  • services/runner/src/tools/relay-watch.ts
  • services/runner/src/tools/relay.ts
  • services/runner/tests/unit/relay-atomic-publish.test.ts
  • services/runner/tests/unit/relay-client.test.ts
  • services/runner/tests/unit/relay-loop.test.ts
  • services/runner/tests/unit/relay-watch.test.ts
  • services/runner/tests/unit/sandbox-agent-orchestration.test.ts
  • services/runner/tests/unit/sandbox-agent-pi-assets.test.ts
  • services/runner/tests/unit/tool-callref-bindings.test.ts
  • services/runner/tests/unit/tool-direct.test.ts
  • services/runner/tests/unit/tool-relay-guard.test.ts

Comment on lines +281 to +284
- `AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS`: one watch exec window. Default 25000,
clamped to [5000, 120000], with downward-only jitter of up to 20 percent. Keep it below the
30 s safety poll: a window of 30 s or more still works but degrades pickup latency and can
demote a healthy watch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS|REMOTE_WATCH_WINDOW_MS|watch window|parseInt" .

Repository: Agenta-AI/agenta

Length of output: 9202


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '60,100p' services/runner/src/tools/relay-watch.ts
printf '\n--- TESTS ---\n'
sed -n '522,620p' services/runner/tests/unit/relay-watch.test.ts
printf '\n--- DOCS ---\n'
sed -n '275,288p' docs/design/agent-workflows/documentation/tools.md

Repository: Agenta-AI/agenta

Length of output: 5923


Use full-string validation for the watch-window env var. resolveRemoteWatchWindowMs() uses Number.parseInt, so values like 30000junk are accepted as 30000. Either reject non-numeric suffixes or document that prefix parsing is allowed.

| [`/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` |
| [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` |
| [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/mcp.ts`, `tools/{mcp-bridge,mcp-server,relay}.ts` | evolving (stdio wired; remote deferred) | `services/agent/tests/unit/mcp-servers.test.ts` |
| [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/mcp.ts`, `tools/{mcp-bridge,mcp-server,relay,relay-client,relay-protocol,relay-watch}.ts` | evolving (stdio wired; remote deferred) | `services/agent/tests/unit/mcp-servers.test.ts` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the inventory status with the contract page.

This row says “stdio wired”, but runner-to-mcp-server.md says user stdio servers are disabled and mcp-server.ts is a refusing stub. If “stdio wired” refers to another channel, name it; otherwise update the status.

Comment on lines +14 to +19
export const RELAY_POLL_MS = Number(
process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300,
);
export const RELAY_TIMEOUT_MS = Number(
process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate RELAY_POLL_MS/RELAY_TIMEOUT_MS parsing — bare Number() silently yields NaN on malformed input.

Unlike relayEnvFlag below (explicit allow-list, safe fallback), these two constants have no validation. A malformed env value (e.g. "30000junk", empty string, or a negative number) makes Number(...) return NaN or a negative number. Since waitForRelayResponse's deadline = Date.now() + opts.timeoutMs would then be NaN/in the past, Date.now() < deadline is always false, so every relay call throws RelayTimeoutError immediately regardless of the actual response timing.

🛡️ Proposed fix
+function parsePositiveIntEnv(value: string | undefined, fallback: number): number {
+  const n = Number(value);
+  return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
-export const RELAY_POLL_MS = Number(
-  process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300,
-);
-export const RELAY_TIMEOUT_MS = Number(
-  process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000,
-);
+export const RELAY_POLL_MS = parsePositiveIntEnv(
+  process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING,
+  300,
+);
+export const RELAY_TIMEOUT_MS = parsePositiveIntEnv(
+  process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT,
+  60000,
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const RELAY_POLL_MS = Number(
process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300,
);
export const RELAY_TIMEOUT_MS = Number(
process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000,
);
function parsePositiveIntEnv(value: string | undefined, fallback: number): number {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const RELAY_POLL_MS = parsePositiveIntEnv(
process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING,
300,
);
export const RELAY_TIMEOUT_MS = parsePositiveIntEnv(
process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT,
60000,
);

log(
`[relay] stage=relay_pickup id=${id} pickup_ms=${
mtimeMs === undefined ? -1 : Date.now() - mtimeMs
} wake=${wake}`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The at-most-once guarantee, worth reading closely. The runner deletes each request file the instant it reads it, and its first list of a reused relay directory marks any leftover files as seen (around line 692) instead of running them. Together these end crash-redelivery on both backends: a request runs at most once per publication, and a request lost to a crash surfaces as a writer timeout and a tool error, never as a replayed side effect. The follow-up fix in #5248 bounds these deletes with a timeout so a hung daemon call cannot wedge the loop here.

@mmabrouk mmabrouk added the needs-review Agent updated; awaiting Mahmoud's review label Jul 12, 2026
// in-sandbox writer defaults it to true, so it is only forwarded — verbatim — when
// the operator set it on the runner.
const responseWatch =
process.env.AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where are these defined. are they on the compose level? we re starting to have to many env vars and ff, just want to make sure it is maintainable

@mmabrouk mmabrouk left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@mmabrouk
mmabrouk marked this pull request as ready for review July 12, 2026 17:46
@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. Backend enhancement New feature or request labels Jul 12, 2026
mmabrouk added a commit that referenced this pull request Jul 12, 2026
Addresses the mechanical review findings on #5243, #5244, and #5245 that carry
no design decision and need no live verification. Landed as one commit on top
of the stack because GitButler cannot retro-assign a new delta onto a lower
applied lane; each fix is mapped to its PR below.

Relay (#5243):
- relay.ts: bound every relay-file removal with a timeout so a hung daemon
  deletion can no longer wedge pickup, stale-sweep, or shutdown; a timed-out
  removal is treated as failed and the loop continues.
- relay-watch.ts: tie a safety-poll miss to the active watch generation so a
  later zero-exit of the same generation cannot erase it (the demotion counter
  now accumulates); validate the whole watch-window string instead of
  parseInt accepting partial garbage.

Shim (#5244):
- tool-mcp-stdio.ts: answer MCP 'ping' with an empty result; return -32700 on
  unparseable input and -32600 on an invalid request; document the
  at-least-once relay delivery contract. Protocol-version negotiation is left
  unchanged (that needs a live check).
- tool-mcp-assets.ts: cache the shim bundle by path and write bundle + specs
  concurrently, removing avoidable serial Daytona round trips.
- restored the direct client-only Daytona refusal test; pinned the Pi vs
  non-Pi ask guard wiring; updated the build:extension docs.

Continuation (#5245):
- tool-mcp-http.ts: authenticate before the method check so an unauthenticated
  GET/DELETE gets 401, not 405.
- added auth tests: unauthorized GET/DELETE, wrong scheme, empty and
  whitespace tokens, and that neither the callback executor nor the client
  relay runs on an unauthorized call.

Deferred (need a design decision or a live check, not in this commit): the
non-Pi ask authorization model (#5244), the bearer-in-argv delivery (#5245),
MCP protocol-version negotiation (#5244).

Claude-Session: https://claude.ai/code/session_0127AM79khCdvD2b8BG2joZL
@mmabrouk
mmabrouk merged commit 8e927fc into big-agents Jul 12, 2026
15 checks passed
@mmabrouk
mmabrouk deleted the feat-event-driven-tool-relay branch July 12, 2026 20:30
pull Bot pushed a commit to jasonkneen/agenta that referenced this pull request Jul 14, 2026
…ndbox stdio MCP shim

Slice 1 of in-sandbox-tool-mcp (docs/design/agent-workflows/projects/in-sandbox-tool-mcp/).
Today a Claude run on Daytona with any custom tool is refused up front
(REMOTE_TOOLS_UNSUPPORTED_MESSAGE): execution via the file relay is solved, but nothing
inside the sandbox advertises the tools to an MCP-client harness. This revives PR Agenta-AI#4873
onto today's paths, consuming the relay client PR Agenta-AI#5243 extracted.

- tools/tool-mcp-stdio.ts: a dependency-free stdio MCP shim (9.6 kB bundled) the harness's
  ACP adapter spawns inside the sandbox. NDJSON JSON-RPC; tools/list serves the run's
  public specs from a specs FILE; tools/call writes a relay request through the shared
  relay client (atomic publication, per-tool timeoutMs) and maps failures to isError
  results. Fail-loud exit 1 on missing env/specs. Env contract in tools/tool-mcp-env.ts:
  AGENTA_AGENT_TOOLS_RELAY_DIR + AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE (a file, not an
  unbounded env value copied through four exec layers).
- engines/sandbox_agent/tool-mcp-assets.ts: bundle + specs upload, always-write (closes
  bundle-version skew in reused sandboxes), THROWS on missing bundle/failed upload on the
  path that requires the shim. Bundle override SANDBOX_AGENT_RELAY_MCP_BUNDLE.
- engines/sandbox_agent/mcp.ts: dedicated internal-entry constructor building the typeless
  ACP stdio shape named agenta-tools (claude_settings.py couples permission rules to it);
  McpServerStdio moved here out of mcp-bridge.ts; user-declared servers may not claim the
  reserved name; toAcpMcpServers still cannot emit stdio; user-stdio refusal unrelaxed.
- run-plan.ts: the remote-tools gate narrows - executable tools on Daytona pass (new
  toolMcpDir ephemeral sibling of the relay dir), client tools refuse loud with their own
  message, non-Daytona remote providers stay fail-closed.
- engines/sandbox_agent/relay-guard.ts: the relay execution guard now guards EVERY harness
  (review finding): allow/deny identical everywhere; ask consumes the Pi grant ledger on Pi
  and passes on MCP harnesses whose own dialog gates the call, with the forged-ask residual
  documented. Previously Claude relay records executed unguarded.
- The slice-0 restart spike locked the A2 transport: the pinned Claude ACP adapter
  (claude-agent-acp 0.22.2 in snapshot agenta-sandbox-pi) respawns the stdio MCP subprocess
  on the session/load path after a VM stop/restart (spike-restart.md in the workspace).

Tests: 25+ new (shim handler + real startToolRelay round trip incl. crash-after-write,
concurrency, stdout purity; assets fail-loud; three-layer session MCP pin; gate matrix;
non-Pi guard semantics). Suite 1054 passed / 4 known pre-existing failures; typecheck clean
for the slice; both bundles build with the forbidden-server-symbols gate.

Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend enhancement New feature or request needs-review Agent updated; awaiting Mahmoud's review size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant