Skip to content

fix(agents): stream forwarded request bodies into sub-agents - #2051

Open
AntoniTok wants to merge 1 commit into
cloudflare:mainfrom
AntoniTok:fix-2015-facet-body-streaming
Open

fix(agents): stream forwarded request bodies into sub-agents#2051
AntoniTok wants to merge 1 commit into
cloudflare:mainfrom
AntoniTok:fix-2015-facet-body-streaming

Conversation

@AntoniTok

@AntoniTok AntoniTok commented Aug 5, 2026

Copy link
Copy Markdown

Closes #2015

The problem

When a request goes to a sub-agent (a URL with /sub/... in it), the parent used to read the entire upload into memory before passing it on. No limit. A 128 MB upload made the parent balloon by ~546 MB. And your app couldn't stop it, because this happened before your code ever ran.

What changed

Two lines. Instead of reading the whole body first, the parent now just hands the body through as a stream — it passes the pipe along instead of filling a bucket and carrying it.

  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
-   forwardedInit.body = await req.arrayBuffer();
+   forwardedInit.body = req.body;
  }

Both call sites had it: Agent._cf_forwardToFacet (src/index.ts) and routeSubAgentRequest (src/sub-routing.ts).

Why it broke in the first place

#1443 fixed a WebSocket bug back in May and needed to set a header on upgrade requests. Doing that meant rebuilding the request from an explicit RequestInit, which doesn't inherit a body — so the body got re-attached by hand, the slow way, as a side effect. It streamed fine before that.

I verified this rather than assuming it. Reverting to the pre-#1443 form (new Request(rewritten, req)) makes all the new streaming tests pass, which confirms where the buffering came from. So this is a genuine regression, shipped in agents@0.12.1. In terms of behaviour this restores the streaming, but #1443's code stays the same. The RequestInit and Upgrade header handling are untouched and only the body attachment changes.

Worth noting why it mattered beyond raw memory: Agent.fetch returns before onRequest whenever the path matches /sub/{class}/{name}, so the unbounded read sat in front of application-level validation. An app that carefully bounded request bodies in onRequest still had an unbounded read ahead of it and no way to bound it. The cost was also per hop — a nested /sub/.../sub/... address re-materialised the same bytes at every level.

Result

Measured with wrangler dev --local against the reporter's repro (a handler that never reads the body, so growth is attributable to the forwarder). Two tarballs built from the same commit, differing only in this fix. Fresh worker per row, peak RSS summed across the workerd processes for a single POST:

Request body facet route, before facet route, after canonical route (control)
16 MB +75 MB +4 MB +2 MB
64 MB +268 MB +4 MB +2 MB
128 MB +546 MB +4 MB +2 MB

Flat now, no matter how big the upload. The before column reproduces the reporter's +71/+284/+556 MB; the after column matches the canonical control.

The one caveat

The upload is now connected end to end. If a sub-agent replies without reading the body, the rest of the upload gets cut off instead of being quietly swallowed. That's the correct behaviour and what you'd want — but it is a change, so anyone relying on the old "swallow it all" habit would notice.

Tests

New file src/tests/sub-agent-body-forwarding.test.ts (9 tests), covering both call sites:

  • dispatch before upload completes — the child replies while the request body is still open. Deadlocks against the old code.
  • prefix of an unfinished body — the child reads one chunk of a still-open body. This is the decisive one: it distinguishes real incremental streaming from "the runtime buffered internally but dispatched early".
  • integrity — a 2 MB body arrives byte-exact (SHA-256) over one hop, over two nested hops, and via routeSubAgentRequest.
  • GET/HEAD — unchanged.

The suite includes a canonical (non-facet) control that runs first. If the test harness itself couldn't stream a request body, every facet assertion would hang for unrelated reasons, so the control makes a failure interpretable. Fixtures are BodyProbeSubAgent (facet) and BodyProbeRootAgent (root control), sharing one handler.

The three streaming assertions fail against main with expected 'pending' to be 'settled' after 2.5s, and pass in ~25ms with this change.

Verification

  • pnpm run test:workers — 91 files / 1810 tests
  • full agents suite — 131 files / 2552 tests
  • @cloudflare/ai-chat 50/737, @cloudflare/voice 12/225, nx affected -t test 27/27
  • pnpm run check — sherif, export check, oxfmt, oxlint, typecheck (120 projects)

The existing WebSocket sub-agent tests matter here specifically, since this touches the function #1443 changed — they pass.

Not addressed

If an onBeforeSubAgent hook consumes the body and returns void, forwarding still throws (the stream is disturbed). That was already true with arrayBuffer(), just with a different message — orthogonal to this issue, so I left it alone.


Open in Devin Review

`Agent._cf_forwardToFacet` and `routeSubAgentRequest` both materialised
the whole forwarded body with `await req.arrayBuffer()` before handing
off to the child facet. The read was unbounded and ran in the parent
Durable Object's isolate, ahead of any application-level intake limit —
`Agent.fetch` returns before `onRequest` whenever the path matches
`/sub/{class}/{name}`, so an app could not bound it itself. Nesting
compounded it: every `/sub/` hop re-materialised the same bytes.

