diff --git a/netlify/functions/agent-proxy.js b/netlify/functions/agent-proxy.js index 62b7e64..f864b7e 100644 --- a/netlify/functions/agent-proxy.js +++ b/netlify/functions/agent-proxy.js @@ -140,18 +140,35 @@ Please follow your instructions to produce the funding outlook brief.` } // ----- Pipe agent activity to the client ----- - // The agent emits multiple event types. We surface: - // - agent.message text blocks (narration) - // - agent.tool_use for the `write` tool, whose `input.content` IS the - // final brief the agent produces (it writes it as a Markdown file in - // the sandbox instead of streaming it back) - // - lightweight status lines for other tool calls so the UI keeps moving + // Long stretches between agent.message events (model "thinking", + // multi-tool batches, etc.) can silence the response stream long enough + // that Netlify's edge proxy drops the connection. To keep bytes flowing + // we (a) forward extra event types as concise status lines and (b) emit + // a periodic heartbeat space when the upstream goes quiet. const encoder = new TextEncoder() const seenEventIds = new Set() const writtenFiles = new Set() // dedupe writes by path + const HEARTBEAT_MS = 10_000 + const stream = new ReadableStream({ async start(controller) { - const send = (s) => controller.enqueue(encoder.encode(s)) + let lastSendAt = Date.now() + const send = (s) => { + controller.enqueue(encoder.encode(s)) + lastSendAt = Date.now() + } + // Zero-width space — invisible in rendered Markdown but counts as a + // byte on the wire, which is enough to defeat proxy idle-timeouts. + const heartbeat = setInterval(() => { + if (Date.now() - lastSendAt >= HEARTBEAT_MS) { + try { + controller.enqueue(encoder.encode('\u200B')) + lastSendAt = Date.now() + } catch { + /* controller may have closed */ + } + } + }, HEARTBEAT_MS / 2) try { for await (const event of upstream) { @@ -170,6 +187,11 @@ Please follow your instructions to produce the funding outlook brief.` break } + case 'agent.thinking': { + send(`\n\n_💭 thinking…_\n\n`) + break + } + case 'agent.tool_use': { // The write tool carries the actual brief in input.content. if ( @@ -194,6 +216,20 @@ Please follow your instructions to produce the funding outlook brief.` break } + case 'agent.tool_result': + case 'agent.mcp_tool_result': { + if (event.is_error) { + const msg = + (Array.isArray(event.content) && + event.content[0]?.text) || + 'tool error' + send(`\n\n_⚠️ tool error: ${String(msg).slice(0, 200)}_\n\n`) + } else { + send(`\n\n_✓ result_\n\n`) + } + break + } + case 'session.error': { const msg = event.error?.message || 'unknown session error' send(`\n\n[session error: ${msg}]`) @@ -211,8 +247,10 @@ Please follow your instructions to produce the funding outlook brief.` break } } + clearInterval(heartbeat) controller.close() } catch (err) { + clearInterval(heartbeat) send(`\n\n[stream error: ${err.message || err}]`) controller.close() }