Files
pmc-funder-tool/netlify/functions/agent-proxy.js
T
2026-05-12 22:39:36 -04:00

299 lines
9.2 KiB
JavaScript

import Anthropic from '@anthropic-ai/sdk'
/**
* Netlify Functions v2 handler.
*
* Proxies a request from the React frontend to a Claude Console-defined
* Managed Agent via the /v1/sessions endpoints. The agent's model, system
* prompt, tools, MCP servers, and skills are configured in the Console and
* referenced here by ID. We open the SSE event stream first, then send a
* `user.message` event, and pipe each `agent.message` text block to the
* client through a ReadableStream so we never hit Netlify's 26-second
* synchronous execution limit.
*/
// All Managed Agents endpoints require this beta header.
const MANAGED_AGENTS_BETA = 'managed-agents-2026-04-01'
// ---------------------------------------------------------------------------
// Anthropic client (reused across warm Lambda invocations)
// ---------------------------------------------------------------------------
let _client
function getClient() {
if (_client) return _client
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('Server is missing ANTHROPIC_API_KEY')
}
_client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
defaultHeaders: { 'anthropic-beta': MANAGED_AGENTS_BETA },
})
return _client
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
export default async (req /*, context */) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
let payload
try {
payload = await req.json()
} catch {
return new Response('Invalid JSON body', { status: 400 })
}
const {
stationName = '',
stationLocation = '',
stationWebsite = '',
} = payload || {}
// Server-side URL validation (mirrors the client-side regex).
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
if (!URL_REGEX.test(stationWebsite)) {
return new Response('Invalid station website URL', { status: 400 })
}
// Read env vars at request time (Netlify makes them available per-invocation).
const AGENT_ID = process.env.ANTHROPIC_AGENT_ID
const ENVIRONMENT_ID = process.env.ANTHROPIC_ENVIRONMENT_ID
const VAULT_IDS = (process.env.ANTHROPIC_VAULT_IDS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const missing = []
if (!AGENT_ID) missing.push('ANTHROPIC_AGENT_ID')
if (!ENVIRONMENT_ID) missing.push('ANTHROPIC_ENVIRONMENT_ID')
if (missing.length) {
return new Response(`Server is missing env var(s): ${missing.join(', ')}`, {
status: 500,
})
}
let client
try {
client = getClient()
} catch (err) {
return new Response(
`Failed to initialize agent: ${err.message || err}`,
{ status: 500 },
)
}
const userMessage = `Here is a new public media station intake:
- Station Name: ${stationName}
- Station Location: ${stationLocation}
- Station Website: ${stationWebsite}
Please follow your instructions to produce the funding outlook brief.`
// ----- Create the session -----
let session
try {
const sessionParams = {
agent: AGENT_ID,
environment_id: ENVIRONMENT_ID,
}
if (VAULT_IDS.length) sessionParams.vault_ids = VAULT_IDS
session = await client.beta.sessions.create(sessionParams)
} catch (err) {
return new Response(
`Failed to create session: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Open the event stream BEFORE sending the user message -----
// (Stream-first ensures we don't miss any events the agent emits.)
let upstream
try {
upstream = await client.beta.sessions.events.stream(session.id)
} catch (err) {
return new Response(
`Failed to open session event stream: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Send the kickoff user.message event -----
try {
await client.beta.sessions.events.send(session.id, {
events: [
{
type: 'user.message',
content: [{ type: 'text', text: userMessage }],
},
],
})
} catch (err) {
return new Response(
`Failed to send user message: ${err.message || err}`,
{ status: 500 },
)
}
// ----- Pipe agent activity to the client -----
// 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) {
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) {
if (event.id && !seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
switch (event.type) {
case 'agent.message': {
if (Array.isArray(event.content)) {
for (const block of event.content) {
if (block?.type === 'text' && block.text) {
send(block.text + '\n\n')
}
}
}
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 (
event.name === 'write' &&
typeof event.input?.content === 'string'
) {
const path = event.input.file_path || event.input.path || ''
if (!writtenFiles.has(path)) {
writtenFiles.add(path)
send('\n\n---\n\n' + event.input.content + '\n\n')
}
} else {
const label = describeToolUse(event)
if (label) send(`\n\n_${label}_\n\n`)
}
break
}
case 'agent.mcp_tool_use': {
const label = describeToolUse(event)
if (label) send(`\n\n_${label}_\n\n`)
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}]`)
break
}
}
}
// Terminal-state gate.
if (event.type === 'session.status_terminated') break
if (
event.type === 'session.status_idle' &&
event.stop_reason?.type !== 'requires_action'
) {
break
}
}
clearInterval(heartbeat)
controller.close()
} catch (err) {
clearInterval(heartbeat)
send(`\n\n[stream error: ${err.message || err}]`)
controller.close()
}
},
cancel() {
// If the client disconnects, abort the upstream stream.
try {
upstream.controller?.abort?.()
} catch {
/* ignore */
}
},
})
function describeToolUse(event) {
const name = event.name || 'tool'
const input = event.input || {}
if (name === 'web_search' && input.query) return `🔍 Searching: ${input.query}`
if (name === 'web_fetch' && input.url) return `🌐 Fetching: ${input.url}`
if (event.type === 'agent.mcp_tool_use') {
const args = Object.entries(input)
.filter(([k]) => k !== 'limit')
.slice(0, 3)
.map(([k, v]) =>
typeof v === 'object' ? `${k}=…` : `${k}=${String(v).slice(0, 40)}`,
)
.join(', ')
return `🛠️ ${name}${args ? ` (${args})` : ''}`
}
return `🛠️ ${name}`
}
return new Response(stream, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'X-Accel-Buffering': 'no',
},
})
}
export const config = {
path: '/.netlify/functions/agent-proxy',
}