261 lines
8.0 KiB
JavaScript
261 lines
8.0 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 -----
|
|
// 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
|
|
const encoder = new TextEncoder()
|
|
const seenEventIds = new Set()
|
|
const writtenFiles = new Set() // dedupe writes by path
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const send = (s) => controller.enqueue(encoder.encode(s))
|
|
|
|
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.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 '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
|
|
}
|
|
}
|
|
controller.close()
|
|
} catch (err) {
|
|
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',
|
|
}
|