Pass `req.body` through as a stream at both call sites instead.

This restores the behaviour from before cloudflare#1443, which switched to an
explicit `RequestInit` so it could set a header on WebSocket upgrades
and re-attached the body with `arrayBuffer()` as a side effect. That
fix's `Upgrade` handling is unchanged.

Measured with `wrangler dev --local` against a handler that never reads
the body, peak RSS across the `workerd` processes for one POST:

  body    facet before   facet after   canonical (control)
  16 MB   +75 MB         +4 MB         +2 MB
  64 MB   +268 MB        +4 MB         +2 MB
  128 MB  +546 MB        +4 MB         +2 MB

Adds `sub-agent-body-forwarding.test.ts`, covering both call sites: the
child is dispatched and can read a prefix of a still-open body, and a
2 MB body arrives byte-exact over one hop and two nested hops. The
streaming assertions deadlock against the previous implementation.

Backpressure now reaches the client: a child that returns without
reading the body cancels the remainder of the upload, where the parent
previously drained it in full.

Closes cloudflare#2015
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a592038

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2051

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2051

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2051

create-think

npm i https://pkg.pr.new/create-think@2051

hono-agents

npm i https://pkg.pr.new/hono-agents@2051

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2051

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2051

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2051

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2051

commit: a592038

@cjol

cjol commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

#1443 fixed a WebSocket bug back in May and needed to set a header on upgrade requests. Doing that meant rebuilding the request from an explicit RequestInit, which doesn't inherit a body — so the body got re-attached by hand, the slow way, as a side effect. It streamed fine before that.

I verified this rather than assuming it: reverting to the pre-#1443 form (new Request(rewritten, req)) makes all the new streaming tests pass. So this is a genuine regression, shipped in agents@0.12.1, and this restores it. #1443's Upgrade header handling is untouched.

Could you explain a little more why 1443 needed to rebuild the request in this way, and why reverting to the previous behaviour does not cause the original WebSocket bug not to come back?

@cjol

cjol commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Do you think it would be appropriate to add the memory consumption benchmark as a regression test in CI somehow/

@AntoniTok

Copy link
Copy Markdown
Author

#1443 fixed a WebSocket bug back in May and needed to set a header on upgrade requests. Doing that meant rebuilding the request from an explicit RequestInit, which doesn't inherit a body — so the body got re-attached by hand, the slow way, as a side effect. It streamed fine before that.
I verified this rather than assuming it: reverting to the pre-#1443 form (new Request(rewritten, req)) makes all the new streaming tests pass. So this is a genuine regression, shipped in agents@0.12.1, and this restores it. #1443's Upgrade header handling is untouched.

Could you explain a little more why 1443 needed to rebuild the request in this way, and why reverting to the previous behaviour does not cause the original WebSocket bug not to come back?

In terms of behaviour this goes back to how it worked before #1443, meaning bodies stream again. But #1443's code stays the same. The RequestInit, the Upgrade check and the SUB_AGENT_OUTER_URL_HEADER line are all still there. The only change is body = await req.arrayBuffer() becoming body = req.body.

I only tried the literal pre-#1443 form (new Request(rewritten, req)) as a bisect step to find where the buffering came from. I'll make that clearer in the description.

#1443 rebuilt the request because It had to set x-cf-agents-subagent-url on upgrade requests so the parent knows which sub-agent path a socket belongs to. onConnect reads it back at index.ts:2582.

Incoming request headers are immutable in workerd, so setting one means copying the headers and building a fresh Request. The pathname is rewritten too, so it used a literal RequestInit, which doesn't carry the body over the way new Request(url, req) does.

The body was re-attached by hand and arrayBuffer() is what got written.

The tell is routeSubAgentRequest, which got the same rebuild but sets no header at all it just copies the shape.

So the WS fix stays intact. Upgrades never reach the changed line. Agent.fetch catches them at index.ts:7031 and serves them from the parent, before the _cf_forwardToFacet call at 7039. Upgrades are also GET with no body, so the guarded line is skipped anyway. Anyhow all of #1443's tests pass.

@AntoniTok

Copy link
Copy Markdown
Author

Do you think it would be appropriate to add the memory consumption benchmark as a regression test in CI somehow/

It could be worthwhile but it would have to be nightly and non-gating and it's not really what protects this.

I don't think workers can measure their own memory, so this can't be a test:workers test. which means we'd have to start a real wrangler dev, pushing 128 MB at it, and sampling RSS from outside which would be slow and noisy on shared runners, so the threshold would have to be loose.

And also the streaming tests already catch this regression on their own. "Child reads a prefix of a still-open body" can't pass against a buffering parent, because buffering holds the child back until the upload finishes. On main it's still pending after 2.5s. Here it's 25ms. No threshold to tune, and it's already in the gating job.

@cjol cjol left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Okay, the key information I was looking for is that the body buffering wasn't actually part of the fix for 1443. That's fine then. Regarding CI, I still think it might be valuable to monitor memory consumption under various scenarios, because the streaming tests cover this specific regression but a more general lens might catch other similar failures. But that doesn't need to block this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

forwardToFacet buffers the entire forwarded request body with no size limit

2 participants