feat(agent-proxy): segment streaming responses at ~54s to bypass Netlify Edge's ~60s response cap

Netlify Edge Functions empirically cap a single streaming response at
~60s wall-clock, regardless of activity. Confirmed against Netlify's
own canonical SSE example (edge-functions-examples.netlify.app/sse)
which also cuts at +60.1s. The Anthropic Managed Agent session is
fine for several minutes; the cap is per-HTTP-response.

This commit splits a long brief across multiple HTTP responses, while
keeping the UX of one continuous stream:

netlify/edge-functions/agent-proxy.ts
- Accept either a new-session payload {stationName, stationLocation,
  stationWebsite} or a resume payload {sessionId, lastEventId,
  startedAt}.
- On resume, skip session creation + user.message send. Just reopen
  the live SSE stream and backfill via
  GET /v1/sessions/{id}/events?after_id=lastEventId (deduped by
  event.id), then keep tailing.
- Single AbortController per segment. A 54s timer aborts the upstream,
  the for-await loops exit, and we write one final NDJSON line:
  {type:'segment_end', sessionId, lastEventId, startedAt}.
- The 20-min OVERALL_BUDGET_MS is enforced via Date.now() - startedAt
  so it spans across all segments.
- Refactor main loop so every iteration is openStream + backfill +
  tail. Cleaner than the previous initial-stream + reconnect-only-on-
  drop pattern.

src/App.jsx
- readStream() now returns a {sessionId, lastEventId, startedAt}
  payload if it saw a segment_end, or null if the stream ended
  cleanly.
- handleSubmit() loops, reopening /api/agent-proxy with the resume
  payload until readStream returns null. Spinner/status state stays
  on across segments so the UI shows one continuous stream.

README.md
- Document the segmented-streaming protocol and why it exists.

Co-Authored-By: alex <alex@semipublic.co>
This commit is contained in:
Devin AI
2026-05-13 13:00:54 +00:00
parent d1c5be112e
commit 8e44de5271
3 changed files with 491 additions and 297 deletions
+72 -35
View File
@@ -48,33 +48,50 @@ export default function App() {
return Object.keys(next).length === 0
}
const handleEvent = (msg) => {
switch (msg?.type) {
case 'text':
if (typeof msg.text === 'string' && msg.text) {
setResult((prev) => prev + msg.text)
}
break
case 'status':
setStatus(msg)
break
case 'error':
case 'session_error':
setStreamError(msg.message || 'streaming error')
break
case 'done':
case 'heartbeat':
default:
break
}
}
// Parse the NDJSON stream from /agent-proxy: one JSON object per line.
// Parse the NDJSON stream from /api/agent-proxy: one JSON object per
// line. Returns a resume payload `{sessionId, lastEventId, startedAt}`
// if the server emitted a `segment_end` (Netlify Edge caps a single
// streaming response at ~60 s, so longer briefs span multiple
// segments). Returns null when the stream ends cleanly (`done`,
// `error`, or upstream EOF without a segment_end).
const readStream = async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let firstByte = true
let segmentEnd = null
let fatalError = null
const handleEvent = (msg) => {
switch (msg?.type) {
case 'text':
if (typeof msg.text === 'string' && msg.text) {
setResult((prev) => prev + msg.text)
}
break
case 'status':
setStatus(msg)
break
case 'segment_end':
if (typeof msg.sessionId === 'string' && msg.sessionId) {
segmentEnd = {
sessionId: msg.sessionId,
lastEventId: msg.lastEventId,
startedAt: msg.startedAt,
}
}
break
case 'error':
case 'session_error':
fatalError = msg.message || 'streaming error'
setStreamError(fatalError)
break
case 'done':
case 'heartbeat':
default:
break
}
}
const flushLines = () => {
let nl
@@ -112,8 +129,10 @@ export default function App() {
/* ignore */
}
}
setIsStreaming(false)
setStatus(null)
// Only return a resume payload if we got a clean segment_end AND
// no fatal error fired in the same segment.
return fatalError ? null : segmentEnd
}
const handleSubmit = async (e) => {
@@ -128,19 +147,37 @@ export default function App() {
setIsStreaming(false)
try {
const response = await fetch('/api/agent-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
let resume = null
// Loop across segments. Each iteration is one HTTP request;
// the server closes it at ~54 s and the next iteration picks
// up from `lastEventId` so the user sees one continuous brief.
// Hard cap on iterations as a belt-and-braces guard against a
// runaway segment loop.
for (let i = 0; i < 40; i++) {
const body = resume
? {
sessionId: resume.sessionId,
lastEventId: resume.lastEventId,
startedAt: resume.startedAt,
}
: form
if (!response.ok || !response.body) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`,
)
const response = await fetch('/api/agent-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok || !response.body) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`,
)
}
const next = await readStream(response)
if (!next) break
resume = next
}
await readStream(response)
} catch (err) {
console.error(err)
setStreamError(err.message || 'Something went wrong while streaming.')