UNPKG

@xynehq/jaf

Version:

Juspay Agent Framework - A purely functional agent framework with immutable state and composable tools

859 lines 41.3 kB
import Fastify from 'fastify'; import cors from '@fastify/cors'; import { chatRequestSchema } from './types.js'; import { run, runStream } from '../core/engine.js'; import { createRunId, createTraceId } from '../core/types.js'; import { v4 as uuidv4 } from 'uuid'; import { safeConsole } from '../utils/logger.js'; // Helper: stable stringify to create deterministic signatures function stableStringify(value) { const seen = new WeakSet(); const helper = (v) => { if (v === null || typeof v !== 'object') return v; if (seen.has(v)) return '[Circular]'; seen.add(v); if (Array.isArray(v)) return v.map(helper); const keys = Object.keys(v).sort(); const obj = {}; for (const k of keys) obj[k] = helper(v[k]); return obj; }; try { return JSON.stringify(helper(value)); } catch { try { return JSON.stringify(value); } catch { return String(value); } } } function tryParseJSON(str) { try { return JSON.parse(str); } catch { return str; } } function computeToolCallSignature(tc) { return `${tc.function.name}:${stableStringify(tryParseJSON(tc.function.arguments))}`; } // Shared JSON Schema definitions to avoid duplication const messageContentPartSchema = { type: 'object', properties: { type: { type: 'string', enum: ['text', 'image_url'] }, text: { type: 'string' }, image_url: { type: 'object', properties: { url: { type: 'string' }, detail: { type: 'string', enum: ['low', 'high', 'auto'] } }, required: ['url'] } }, required: ['type'] }; const attachmentSchema = { type: 'object', properties: { kind: { type: 'string', enum: ['image', 'audio', 'video', 'document', 'file'] }, mimeType: { type: 'string' }, name: { type: 'string' }, url: { type: 'string' }, data: { type: 'string' }, format: { type: 'string' } } }; const httpMessageSchema = { type: 'object', properties: { role: { type: 'string', enum: ['user', 'assistant', 'system'] }, content: { oneOf: [ { type: 'string' }, { type: 'array', items: messageContentPartSchema } ] }, attachments: { type: 'array', items: attachmentSchema } }, required: ['role', 'content'] }; const chatRequestBodySchema = { type: 'object', properties: { messages: { type: 'array', items: httpMessageSchema }, agentName: { type: 'string' }, context: {}, maxTurns: { type: 'number' }, stream: { type: 'boolean', default: false }, conversationId: { type: 'string' }, memory: { type: 'object', properties: { autoStore: { type: 'boolean', default: true }, maxMessages: { type: 'number' }, compressionThreshold: { type: 'number' }, storeOnCompletion: { type: 'boolean' } } } }, required: ['messages', 'agentName'] }; /** * Create and configure a JAF server instance * Functional implementation following JAF principles */ export function createJAFServer(config) { // BACKWARDS COMPATIBILITY: Handle legacy agentRegistry at top level if (config.agentRegistry && !config.runConfig.agentRegistry) { safeConsole.warn('[JAF:SERVER] DEPRECATED: agentRegistry should be provided in runConfig.agentRegistry. Using legacy configuration for backwards compatibility.'); config.runConfig.agentRegistry = config.agentRegistry; } // Ensure agentRegistry exists if (!config.runConfig.agentRegistry) { throw new Error('agentRegistry must be provided either in config.agentRegistry (deprecated) or config.runConfig.agentRegistry'); } const startTime = Date.now(); // SSE subscribers for approval-related events const approvalSubscribers = new Set(); const sseSend = (res, event, data) => { try { res.write(`event: ${event}\n`); res.write(`data: ${JSON.stringify(data)}\n\n`); } catch { /* ignore */ } }; const broadcastApprovalRequired = (payload) => { for (const client of approvalSubscribers) { if (client.filterConversationId && client.filterConversationId !== payload.conversationId) continue; sseSend(client.res, 'approval_required', { ...payload, timestamp: payload.timestamp || new Date().toISOString() }); } }; const broadcastApprovalDecision = (payload) => { for (const client of approvalSubscribers) { if (client.filterConversationId && client.filterConversationId !== payload.conversationId) continue; sseSend(client.res, 'approval_decision', { ...payload, timestamp: payload.timestamp || new Date().toISOString() }); } }; const app = Fastify({ logger: true, bodyLimit: config.maxBodySize ?? 50 * 1024 * 1024, // Configurable body size limit ajv: { customOptions: { removeAdditional: false, useDefaults: true, coerceTypes: true } } }); const setupMiddleware = async () => { if (config.cors !== false) { await app.register(cors, { origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] }); } // Add request/response validation app.addHook('preHandler', async (request, reply) => { // Add CORS headers for preflight requests if (request.method === 'OPTIONS') { void reply.header('Access-Control-Allow-Origin', '*'); void reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); void reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); return reply.code(200).send(); } }); }; const setupRoutes = () => { // Health check endpoint app.get('/health', async (request, reply) => { const response = { status: 'healthy', timestamp: new Date().toISOString(), version: '2.0.0', uptime: Date.now() - startTime }; return reply.code(200).send(response); }); // List available agents app.get('/agents', async (request, reply) => { try { const agents = Array.from(config.runConfig.agentRegistry.entries()).map(([name, agent]) => ({ name, description: typeof agent.instructions === 'function' ? 'Agent description' // Safe fallback since we don't have context : agent.instructions, tools: agent.tools?.map(tool => tool.schema.name) || [] })); const response = { success: true, data: { agents } }; return reply.code(200).send(response); } catch (error) { const response = { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; return reply.code(500).send(response); } }); // Chat completion endpoint app.post('/chat', { schema: { body: chatRequestBodySchema } }, async (request, reply) => { const requestStartTime = Date.now(); try { // Validate request body const validatedRequest = chatRequestSchema.parse(request.body); // Check if agent exists if (!config.runConfig.agentRegistry.has(validatedRequest.agentName)) { const response = { success: false, error: `Agent '${validatedRequest.agentName}' not found. Available agents: ${Array.from(config.runConfig.agentRegistry.keys()).join(', ')}` }; return reply.code(404).send(response); } // Check message length limit (configurable via JAF_MESSAGE_LIMIT env variable) const messageLimit = process.env.JAF_MESSAGE_LIMIT ? parseInt(process.env.JAF_MESSAGE_LIMIT, 10) : undefined; if (messageLimit && messageLimit > 0) { for (const msg of validatedRequest.messages) { if (msg.content && msg.content.length > messageLimit) { const response = { success: false, error: `Message content exceeds the maximum allowed length of ${messageLimit} characters. Your message has ${msg.content.length} characters.` }; return reply.code(400).send(response); } } } // Convert HTTP messages to JAF messages const jafMessages = validatedRequest.messages.map(msg => ({ role: msg.role === 'system' ? 'user' : msg.role, content: msg.content, attachments: msg.attachments })); // Create initial state const runId = createRunId(uuidv4()); const traceId = createTraceId(uuidv4()); // Generate conversationId if not provided const conversationId = validatedRequest.conversationId || `conv-${uuidv4()}`; // Handle approval message(s) if present const initialApprovals = new Map(); const initialStateMessages = jafMessages; const approvalsList = validatedRequest.approvals ?? []; const persistApproval = async (convId, appr) => { if (!config.defaultMemoryProvider) return; const provider = config.defaultMemoryProvider; // Keyed by previous run/session id + toolCallId for uniqueness const approvalKey = `${appr.sessionId}:${appr.toolCallId}`; const baseEntry = { approved: appr.approved, status: appr.approved ? 'approved' : 'rejected', additionalContext: appr.additionalContext, sessionId: appr.sessionId, toolCallId: appr.toolCallId, }; try { const existing = await provider.getConversation(convId); if (existing.success && existing.data) { // Try to enrich entry with tool name and signature for robust matching try { const msgs = existing.data.messages; for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i]; if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { const match = m.tool_calls.find(tc => tc.id === appr.toolCallId); if (match) { baseEntry.toolName = match.function.name; baseEntry.signature = computeToolCallSignature(match); break; } } } } catch { /* best-effort */ } const existingApprovals = (existing.data.metadata?.toolApprovals ?? {}); const prev = existingApprovals[approvalKey]; // Merge additionalContext shallowly and avoid regressions const mergedAdditional = { ...(prev?.additionalContext || {}), ...(baseEntry.additionalContext || {}), }; const nextEntry = { ...prev, ...baseEntry, additionalContext: mergedAdditional, // Preserve earliest timestamp if no effective change; else update timestamp: prev && (prev.status === baseEntry.status && stableStringify(prev.additionalContext) === stableStringify(mergedAdditional)) ? prev.timestamp : new Date().toISOString(), }; const noChange = prev && prev.status === nextEntry.status && stableStringify(prev.additionalContext) === stableStringify(nextEntry.additionalContext) && (prev.toolName ?? null) === (nextEntry.toolName ?? null) && (prev.signature ?? null) === (nextEntry.signature ?? null); if (!noChange) { const mergedApprovals = { ...existingApprovals, [approvalKey]: nextEntry }; await provider.appendMessages(convId, [], { toolApprovals: mergedApprovals, traceId }); } } else if (existing.success && !existing.data) { // Create conversation shell with just metadata if not present const entry = { ...baseEntry, timestamp: new Date().toISOString() }; await provider.storeMessages(convId, [], { toolApprovals: { [approvalKey]: entry }, traceId }); } // If provider call failed, we intentionally do not throw; run will proceed } catch { // Ignore persistence errors here to avoid breaking the request path } // Broadcast decision to approvals SSE try { broadcastApprovalDecision({ conversationId: convId, sessionId: appr.sessionId, toolCallId: appr.toolCallId, status: appr.approved ? 'approved' : 'rejected', additionalContext: appr.additionalContext }); } catch { /* ignore */ } }; if (approvalsList.length > 0) { for (const approval of approvalsList) { if (approval.sessionId) { initialApprovals.set(approval.toolCallId, { status: approval.approved ? 'approved' : 'rejected', approved: approval.approved, additionalContext: approval.additionalContext }); } await persistApproval(conversationId, approval); } } // Seed approvals from persisted conversation metadata (toolApprovals) // This allows previously stored decisions to be applied even if the client // does not resend them in the current request. if (config.defaultMemoryProvider) { try { const conv = await config.defaultMemoryProvider.getConversation(conversationId); if (conv.success && conv.data) { const toolApprovals = (conv.data.metadata?.toolApprovals ?? null); if (toolApprovals) { // Collect candidate tool_call ids and signatures from latest assistant message with tool_calls const allMessages = conv.data.messages; const assistantWithTools = [...allMessages].reverse().find(m => m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0); const candidateIds = new Set(assistantWithTools?.tool_calls?.map(tc => tc.id) ?? []); const candidateSignatures = new Map(); if (assistantWithTools?.tool_calls) { for (const tc of assistantWithTools.tool_calls) { try { candidateSignatures.set(tc.id, computeToolCallSignature(tc)); } catch { /* ignore */ } } } // Prefer explicit approval from request; otherwise seed from persisted entries const existingKeys = new Set(Array.from(initialApprovals.keys())); for (const [, entry] of Object.entries(toolApprovals)) { const persistedToolCallId = (entry && entry.toolCallId); const persistedSignature = (entry && entry.signature); // Try direct id match first let targetId = undefined; if (persistedToolCallId && candidateIds.has(persistedToolCallId)) { targetId = persistedToolCallId; } else if (persistedSignature) { // Signature match fallback for (const [cid, sig] of candidateSignatures.entries()) { if (sig === persistedSignature) { targetId = cid; break; } } } if (!targetId) continue; if (existingKeys.has(targetId)) continue; const status = entry.status ?? (entry.approved === true ? 'approved' : (entry.additionalContext?.status === 'pending' ? 'pending' : 'rejected')); initialApprovals.set(targetId, { status, approved: !!entry.approved, additionalContext: entry.additionalContext }); } } } } catch (e) { app.log.warn({ err: e }, 'Failed to seed approvals from metadata'); } } const initialState = { runId, traceId, messages: initialStateMessages, currentAgentName: validatedRequest.agentName, context: validatedRequest.context || {}, turnCount: 0, approvals: initialApprovals, }; // Create run config with memory configuration const runConfig = { ...config.runConfig, maxTurns: validatedRequest.maxTurns || config.runConfig.maxTurns || 10, conversationId, memory: config.defaultMemoryProvider ? { provider: config.defaultMemoryProvider, autoStore: validatedRequest.memory?.autoStore ?? config.runConfig.memory?.autoStore ?? true, maxMessages: validatedRequest.memory?.maxMessages ?? config.runConfig.memory?.maxMessages, compressionThreshold: validatedRequest.memory?.compressionThreshold ?? config.runConfig.memory?.compressionThreshold, storeOnCompletion: validatedRequest.memory?.storeOnCompletion ?? config.runConfig.memory?.storeOnCompletion } : undefined }; // Handle streaming vs non-streaming if (validatedRequest.stream) { // SSE headers reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' }); const send = (event, data) => { try { reply.raw.write(`event: ${event}\n`); reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); } catch (e) { app.log.warn({ err: e }, 'SSE write failed'); } }; // Send initial metadata event send('stream_start', { runId, traceId, conversationId, agent: validatedRequest.agentName }); let clientClosed = false; request.raw.on('close', () => { clientClosed = true; try { reply.raw.end(); } catch { /* ignore */ } }); // Use the core streaming generator to forward events try { for await (const event of runStream(initialState, runConfig)) { if (clientClosed) break; send(event.type, event); // If run ends, we close the stream if (event.type === 'run_end') { // Broadcast approval_required to approvals SSE if interrupted try { const outcome = event.data?.outcome; if (outcome && outcome.status === 'interrupted') { const interruptions = outcome.interruptions; for (const intr of interruptions) { if (intr.type === 'tool_approval') { const toolCall = intr.toolCall; const args = tryParseJSON(toolCall.function.arguments); broadcastApprovalRequired({ conversationId, sessionId: intr.sessionId || runId, toolCallId: toolCall.id, toolName: toolCall.function.name, args, signature: computeToolCallSignature(toolCall) }); } } } } catch { /* ignore */ } break; } } } catch (streamErr) { send('error', { message: streamErr instanceof Error ? streamErr.message : String(streamErr) }); } finally { if (!clientClosed) { send('stream_end', { ended: true }); try { reply.raw.end(); } catch { /* ignore */ } } } // Fastify requires a return, but we've handled the response // Returning undefined indicates the response has been sent // eslint-disable-next-line @typescript-eslint/no-unsafe-return return undefined; } // Run the agent const result = await run(initialState, runConfig); const executionTime = Date.now() - requestStartTime; // Convert JAF messages back to HTTP messages, including tool interactions const httpMessages = result.finalState.messages.map(msg => { if (msg.role === 'tool') { // Include tool messages with special formatting return { role: 'tool', content: msg.content, tool_call_id: msg.tool_call_id }; } else if (msg.role === 'assistant' && msg.tool_calls) { // Include assistant messages with tool calls return { role: msg.role, content: msg.content || '', tool_calls: msg.tool_calls.map(tc => ({ id: tc.id, type: tc.type, function: { name: tc.function.name, arguments: tc.function.arguments } })) }; } else { // Regular user/assistant messages return { role: msg.role, content: msg.content, ...(msg.attachments ? { attachments: msg.attachments } : {}) }; } }); const response = { success: true, data: { runId: result.finalState.runId, traceId: result.finalState.traceId, conversationId: conversationId, messages: httpMessages, outcome: { status: result.outcome.status, output: result.outcome.status === 'completed' ? String(result.outcome.output) : undefined, error: result.outcome.status === 'error' ? result.outcome.error : undefined, interruptions: result.outcome.status === 'interrupted' ? result.outcome.interruptions.map(interruption => { if (interruption.type === 'tool_approval') { return { type: interruption.type, toolCall: interruption.toolCall, sessionId: interruption.sessionId || result.finalState.runId }; } else { // clarification_required return { type: interruption.type, clarificationId: interruption.clarificationId, question: interruption.question, options: [...interruption.options], context: interruption.context }; } }) : undefined }, turnCount: result.finalState.turnCount, executionTimeMs: executionTime } }; // Broadcast approval_required to approvals SSE if interrupted (non-streaming) if (result.outcome.status === 'interrupted') { try { for (const intr of result.outcome.interruptions) { if (intr.type === 'tool_approval') { const toolCall = intr.toolCall; const args = tryParseJSON(toolCall.function.arguments); broadcastApprovalRequired({ conversationId, sessionId: intr.sessionId || runId, toolCallId: toolCall.id, toolName: toolCall.function.name, args, signature: computeToolCallSignature(toolCall) }); } } } catch { /* ignore */ } } return reply.code(200).send(response); } catch (error) { const executionTime = Date.now() - requestStartTime; const response = { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; request.log.error({ error: error, executionTimeMs: executionTime, requestBody: request.body }, 'Chat endpoint error'); return reply.code(500).send(response); } }); // Agent-specific chat endpoint (convenience) app.post('/agents/:agentName/chat', { schema: { params: { type: 'object', properties: { agentName: { type: 'string' } }, required: ['agentName'] }, body: chatRequestBodySchema } }, async (request, reply) => { // Delegate to main chat endpoint const chatRequest = { ...request.body, agentName: request.params.agentName }; // Create a new request object for the main handler const newRequest = { ...request, body: chatRequest }; // Call the main chat handler return app.inject({ method: 'POST', url: '/chat', payload: chatRequest }).then((response) => JSON.parse(response.body)); }); // Memory management endpoints app.get('/conversations/:conversationId', async (request, reply) => { if (!config.defaultMemoryProvider) { return reply.code(503).send({ success: false, error: 'Memory provider not configured' }); } const result = await config.defaultMemoryProvider.getConversation(request.params.conversationId); if (!result.success) { return reply.code(500).send({ success: false, error: result.error.message }); } return reply.code(200).send({ success: true, data: result.data }); }); app.delete('/conversations/:conversationId', async (request, reply) => { if (!config.defaultMemoryProvider) { return reply.code(503).send({ success: false, error: 'Memory provider not configured' }); } const result = await config.defaultMemoryProvider.deleteConversation(request.params.conversationId); if (!result.success) { return reply.code(500).send({ success: false, error: result.error.message }); } return reply.code(200).send({ success: true, data: { deleted: result.data } }); }); app.get('/memory/health', async (request, reply) => { if (!config.defaultMemoryProvider) { return reply.code(503).send({ success: false, error: 'Memory provider not configured' }); } const result = await config.defaultMemoryProvider.healthCheck(); if (!result.success) { return reply.code(500).send({ success: false, error: 'Health check failed', details: result.error }); } return reply.code(200).send({ success: true, data: result.data }); }); // List pending approvals for a conversation (best-effort from stored history) app.get('/approvals/pending', async (request, reply) => { if (!config.defaultMemoryProvider) { return reply.code(503).send({ success: false, error: 'Memory provider not configured' }); } const conversationId = request.query.conversationId; if (!conversationId) { return reply.code(400).send({ success: false, error: 'conversationId is required' }); } const conv = await config.defaultMemoryProvider.getConversation(conversationId); if (!conv.success) { return reply.code(500).send({ success: false, error: conv.error.message }); } if (!conv.data) { return reply.code(200).send({ success: true, data: { pending: [] } }); } const messages = conv.data.messages; const approvalsMeta = (conv.data.metadata?.toolApprovals ?? {}); // Find most recent assistant message with tool calls const assistantIndex = [...messages].reverse().findIndex(m => m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0); if (assistantIndex === -1) { return reply.code(200).send({ success: true, data: { pending: [] } }); } const realIndex = messages.length - 1 - assistantIndex; const assistantMsg = messages[realIndex]; // Which tool_calls have already produced results? const toolIds = new Set((assistantMsg.tool_calls ?? []).map(tc => tc.id)); const executed = new Set(); for (let j = realIndex + 1; j < messages.length; j++) { const m = messages[j]; if (m.role === 'tool' && m.tool_call_id && toolIds.has(m.tool_call_id)) { executed.add(m.tool_call_id); } } const pending = []; const entries = Object.values(approvalsMeta); for (const tc of assistantMsg.tool_calls ?? []) { if (executed.has(tc.id)) continue; // already resolved const match = entries.find(e => e && e.toolCallId === tc.id); const status = match?.status ?? (match?.approved === true ? 'approved' : (match?.additionalContext?.status === 'pending' ? 'pending' : undefined)); const decisionPending = !match || status === 'pending'; if (!decisionPending) continue; pending.push({ conversationId, toolCallId: tc.id, toolName: tc.function.name, args: tryParseJSON(tc.function.arguments), signature: computeToolCallSignature(tc), status: 'pending', sessionId: conv.data.metadata?.runId, }); } return reply.code(200).send({ success: true, data: { pending } }); }); }; const start = async () => { try { await setupMiddleware(); setupRoutes(); const host = config.host || 'localhost'; const port = config.port || 3000; // Approvals SSE stream app.get('/approvals/stream', async (request, reply) => { // SSE headers reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' }); const filterConversationId = (request.query && request.query.conversationId) || undefined; const client = { res: reply.raw, filterConversationId }; approvalSubscribers.add(client); // Initial greeting sseSend(reply.raw, 'stream_start', { conversationId: filterConversationId || null }); // Heartbeat const interval = setInterval(() => { try { sseSend(reply.raw, 'ping', { ts: Date.now() }); } catch { /* ignore */ } }, 15000); // Cleanup on close request.raw.on('close', () => { clearInterval(interval); approvalSubscribers.delete(client); try { reply.raw.end(); } catch { /* ignore */ } }); // Fastify route handled via raw stream // eslint-disable-next-line @typescript-eslint/no-unsafe-return return undefined; }); safeConsole.log(`🔧 Starting Fastify server on ${host}:${port}...`); await app.listen({ port, host }); safeConsole.log(`🔧 Fastify server started successfully`); safeConsole.log(`🚀 JAF Server running on http://${host}:${port}`); safeConsole.log(`📋 Available agents: ${Array.from(config.runConfig.agentRegistry.keys()).join(', ')}`); safeConsole.log(`🏥 Health check: http://${host}:${port}/health`); safeConsole.log(`🤖 Agents list: http://${host}:${port}/agents`); safeConsole.log(`💬 Chat endpoint: http://${host}:${port}/chat`); if (config.defaultMemoryProvider) { safeConsole.log(`🧠 Memory provider: Configured`); safeConsole.log(`📊 Memory health: http://${host}:${port}/memory/health`); safeConsole.log(`💾 Conversation management: http://${host}:${port}/conversations/:id`); } else { safeConsole.log(`🧠 Memory provider: Not configured (conversations will not persist)`); } } catch (error) { app.log.error(error); process.exit(1); } }; const stop = async () => { await app.close(); // Close memory provider if configured if (config.defaultMemoryProvider) { await config.defaultMemoryProvider.close(); } }; return { app, start, stop }; } //# sourceMappingURL=server.js.map