diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000000..a2795e4d076a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "proto/protobuf"] + path = proto/protobuf + url = https://github.com/protocolbuffers/protobuf.git +[submodule "proto/googleapis"] + path = proto/googleapis + url = https://github.com/googleapis/googleapis.git diff --git a/Dockerfile.multi b/Dockerfile.multi index 8013a3c65911..0d6c4ef577bf 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -72,6 +72,7 @@ WORKDIR /app RUN npm ci --omit=dev COPY api ./api COPY config ./config +COPY proto ./proto COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist diff --git a/api/app/clients/A2AClient.js b/api/app/clients/A2AClient.js new file mode 100644 index 000000000000..608b6288f33f --- /dev/null +++ b/api/app/clients/A2AClient.js @@ -0,0 +1,577 @@ +const { v4: uuidv4 } = require('uuid'); +const grpc = require('@grpc/grpc-js'); +const protoLoader = require('@grpc/proto-loader'); + +/** + * A2A Client for communicating with external A2A protocol agents + * This is separate from LibreChat's internal agent system + */ +class A2AClient { + /** + * @param {import('../../server/types/a2a').A2AExternalAgent} agentConfig + */ + constructor(agentConfig) { + this.agentConfig = agentConfig; + this.timeout = agentConfig.timeout || 30000; + this.maxRetries = agentConfig.maxRetries || 3; + this.activeRequests = new Map(); + } + + /** + * Send a message to the A2A agent + * @param {string} message - The message to send + * @param {string} [contextId] - Optional context ID for conversation continuity + * @param {boolean} [taskBased=false] - Whether to use task-based interaction + * @returns {Promise} + */ + async sendMessage(message, contextId = null, taskBased = false) { + const requestId = uuidv4(); + + try { + this.activeRequests.set(requestId, { timestamp: Date.now() }); + + if (taskBased) { + return await this.createTask(message, contextId); + } else { + return await this.sendDirectMessage(message, contextId); + } + } catch (error) { + console.error(`A2A Client Error (${this.agentConfig.name}):`, error); + return { + success: false, + error: error.message || 'Unknown error occurred', + }; + } finally { + this.activeRequests.delete(requestId); + } + } + + /** + * Send a direct message (non-task based) + * @private + */ + async sendDirectMessage(message, contextId) { + const messagePayload = { + role: 'user', + parts: [{ type: 'text', content: message }] + }; + + const requestData = { + message: messagePayload, + contextId: contextId || uuidv4(), + }; + + const response = await this.makeRequest('/message/send', requestData); + + return { + success: true, + message: response.message, + contextId: response.contextId, + data: response, + }; + } + + /** + * Create a task-based interaction + * @private + */ + async createTask(message, contextId) { + const taskPayload = { + contextId: contextId || uuidv4(), + message: { + role: 'user', + parts: [{ type: 'text', content: message }] + } + }; + + const response = await this.makeRequest('/tasks/create', taskPayload); + + return { + success: true, + task: { + id: response.taskId || response.id, + contextId: response.contextId, + status: response.status || 'submitted', + statusMessage: response.statusMessage, + history: response.history || [], + artifacts: response.artifacts || [], + }, + data: response, + }; + } + + /** + * Get task status + * @param {string} taskId - Task identifier + * @returns {Promise} + */ + async getTaskStatus(taskId) { + try { + const response = await this.makeRequest('/tasks/get', { taskId }); + return { + id: taskId, + contextId: response.contextId, + status: response.status, + statusMessage: response.statusMessage, + history: response.history || [], + artifacts: response.artifacts || [], + updatedAt: new Date(), + }; + } catch (error) { + console.error(`Failed to get task status for ${taskId}:`, error); + throw error; + } + } + + /** + * Cancel a task + * @param {string} taskId - Task identifier + */ + async cancelTask(taskId) { + try { + return await this.makeRequest('/tasks/cancel', { taskId }); + } catch (error) { + console.error(`Failed to cancel task ${taskId}:`, error); + throw error; + } + } + + /** + * Make HTTP request to A2A agent + * @private + */ + async makeRequest(endpoint, data, retryCount = 0) { + const { agentCard } = this.agentConfig; + if (!agentCard || !agentCard.url) { + throw new Error('Agent card or URL not available'); + } + + // Route to gRPC client for GRPC transport + if (agentCard.preferredTransport === 'GRPC') { + return this.makeGrpcRequest(endpoint, data, retryCount); + } + + // HTTP/JSONRPC transport + const url = this.buildUrl(agentCard.url, endpoint, agentCard.preferredTransport); + const headers = this.buildHeaders(agentCard.preferredTransport); + + const requestOptions = { + method: 'POST', + headers, + body: this.buildRequestBody(data, agentCard.preferredTransport, endpoint), + signal: AbortSignal.timeout(this.timeout), + }; + + try { + const response = await fetch(url, requestOptions); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return await this.parseResponse(response, agentCard.preferredTransport); + } catch (error) { + if (retryCount < this.maxRetries && this.isRetryableError(error)) { + console.warn(`Retrying request (${retryCount + 1}/${this.maxRetries}):`, error.message); + await this.delay(1000 * Math.pow(2, retryCount)); // Exponential backoff + return this.makeRequest(endpoint, data, retryCount + 1); + } + throw error; + } + } + + /** + * Build request URL based on transport protocol + * @private + */ + buildUrl(baseUrl, endpoint, transport) { + const cleanUrl = baseUrl.replace(/\/$/, ''); + + switch (transport) { + case 'JSONRPC': + return `${cleanUrl}/jsonrpc`; + case 'HTTP+JSON': + return `${cleanUrl}/v1${endpoint}`; + case 'GRPC': + // gRPC uses HTTP endpoint for the A2A service + return `${cleanUrl}/v1/message:send`; + default: + return `${cleanUrl}${endpoint}`; + } + } + + /** + * Build request headers + * @private + */ + buildHeaders(transport) { + const headers = { + 'User-Agent': 'LibreChat-A2A-Client/1.0', + }; + + // Set content type based on transport + switch (transport) { + case 'GRPC': + // Use JSON for gRPC-Web gateway compatibility + headers['Content-Type'] = 'application/json'; + headers['Accept'] = 'application/json'; + headers['grpc-accept-encoding'] = 'gzip'; + // Add Stripe-specific headers for authentication + headers['User-Agent'] = 'LibreChat-gRPC-Client/1.0'; + break; + case 'JSONRPC': + case 'HTTP+JSON': + default: + headers['Content-Type'] = 'application/json'; + break; + } + + // Add authentication headers + const { authentication } = this.agentConfig; + if (authentication && authentication.type !== 'none') { + Object.assign(headers, this.buildAuthHeaders(authentication)); + } + + return headers; + } + + /** + * Build authentication headers + * @private + */ + buildAuthHeaders(auth) { + const headers = {}; + + switch (auth.type) { + case 'apikey': + if (auth.credentials?.apikey) { + headers['X-API-Key'] = auth.credentials.apikey; + } + break; + case 'http': + if (auth.credentials?.token) { + headers['Authorization'] = `Bearer ${auth.credentials.token}`; + } + break; + case 'oauth2': + if (auth.credentials?.access_token) { + headers['Authorization'] = `Bearer ${auth.credentials.access_token}`; + } + break; + } + + // Add custom headers + if (auth.headers) { + Object.assign(headers, auth.headers); + } + + return headers; + } + + /** + * Build request body based on transport protocol + * @private + */ + buildRequestBody(data, transport, endpoint) { + switch (transport) { + case 'JSONRPC': + return JSON.stringify({ + jsonrpc: '2.0', + method: endpoint.replace('/', ''), + params: data, + id: uuidv4(), + }); + case 'HTTP+JSON': + default: + return JSON.stringify(data); + } + } + + /** + * Parse response based on transport protocol + * @private + */ + async parseResponse(response, transport) { + const responseData = await response.json(); + + switch (transport) { + case 'JSONRPC': + if (responseData.error) { + throw new Error(`JSON-RPC Error: ${responseData.error.message}`); + } + return responseData.result; + case 'GRPC': + // Convert Stripe gRPC SendMessageResponse to A2A format + if (responseData.task) { + return { + taskId: responseData.task.id, + contextId: responseData.task.context_id, + status: responseData.task.status?.state || 'SUBMITTED', + timestamp: responseData.task.status?.timestamp, + // Convert to A2A message format + message: { + messageId: responseData.task.id, + conversationId: responseData.task.context_id, + text: `Task ${responseData.task.status?.state || 'submitted'}`, + sender: 'Agent', + timestamp: responseData.task.status?.timestamp + } + }; + } + return responseData; + case 'HTTP+JSON': + default: + return responseData; + } + } + + /** + * Check if error is retryable + * @private + */ + isRetryableError(error) { + // Network errors, timeouts, and 5xx server errors are retryable + return ( + error.name === 'AbortError' || + error.name === 'TypeError' || + (error.message && error.message.includes('HTTP 5')) + ); + } + + /** + * Delay utility for retries + * @private + */ + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Get agent health status + */ + async getHealthStatus() { + // For agent-card-less agents (like LLM Explorer), skip HTTP health checks + if (!this.agentConfig.agentCardUrl) { + // Assume gRPC-only hardcoded agents are always online (they're directly configured) + return { + status: 'online', + timestamp: new Date(), + }; + } + + // For HTTP-based agents with discoverable agent cards + try { + const response = await fetch(`${this.agentConfig.agentCard.url}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), // Short timeout for health checks + }); + + return { + status: response.ok ? 'online' : 'error', + timestamp: new Date(), + statusCode: response.status, + }; + } catch (error) { + return { + status: 'offline', + timestamp: new Date(), + error: error.message, + }; + } + } + + /** + * Load gRPC proto definition for A2A service using proto-loader + * @private + */ + loadGrpcProto() { + if (this.grpcProto) { + return this.grpcProto; + } + + // Load the A2A protocol proto file from submodule + const packageDefinition = protoLoader.loadSync( + '/app/proto/a2a/grpc/a2a.proto', + { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + '/app/proto/googleapis', // Google API protos (annotations, client, field_behavior) + // '/app/third_party/protobuf/src', // Core Google protos (empty, struct, timestamp) + '/app/proto/a2a/grpc' // A2A proto directory + ] + } + ); + + this.grpcProto = grpc.loadPackageDefinition(packageDefinition); + return this.grpcProto; + } + + /** + * Make gRPC request to A2A agent + * @private + */ + async makeGrpcRequest(endpoint, data, retryCount = 0) { + const { agentCard } = this.agentConfig; + if (!agentCard || !agentCard.url) { + throw new Error('Agent card or URL not available'); + } + + try { + // Get gRPC endpoint from agent card endpoints + const grpcUrl = (agentCard.endpoints?.grpcService || agentCard.url).replace(/^https?:\/\//, ''); + + // Load proto definition + const proto = this.loadGrpcProto(); + + // Create gRPC client using the proper service + const A2AService = proto.com.stripe.agent.a2a.v1.A2AService; + + // Create gRPC client + console.log("grpc url", grpcUrl); + const debugClient = new A2AService( + grpcUrl, + // 'llm-explorer-agent-grpc.service.envoy:10082', + grpc.credentials.createInsecure(), + { + 'grpc.default_authority': 'llm-explorer-agent-grpc.service.envoy', + } + ); + + // Convert A2A data to A2A gRPC protocol format + // Extract text from LibreChat message format + let textContent = ''; + if (data.message) { + if (typeof data.message === 'string') { + textContent = data.message; + } else if (data.message.parts && Array.isArray(data.message.parts)) { + // Extract text from LibreChat message parts + const textParts = data.message.parts + .filter(part => part.type === 'text') + .map(part => part.content) + .join(' '); + textContent = textParts; + } else if (data.message.content) { + textContent = data.message.content; + } + } else if (data.text) { + textContent = data.text; + } + + // Detect agent type and use appropriate request format + let grpcRequest; + + if (this.agentConfig.id.includes('llm-explorer-agent--grpc')) { + // Real LLM Explorer Agent format (matches grpcurl command) + grpcRequest = { + request: { + metadata: { + fields: { + agent_name: { stringValue: 'hello' }, + }, + }, + content: [ + { + data: { + data: { + fields: { + name: { stringValue: textContent || 'NAME' }, + }, + }, + }, + }, + ], + role: 'ROLE_AGENT', + }, + configuration: { + blocking: true, + }, + }; + console.log('Making gRPC call to LLM Explorer Agent:', grpcUrl); + } else { + // Mock A2A Protocol format + grpcRequest = { + message: textContent || 'Hello', + context_id: data.contextId || uuidv4(), + metadata: { + agent_name: data.agentId || 'hello', + endpoint: endpoint || '/message/send' + } + }; + console.log('Making gRPC call to A2A agent:', grpcUrl); + } + + // Make gRPC call + const grpcResponse = await new Promise((resolve, reject) => { + debugClient.SendMessage(grpcRequest, (error, response) => { + console.log("grpc response", response); + console.log("grpc error", error); + console.log("grpc request", JSON.stringify(grpcRequest)); + if (error) { + console.error('gRPC call failed:', { + code: error.code, + message: error.message, + details: error.details || 'none' + }); + reject(error); + } else { + console.log('gRPC call successful'); + resolve(response); + } + }); + }); + + + // Handle different response formats based on agent type + if (this.agentConfig.id.includes('llm-explorer-agent--grpc')) { + // Handle LLM Explorer Agent response format + return { + success: true, + message: { + messageId: uuidv4(), + conversationId: data.contextId || uuidv4(), + text: grpcResponse.response || grpcResponse.message || JSON.stringify(grpcResponse), + sender: this.agentConfig.name, + timestamp: new Date().toISOString() + }, + contextId: data.contextId || uuidv4(), + error: null + }; + } else { + // Handle mock A2A Protocol response format + return { + success: grpcResponse.success || true, + message: { + messageId: grpcResponse.message?.message_id || uuidv4(), + conversationId: grpcResponse.message?.conversation_id || grpcResponse.context_id, + text: grpcResponse.message?.text || 'Response received', + sender: grpcResponse.message?.sender || this.agentConfig.name, + timestamp: grpcResponse.message?.timestamp || new Date().toISOString() + }, + contextId: grpcResponse.context_id || data.contextId, + error: grpcResponse.error || null + }; + } + + } catch (error) { + if (retryCount < this.maxRetries && this.isRetryableError(error)) { + console.warn(`Retrying gRPC request (${retryCount + 1}/${this.maxRetries}):`, error.message); + await this.delay(1000 * Math.pow(2, retryCount)); + return this.makeGrpcRequest(endpoint, data, retryCount + 1); + } + throw new Error(`gRPC request failed: ${error.code}: ${error.message}`); + } + } + + /** + * Cleanup resources + */ + destroy() { + this.activeRequests.clear(); + } +} + +module.exports = A2AClient; \ No newline at end of file diff --git a/api/package.json b/api/package.json index 1b310079ed3e..a6a8d54f68e7 100644 --- a/api/package.json +++ b/api/package.json @@ -42,6 +42,8 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", + "@grpc/grpc-js": "1.9.15", + "@grpc/proto-loader": "0.7.13", "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", diff --git a/api/server/controllers/a2a/chat.js b/api/server/controllers/a2a/chat.js new file mode 100644 index 000000000000..82202e074065 --- /dev/null +++ b/api/server/controllers/a2a/chat.js @@ -0,0 +1,138 @@ +const { v4: uuidv4 } = require('uuid'); +const { sendEvent } = require('@librechat/api'); +const { saveMessage, saveConvo } = require('~/models'); +const discoveryService = require('../../services/A2ADiscoveryService'); + +/** + * Register a new A2A agent + */ +const registerA2AAgent = async (req, res) => { + try { + const { agentCardUrl, authentication = { type: 'none' }, options = {} } = req.body; + + if (!agentCardUrl) { + return res.status(400).json({ + error: 'Missing required parameter: agentCardUrl' + }); + } + + const agentId = await discoveryService.registerAgent(agentCardUrl, authentication, options); + const agent = discoveryService.getAgent(agentId); + + res.status(201).json({ + success: true, + agentId, + agent: { + id: agent.id, + name: agent.name, + description: agent.description, + status: agent.status, + } + }); + + } catch (error) { + console.error('Error registering A2A agent:', error); + res.status(500).json({ + error: 'Failed to register A2A agent', + message: error.message + }); + } +}; + +/** + * Unregister an A2A agent + */ +const unregisterA2AAgent = async (req, res) => { + try { + const { agentId } = req.params; + + await discoveryService.unregisterAgent(agentId); + + res.json({ + success: true, + message: `Agent ${agentId} unregistered successfully` + }); + + } catch (error) { + console.error('Error unregistering A2A agent:', error); + res.status(500).json({ + error: 'Failed to unregister A2A agent', + message: error.message + }); + } +}; + +/** + * Get available A2A agents + */ +const getA2AAgents = async (req, res) => { + try { + const agents = discoveryService.getRegisteredAgents(); + + // Format agents for client consumption + const formattedAgents = agents.map(agent => ({ + id: agent.id, + name: agent.name, + description: agent.description, + status: agent.status, + capabilities: agent.agentCard?.capabilities || {}, + skills: agent.agentCard?.skills || [], + transport: agent.preferredTransport, + lastHealthCheck: agent.lastHealthCheck, + createdAt: agent.createdAt, + })); + + res.json({ agents: formattedAgents }); + + } catch (error) { + console.error('Error fetching A2A agents:', error); + res.status(500).json({ + error: 'Failed to fetch A2A agents', + message: error.message + }); + } + }; + +/** + * Get A2A agent details + */ +const getA2AAgent = async (req, res) => { + try { + const { agentId } = req.params; + + const agent = discoveryService.getAgent(agentId); + if (!agent) { + return res.status(404).json({ + error: `A2A agent not found: ${agentId}` + }); + } + + res.json({ + agent: { + id: agent.id, + name: agent.name, + description: agent.description, + status: agent.status, + agentCard: agent.agentCard, + transport: agent.preferredTransport, + lastHealthCheck: agent.lastHealthCheck, + createdAt: agent.createdAt, + updatedAt: agent.updatedAt, + } + }); + + } catch (error) { + console.error('Error fetching A2A agent details:', error); + res.status(500).json({ + error: 'Failed to fetch A2A agent details', + message: error.message + }); + } +}; + +module.exports = { + getA2AAgents, + registerA2AAgent, + unregisterA2AAgent, + getA2AAgent, +}; \ No newline at end of file diff --git a/api/server/controllers/agents/A2AAgentClient.js b/api/server/controllers/agents/A2AAgentClient.js new file mode 100644 index 000000000000..f3ce1a224c2f --- /dev/null +++ b/api/server/controllers/agents/A2AAgentClient.js @@ -0,0 +1,765 @@ +const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); +const { saveMessage, saveConvo, getConvoById, getMessages } = require('~/models'); +const { EModelEndpoint, Constants } = require('librechat-data-provider'); + +/** + * A2A Agent Client that integrates with LibreChat's conversation system + * This client uses A2A protocol but maintains compatibility with LibreChat's agents system + */ +class A2AAgentClient { + constructor({ + req, + res, + contentParts, + eventHandlers, + collectedUsage, + aggregateContent, + artifactPromises, + agent, + spec, + iconURL, + endpointType, + endpoint = EModelEndpoint.agents, + }) { + this.req = req; + this.res = res; + this.contentParts = contentParts; + this.eventHandlers = eventHandlers; + this.collectedUsage = collectedUsage; + this.aggregateContent = aggregateContent; + this.artifactPromises = artifactPromises; + this.agent = agent; + this.spec = spec; + this.iconURL = iconURL; + this.endpointType = endpointType; + this.endpoint = endpoint; + this.savedMessageIds = new Set(); + this.skipSaveUserMessage = false; + this.taskLastStatus = new Map(); // taskId -> { status, statusMessage } + + // Add options for compatibility with LibreChat's agents system + this.options = { + titleConvo: true, // Enable title generation for A2A conversations + }; + } + + async sendMessage(text, messageOptions = {}) { + const { + user, + onStart, + getReqData, + isContinued, + isRegenerate, + editedContent, + conversationId, + parentMessageId, + abortController, + overrideParentMessageId, + isEdited, + responseMessageId: editedResponseMessageId, + progressOptions, + } = messageOptions; + + try { + // Generate message IDs + const userMessageId = uuidv4(); + const responseMessageId = editedResponseMessageId || uuidv4(); + const contextId = conversationId || uuidv4(); + + logger.debug(`A2A Agent Client - sending message to ${this.agent.name} (${this.agent.id})`); + logger.debug(`A2A Agent Client - contextId: ${contextId}`); + + // Determine parent for linear conversation linking + let effectiveParentId = parentMessageId || overrideParentMessageId || null; + if (!effectiveParentId) { + try { + effectiveParentId = await this.getLastMessageId(contextId); + } catch (_) { + effectiveParentId = null; + } + } else { + // Validate that the provided parent exists in this conversation; fallback if it doesn't + try { + const existing = await getMessages({ conversationId: contextId }, 'messageId createdAt'); + const exists = Array.isArray(existing) + ? existing.some((m) => m.messageId === effectiveParentId) + : false; + if (!exists) { + const fallback = await this.getLastMessageId(contextId); + effectiveParentId = fallback || null; + } + } catch (_) { + // ignore and keep effectiveParentId as-is + } + } + + // Create user message + // Ensure brand-new threads use the sentinel NO_PARENT so the client + // recognizes it as a new conversation and updates the sidebar immediately. + const parentIdForUserMsg = effectiveParentId || Constants.NO_PARENT; + const userMessage = { + messageId: userMessageId, + conversationId: contextId, + parentMessageId: parentIdForUserMsg, + role: 'user', + text: text, + user: user, + endpoint: 'a2a', + model: this.agent.id, + isCreatedByUser: true, + }; + + // Notify start if callback provided + if (onStart) { + getReqData({ + userMessage, + userMessagePromise: Promise.resolve(userMessage), + responseMessageId, + sender: this.agent.name, + conversationId: contextId, + }); + onStart(userMessage); + } + logger.debug('[A2A][Server] onStart payload', { + conversationId: contextId, + userMessageId: userMessageId, + parentMessageId: userMessage.parentMessageId, + }); + + // Detect if this should be a task-based workflow + const shouldUseTask = this.shouldUseTaskBasedWorkflow(text); + + logger.info(`A2A Agent Client - message analysis: shouldUseTask=${shouldUseTask}, textLength=${text.length}`); + logger.debug(`A2A Agent Client - using task-based workflow: ${shouldUseTask}`); + + // Send message to A2A agent (task-based or direct) + const response = await this.agent.a2aClient.sendMessage(text, contextId, shouldUseTask); + + if (!response.success) { + throw new Error(response.error || 'A2A agent returned error'); + } + + let responseText; + let metadata = { + agentId: this.agent.id, + agentName: this.agent.name, + transport: 'a2a', + }; + + if (shouldUseTask && response.task) { + logger.info(`A2A Agent Client - Task created: ${response.task.id}, status: ${response.task.status}`); + + // Handle task-based response + responseText = `šŸ”„ **Task Created:** ${response.task.statusMessage || 'Processing your request...'}\n\n` + + `**Task ID:** \`${response.task.id}\`\n\n` + + `I've started working on your request. You can check the progress or I'll update you when it's complete.`; + + metadata.taskId = response.task.id; + metadata.taskStatus = response.task.status; + metadata.isTaskBased = true; + + // Start polling task status in background + logger.info(`A2A Agent Client - Starting background polling for task: ${response.task.id}`); + this.startTaskPolling(response.task.id, contextId, user, responseMessageId); + } else { + // Handle direct message response + // Parse the complex nested response structure + responseText = 'Response received from A2A agent'; + + try { + // First, try to parse the text field if it's JSON + let parsedResponse = response.message?.text; + if (typeof parsedResponse === 'string') { + parsedResponse = JSON.parse(parsedResponse); + } + console.log("parsedResponse", parsedResponse); + + // Extract from task structure: task.status.update.content[0].data.data.fields.output.structValue.fields.output.stringValue + const taskOutput = parsedResponse?.task?.status?.update?.content?.[0]?.data?.data?.fields?.output?.structValue?.fields?.output?.stringValue; + console.log("taskOutput", taskOutput); + if (taskOutput) { + responseText = taskOutput; + } else { + // No task output found, use fallback parsing + responseText = response.data?.parts?.[0]?.content || + response.message?.parts?.[0]?.content || + response.message?.text || + response.message?.content || + response.parts?.[0]?.content || + response.content || + 'Response received from A2A agent'; + } + } catch (parseError) { + console.warn('Failed to parse A2A response JSON:', parseError); + // responseText keeps its default value on parse error + } + logger.info(`A2A Agent Client - response: ${JSON.stringify(response)}`); + } + console.log("responseText", responseText); + + // Create response message + const responseMessage = { + messageId: responseMessageId, + conversationId: contextId, + parentMessageId: userMessageId, + role: 'assistant', + text: responseText, + user: user, + endpoint: 'a2a', + model: this.agent.id, + metadata: metadata, + }; + + // Mark messages as saved to avoid duplicate saves + this.savedMessageIds.add(userMessageId); + this.savedMessageIds.add(responseMessageId); + + // Save user message + await saveMessage(this.req, userMessage, { + context: 'A2A Agent Client - User Message', + }); + + // Save agent response message + await saveMessage(this.req, responseMessage, { + context: 'A2A Agent Client - Agent Response', + }); + logger.debug('[A2A][Server] saved messages', { + conversationId: contextId, + userMessageId, + responseMessageId, + responseParent: responseMessage.parentMessageId, + }); + + logger.debug(`A2A Agent Client - response: ${responseText.substring(0, 100)}...`); + + // Save conversation to database with task metadata + // Title policy for A2A: + // - Generate a concise title from the first user message on new threads + // - Do not overwrite titles on continued threads + const conversationData = { + conversationId: contextId, + endpoint: 'a2a', + model: this.agent.id, + user: user, + }; + try { + const isNewThread = userMessage.parentMessageId == null; + if (isNewThread) { + conversationData.title = await this.titleConvo({ text }); + } + } catch { + // If title generation fails, omit title and let client-side titlegen (if any) handle it + } + + // Add task information to conversation metadata if this is a task + if (shouldUseTask && response.task) { + conversationData.metadata = { + activeTaskId: response.task.id, + lastTaskStatus: response.task.status, + lastTaskUpdate: new Date(), + agentId: this.agent.id, + }; + } + + // Return response in LibreChat's expected format + return { + ...responseMessage, + sender: this.agent.name, + databasePromise: this.saveConversation(conversationData).then(savedConvo => ({ + conversation: savedConvo || conversationData + })), + }; + + } catch (error) { + logger.error('A2A Agent Client error:', error); + throw error; + } + } + + // Method to save conversation (called by agents controller) + async saveConversation(conversationData) { + try { + return await saveConvo(this.req, { + ...conversationData, + endpoint: 'a2a', + model: this.agent.id, + }, { context: 'A2A Agent Client - Conversation' }); + } catch (error) { + logger.error('Error saving A2A conversation:', error); + throw error; + } + } + + // Title generation method for A2A conversations + async titleConvo({ text }) { + try { + // Generate a simple title based on the first user message + // A more sophisticated approach could use the A2A agent to generate titles + const words = text.trim().split(/\s+/); + if (words.length <= 6) { + return text.trim(); + } + return words.slice(0, 6).join(' ') + '...'; + } catch (error) { + logger.error('A2A title generation error:', error); + return `A2A Chat with ${this.agent.name}`; + } + } + + /** + * Determine if a message should trigger task-based workflow + * @param {string} text - User message text + * @returns {boolean} + */ + shouldUseTaskBasedWorkflow(text) { + const taskTriggers = [ + // Direct task keywords + 'create task', 'start task', 'task:', '/task', + + // Analysis keywords + 'analyze', 'analysis', 'examine', 'investigate', 'research', + + // Creation/generation keywords + 'create', 'generate', 'build', 'make', 'develop', 'design', + 'write a', 'compose', 'draft', + + // Processing keywords + 'process', 'calculate', 'compute', 'transform', 'convert', + + // Complex request indicators + 'step by step', 'detailed', 'comprehensive', 'thorough', + 'multiple', 'several steps', 'workflow' + ]; + + const lowerText = text.toLowerCase(); + + // Check for explicit task triggers + if (taskTriggers.some(trigger => lowerText.includes(trigger))) { + return true; + } + + // Check for long, complex requests (likely tasks) + if (text.length > 200) { + return true; + } + + // Check for questions that might benefit from structured processing + if (lowerText.includes('how to') && text.length > 50) { + return true; + } + + return false; + } + + /** + * Start polling task status in background + * @param {string} taskId - Task identifier + * @param {string} contextId - Context identifier + * @param {Object} user - User object + * @param {string} initialResponseMessageId - Initial response message ID + */ + startTaskPolling(taskId, contextId, user, initialResponseMessageId) { + logger.info(`A2A Task Polling - Starting polling for task: ${taskId} in conversation: ${contextId}`); + + const pollInterval = 3000; // Poll every 3 seconds + const maxPollTime = 300000; // Max 5 minutes + const startTime = Date.now(); + let pollCount = 0; + + const poll = async () => { + try { + pollCount++; + const elapsedTime = Date.now() - startTime; + + // Check if we've exceeded max poll time + if (elapsedTime > maxPollTime) { + logger.warn(`A2A Task Polling - Timeout reached for task: ${taskId} after ${Math.round(elapsedTime/1000)}s`); + return; + } + + logger.debug(`A2A Task Polling - Poll #${pollCount} for task: ${taskId} (elapsed: ${Math.round(elapsedTime/1000)}s)`); + + // Get task status + const taskStatus = await this.agent.a2aClient.getTaskStatus(taskId); + + logger.info(`A2A Task Polling - Task ${taskId} status: ${taskStatus.status}, message: ${taskStatus.statusMessage}`); + + // If task is completed, send update message + if (taskStatus.status === 'completed') { + logger.info(`A2A Task Polling - Task completed, sending completion message: ${taskId}`); + await this.sendTaskCompletionMessage(taskId, taskStatus, contextId, user); + } else if (taskStatus.status === 'failed') { + logger.info(`A2A Task Polling - Task failed, sending failure message: ${taskId}`); + await this.sendTaskFailureMessage(taskId, taskStatus, contextId, user); + } else if (taskStatus.status === 'working') { + logger.debug(`A2A Task Polling - Task still working, continuing to poll: ${taskId}`); + // Save a status update message only when status text changes to avoid duplicates + const last = this.taskLastStatus.get(taskId) || {}; + if (last.status !== taskStatus.status || last.statusMessage !== taskStatus.statusMessage) { + try { + await this.sendTaskStatusUpdate(taskId, taskStatus, contextId, user); + await this.updateConversationTaskStatus(contextId, taskId, 'working', user); + this.taskLastStatus.set(taskId, { + status: taskStatus.status, + statusMessage: taskStatus.statusMessage, + }); + } catch (e) { + logger.warn(`A2A Task Polling - Failed to persist status update for ${taskId}:`, e); + } + } + // Continue polling for working tasks + setTimeout(poll, pollInterval); + } else { + logger.warn(`A2A Task Polling - Unexpected task status: ${taskStatus.status} for task: ${taskId}`); + } + + } catch (error) { + logger.error(`A2A Task Polling - Error polling task ${taskId}:`, error); + // Stop polling on error + } + }; + + // Start polling after a short delay + logger.debug(`A2A Task Polling - Scheduling first poll for task ${taskId} in ${pollInterval}ms`); + setTimeout(poll, pollInterval); + } + + /** + * Send task completion message + * @param {string} taskId - Task identifier + * @param {Object} taskStatus - Task status object + * @param {string} contextId - Context identifier + * @param {Object} user - User object + */ + async sendTaskCompletionMessage(taskId, taskStatus, contextId, user) { + try { + // Extract final response from task history + const finalResponse = taskStatus.history + ?.filter(msg => msg.role === 'agent') + ?.pop()?.parts?.[0]?.content || 'Task completed successfully!'; + + // Format artifacts if available + let artifactsText = ''; + if (taskStatus.artifacts && taskStatus.artifacts.length > 0) { + artifactsText = '\n\n**Generated Artifacts:**\n'; + taskStatus.artifacts.forEach(artifact => { + artifactsText += `- **${artifact.name}** (${artifact.type})\n`; + if (artifact.content && typeof artifact.content === 'object') { + artifactsText += ` ${JSON.stringify(artifact.content, null, 2)}\n`; + } + }); + } + + const completionText = `āœ… **Task Completed:** \`${taskId}\`\n\n${finalResponse}${artifactsText}`; + + // Parent selection for status updates: + // Link to the most recent message in this conversation (authoritative order) + // so incremental refetch renders linearly without relying on client hints. + const parentMessageId = await this.getLastMessageId(contextId); + + const completionMessage = { + messageId: uuidv4(), + conversationId: contextId, + parentMessageId: parentMessageId, + role: 'assistant', + text: completionText, + user: user, + endpoint: 'a2a', + model: this.agent.id, + sender: this.agent.name, + metadata: { + agentId: this.agent.id, + agentName: this.agent.name, + transport: 'a2a', + taskId: taskId, + taskStatus: 'completed', + isTaskCompletion: true, + artifacts: taskStatus.artifacts, + }, + }; + + // Save completion message + await saveMessage(this.req, completionMessage, { + context: 'A2A Agent Client - Task Completion', + }); + + // Update conversation metadata to mark task as completed + await this.updateConversationTaskStatus(contextId, taskId, 'completed', user); + + } catch (error) { + logger.error(`Failed to send task completion message for ${taskId}:`, error); + } + } + + /** + * Send task failure message + * @param {string} taskId - Task identifier + * @param {Object} taskStatus - Task status object + * @param {string} contextId - Context identifier + * @param {Object} user - User object + */ + async sendTaskFailureMessage(taskId, taskStatus, contextId, user) { + try { + const failureText = `āŒ **Task Failed:** \`${taskId}\`\n\n` + + `**Error:** ${taskStatus.statusMessage || 'Unknown error occurred'}\n\n` + + `The task could not be completed. You can try rephrasing your request or contact support if the issue persists.`; + + // Find the most recent message in this conversation to use as parent + const parentMessageId = await this.getLastMessageId(contextId); + + const failureMessage = { + messageId: uuidv4(), + conversationId: contextId, + parentMessageId: parentMessageId, + role: 'assistant', + text: failureText, + user: user, + endpoint: 'a2a', + model: this.agent.id, + sender: this.agent.name, + metadata: { + agentId: this.agent.id, + agentName: this.agent.name, + transport: 'a2a', + taskId: taskId, + taskStatus: 'failed', + isTaskFailure: true, + }, + }; + + // Save failure message + await saveMessage(this.req, failureMessage, { + context: 'A2A Agent Client - Task Failure', + }); + + // Update conversation metadata to mark task as failed + await this.updateConversationTaskStatus(contextId, taskId, 'failed', user); + + } catch (error) { + logger.error(`Failed to send task failure message for ${taskId}:`, error); + } + } + + /** + * Get task status for a given task ID + * @param {string} taskId - Task identifier + * @returns {Promise} Task status + */ + async getTaskStatus(taskId) { + try { + return await this.agent.a2aClient.getTaskStatus(taskId); + } catch (error) { + logger.error(`Failed to get task status for ${taskId}:`, error); + throw error; + } + } + + /** + * Cancel a task + * @param {string} taskId - Task identifier + * @returns {Promise} Cancellation result + */ + async cancelTask(taskId) { + try { + return await this.agent.a2aClient.cancelTask(taskId); + } catch (error) { + logger.error(`Failed to cancel task ${taskId}:`, error); + throw error; + } + } + + /** + * Update conversation metadata with task status + * @param {string} conversationId - Conversation identifier + * @param {string} taskId - Task identifier + * @param {string} status - Task status + * @param {Object} user - User object + */ + async updateConversationTaskStatus(conversationId, taskId, status, user) { + try { + logger.info(`A2A Metadata Update - Updating conversation ${conversationId} for task ${taskId} -> status: ${status}`); + + // Update conversation metadata + const conversationData = { + conversationId: conversationId, + metadata: { + activeTaskId: status === 'completed' || status === 'failed' ? null : taskId, + lastTaskId: taskId, + lastTaskStatus: status, + lastTaskUpdate: new Date(), + agentId: this.agent.id, + }, + user: user, + }; + + logger.debug(`A2A Metadata Update - Metadata to save:`, { + activeTaskId: conversationData.metadata.activeTaskId, + lastTaskId: conversationData.metadata.lastTaskId, + lastTaskStatus: conversationData.metadata.lastTaskStatus, + agentId: conversationData.metadata.agentId, + }); + + await this.saveConversation(conversationData); + logger.info(`A2A Metadata Update - Successfully updated conversation task status: ${taskId} -> ${status}`); + } catch (error) { + logger.error(`A2A Metadata Update - Failed to update conversation task status for ${taskId}:`, error); + } + } + + /** + * Check for active tasks when conversation is loaded and recover if needed + * @param {string} conversationId - Conversation identifier + * @param {Object} user - User object + */ + async recoverActiveTasks(conversationId, user) { + try { + logger.info(`A2A Task Recovery - Starting recovery for conversation: ${conversationId}`); + + // Get conversation to check for active tasks + const conversation = await this.getConversation(conversationId); + logger.debug(`A2A Task Recovery - Retrieved conversation data:`, { + hasMetadata: !!conversation?.metadata, + activeTaskId: conversation?.metadata?.activeTaskId, + lastTaskUpdate: conversation?.metadata?.lastTaskUpdate, + agentId: conversation?.metadata?.agentId + }); + + if (!conversation?.metadata?.activeTaskId) { + logger.info(`A2A Task Recovery - No active tasks found in conversation: ${conversationId}`); + return; + } + + const taskId = conversation.metadata.activeTaskId; + const lastUpdate = new Date(conversation.metadata.lastTaskUpdate); + const timeSinceUpdate = Date.now() - lastUpdate.getTime(); + + logger.info(`A2A Task Recovery - Found active task: ${taskId}, last updated: ${lastUpdate.toISOString()}, time since: ${Math.round(timeSinceUpdate/1000)}s ago`); + + // If last update was more than 10 minutes ago, consider task stale + if (timeSinceUpdate > 10 * 60 * 1000) { + logger.warn(`A2A Task Recovery - Task ${taskId} appears stale (${Math.round(timeSinceUpdate/60000)} minutes old), skipping recovery`); + return; + } + + logger.info(`A2A Task Recovery - Fetching current status for task: ${taskId}`); + + // Get current task status + const taskStatus = await this.agent.a2aClient.getTaskStatus(taskId); + logger.info(`A2A Task Recovery - Retrieved task status: ${taskStatus.status}, message: ${taskStatus.statusMessage}`); + + if (taskStatus.status === 'completed') { + logger.info(`A2A Task Recovery - Task completed, sending completion message: ${taskId}`); + await this.sendTaskCompletionMessage(taskId, taskStatus, conversationId, user); + } else if (taskStatus.status === 'failed') { + logger.info(`A2A Task Recovery - Task failed, sending failure message: ${taskId}`); + await this.sendTaskFailureMessage(taskId, taskStatus, conversationId, user); + } else if (taskStatus.status === 'working') { + logger.info(`A2A Task Recovery - Task still working, sending status update and resuming polling: ${taskId}`); + // Send status update message + await this.sendTaskStatusUpdate(taskId, taskStatus, conversationId, user); + + // Resume polling + this.startTaskPolling(taskId, conversationId, user, null); + } else { + logger.warn(`A2A Task Recovery - Unexpected task status: ${taskStatus.status} for task: ${taskId}`); + } + + } catch (error) { + logger.error(`A2A Task Recovery - Failed to recover active tasks for conversation ${conversationId}:`, error); + } + } + + /** + * Send task status update message + * @param {string} taskId - Task identifier + * @param {Object} taskStatus - Task status object + * @param {string} contextId - Context identifier + * @param {Object} user - User object + */ + async sendTaskStatusUpdate(taskId, taskStatus, contextId, user) { + try { + logger.info(`A2A Task Status Update - Sending status update for task: ${taskId}`); + + const statusText = `šŸ”„ **Task Status Update:** \`${taskId}\`\n\n` + + `**Status:** ${taskStatus.status}\n` + + `**Message:** ${taskStatus.statusMessage || 'Still processing...'}\n\n` + + `I'm continuing to work on your request. You'll be notified when it's complete.`; + + // Find the most recent message in this conversation to use as parent + const parentMessageId = await this.getLastMessageId(contextId); + + const statusMessage = { + messageId: uuidv4(), + conversationId: contextId, + parentMessageId: parentMessageId, // Link to the most recent message for proper threading + role: 'assistant', + text: statusText, + user: user, + endpoint: 'a2a', + model: this.agent.id, + sender: this.agent.name, + metadata: { + agentId: this.agent.id, + agentName: this.agent.name, + transport: 'a2a', + taskId: taskId, + taskStatus: taskStatus.status, + isTaskStatusUpdate: true, + }, + }; + + await saveMessage(this.req, statusMessage, { + context: 'A2A Agent Client - Task Status Update', + }); + + // Note: Real-time events cannot be sent from background task polling + // since the original HTTP response has closed. The frontend should poll + // for task completion or detect new messages through existing mechanisms. + logger.info(`A2A Task Status Update - Message saved for task: ${taskId}, frontend will detect on next poll`); + + logger.info(`A2A Task Status Update - Status update sent for task: ${taskId}`); + + } catch (error) { + logger.error(`Failed to send task status update for ${taskId}:`, error); + } + } + + /** + * Get conversation from database + * @param {string} conversationId - Conversation identifier + * @returns {Promise} Conversation object + */ + async getConversation(conversationId) { + try { + return await getConvoById(this.req, conversationId); + } catch (error) { + logger.error(`Failed to get conversation ${conversationId}: ${error.message}`); + return null; + } + } + + /** + * Get the last message ID from a conversation for proper threading + * @param {string} conversationId - Conversation identifier + * @returns {Promise} Last message ID + */ + async getLastMessageId(conversationId) { + try { + const messages = await getMessages({ conversationId }, 'messageId createdAt'); + + if (messages && messages.length > 0) { + // Sort messages by creation date (newest first) to get the latest message + const sortedMessages = messages.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + return sortedMessages[0].messageId; + } + + return null; + } catch (error) { + logger.error(`Failed to get last message ID for conversation ${conversationId}:`, error); + return null; + } + } +} + +module.exports = A2AAgentClient; \ No newline at end of file diff --git a/api/server/index.js b/api/server/index.js index e6ad3428ea7a..ea657cf8acc8 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -165,6 +165,7 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/a2a', routes.a2a); app.use(ErrorController); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 6f554d95d027..bb94b55fca6a 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -15,12 +15,14 @@ const openAI = require('~/server/services/Endpoints/openAI'); const agents = require('~/server/services/Endpoints/agents'); const custom = require('~/server/services/Endpoints/custom'); const google = require('~/server/services/Endpoints/google'); +const a2a = require('~/server/services/Endpoints/a2a'); const buildFunction = { [EModelEndpoint.openAI]: openAI.buildOptions, [EModelEndpoint.google]: google.buildOptions, [EModelEndpoint.custom]: custom.buildOptions, [EModelEndpoint.agents]: agents.buildOptions, + [EModelEndpoint.a2a]: a2a.buildOptions, [EModelEndpoint.bedrock]: bedrock.buildOptions, [EModelEndpoint.azureOpenAI]: openAI.buildOptions, [EModelEndpoint.anthropic]: anthropic.buildOptions, diff --git a/api/server/routes/a2a.js b/api/server/routes/a2a.js new file mode 100644 index 000000000000..bfc08d70b1de --- /dev/null +++ b/api/server/routes/a2a.js @@ -0,0 +1,480 @@ +const express = require('express'); +const { + getA2AAgents, + registerA2AAgent, + unregisterA2AAgent, + getA2AAgent, +} = require('../controllers/a2a/chat'); +const requireJwtAuth = require('../middleware/requireJwtAuth'); +const discoveryService = require('../services/A2ADiscoveryService'); + +const router = express.Router(); + +/** + * A2A Routes for External Agent Communication + * These routes handle communication with external A2A protocol agents + */ + +// Middleware to ensure A2A service is running +router.use((req, res, next) => { + if (!discoveryService.isRunning) { + discoveryService.start(); + } + next(); +}); + +/** + * Chat with A2A agent + * POST /api/a2a/chat + * + * Body: + * - agentId: string (required) - A2A agent identifier + * - message: string (required) - Message to send + * - conversationId: string (optional) - Conversation context ID + * - taskBased: boolean (optional, default: false) - Use task-based workflow + * - streaming: boolean (optional, default: true) - Enable streaming response + */ +// router.post('/chat', requireJwtAuth, handleA2AChat); + +/** + * Get all registered A2A agents + * GET /api/a2a/agents + * + * Query parameters: + * - status: string (optional) - Filter by agent status (online, offline, error) + * - capability: string (optional) - Filter by capability (streaming, taskBased, etc.) + */ +router.get('/agents', requireJwtAuth, (req, res, next) => { + const { status, capability } = req.query; + + // Add filtering logic if query parameters provided + req.filterOptions = { status, capability }; + + getA2AAgents(req, res, next); +}); + +/** + * Register new A2A agent + * POST /api/a2a/agents/register + * + * Body: + * - agentCardUrl: string (required) - URL to A2A agent card + * - authentication: object (optional) - Authentication configuration + * - type: 'none' | 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls' + * - credentials: object (optional) - Authentication credentials + * - headers: object (optional) - Custom headers + * - options: object (optional) - Additional configuration + * - timeout: number (optional, default: 30000) - Request timeout in ms + * - maxRetries: number (optional, default: 3) - Maximum retry attempts + * - enableStreaming: boolean (optional, default: true) - Enable streaming + * - enableTasks: boolean (optional, default: true) - Enable task-based workflows + */ +router.post('/agents/register', requireJwtAuth, registerA2AAgent); + +/** + * Get specific A2A agent details + * GET /api/a2a/agents/:agentId + */ +router.get('/agents/:agentId', requireJwtAuth, getA2AAgent); + +/** + * Unregister A2A agent + * DELETE /api/a2a/agents/:agentId + */ +router.delete('/agents/:agentId', requireJwtAuth, unregisterA2AAgent); + +/** + * Refresh A2A agent card + * POST /api/a2a/agents/:agentId/refresh + */ +router.post('/agents/:agentId/refresh', requireJwtAuth, async (req, res) => { + try { + const { agentId } = req.params; + + await discoveryService.refreshAgentCard(agentId); + + res.json({ + success: true, + message: `Agent card refreshed for ${agentId}` + }); + } catch (error) { + console.error('Error refreshing agent card:', error); + res.status(500).json({ + error: 'Failed to refresh agent card', + message: error.message + }); + } +}); + +/** + * Perform health check for specific agent + * POST /api/a2a/agents/:agentId/health + */ +router.post('/agents/:agentId/health', requireJwtAuth, async (req, res) => { + try { + const { agentId } = req.params; + + await discoveryService.performHealthCheck(agentId); + const agent = discoveryService.getAgent(agentId); + + if (!agent) { + return res.status(404).json({ + error: `A2A agent not found: ${agentId}` + }); + } + + res.json({ + agentId, + status: agent.status, + lastHealthCheck: agent.lastHealthCheck, + }); + } catch (error) { + console.error('Error performing health check:', error); + res.status(500).json({ + error: 'Failed to perform health check', + message: error.message + }); + } +}); + +/** + * Perform health checks for all agents + * POST /api/a2a/health + */ +router.post('/health', requireJwtAuth, async (req, res) => { + try { + await discoveryService.performHealthChecks(); + + const agents = discoveryService.getRegisteredAgents(); + const healthSummary = agents.map(agent => ({ + id: agent.id, + name: agent.name, + status: agent.status, + lastHealthCheck: agent.lastHealthCheck, + })); + + res.json({ + success: true, + timestamp: new Date(), + agents: healthSummary, + }); + } catch (error) { + console.error('Error performing health checks:', error); + res.status(500).json({ + error: 'Failed to perform health checks', + message: error.message + }); + } +}); + +/** + * Get agents by capability + * GET /api/a2a/agents/by-capability/:capability + * + * Supported capabilities: streaming, push, multiTurn, taskBased, tools + */ +router.get('/agents/by-capability/:capability', requireJwtAuth, (req, res) => { + try { + const { capability } = req.params; + + const agents = discoveryService.getAgentsByCapability(capability); + + const formattedAgents = agents.map(agent => ({ + id: agent.id, + name: agent.name, + description: agent.description, + status: agent.status, + capabilities: agent.agentCard?.capabilities || {}, + skills: agent.agentCard?.skills || [], + })); + + res.json({ + capability, + agents: formattedAgents + }); + } catch (error) { + console.error('Error fetching agents by capability:', error); + res.status(500).json({ + error: 'Failed to fetch agents by capability', + message: error.message + }); + } +}); + +/** + * Get online agents only + * GET /api/a2a/agents/online + */ +router.get('/agents/online', requireJwtAuth, (req, res) => { + try { + const agents = discoveryService.getOnlineAgents(); + + const formattedAgents = agents.map(agent => ({ + id: agent.id, + name: agent.name, + description: agent.description, + status: agent.status, + capabilities: agent.agentCard?.capabilities || {}, + skills: agent.agentCard?.skills || [], + lastHealthCheck: agent.lastHealthCheck, + })); + + res.json({ agents: formattedAgents }); + } catch (error) { + console.error('Error fetching online agents:', error); + res.status(500).json({ + error: 'Failed to fetch online agents', + message: error.message + }); + } +}); + +/** + * Discover agent at URL (without registering) + * POST /api/a2a/discover + * + * Body: + * - agentCardUrl: string (required) - URL to A2A agent card + */ +router.post('/discover', requireJwtAuth, async (req, res) => { + try { + const { agentCardUrl } = req.body; + + if (!agentCardUrl) { + return res.status(400).json({ + error: 'Missing required parameter: agentCardUrl' + }); + } + + const agentCard = await discoveryService.discoverAgent(agentCardUrl); + + res.json({ + success: true, + agentCard, + }); + } catch (error) { + console.error('Error discovering agent:', error); + res.status(500).json({ + error: 'Failed to discover agent', + message: error.message + }); + } +}); + +/** + * Get task status for a specific task + * GET /api/a2a/tasks/:taskId/status + */ +router.get('/tasks/:taskId/status', requireJwtAuth, async (req, res) => { + try { + const { taskId } = req.params; + const { agentId } = req.query; + + if (!taskId) { + return res.status(400).json({ error: 'Task ID is required' }); + } + + if (!agentId) { + return res.status(400).json({ error: 'Agent ID is required' }); + } + + // Get the A2A client for the agent + const client = discoveryService.getClient(agentId); + if (!client) { + return res.status(404).json({ error: `A2A client not found for agent: ${agentId}` }); + } + + // Get task status + const taskStatus = await client.getTaskStatus(taskId); + + console.log(`Task status retrieved: ${taskId} -> ${taskStatus.status}`); + + res.json({ + success: true, + task: taskStatus, + }); + + } catch (error) { + console.error(`Error getting task status for ${req.params.taskId}:`, error); + res.status(500).json({ + error: 'Failed to get task status', + message: error.message, + }); + } +}); + +/** + * Cancel a task + * DELETE /api/a2a/tasks/:taskId + */ +router.delete('/tasks/:taskId', requireJwtAuth, async (req, res) => { + try { + const { taskId } = req.params; + const { agentId } = req.query; + + if (!taskId) { + return res.status(400).json({ error: 'Task ID is required' }); + } + + if (!agentId) { + return res.status(400).json({ error: 'Agent ID is required' }); + } + + // Get the A2A client for the agent + const client = discoveryService.getClient(agentId); + if (!client) { + return res.status(404).json({ error: `A2A client not found for agent: ${agentId}` }); + } + + // Cancel the task + const result = await client.cancelTask(taskId); + + console.log(`Task cancelled: ${taskId}`); + + res.json({ + success: true, + result: result, + }); + + } catch (error) { + console.error(`Error cancelling task ${req.params.taskId}:`, error); + res.status(500).json({ + error: 'Failed to cancel task', + message: error.message, + }); + } +}); + +/** + * Get active tasks for a conversation + * GET /api/a2a/conversations/:conversationId/tasks + */ +router.get('/conversations/:conversationId/tasks', requireJwtAuth, async (req, res) => { + try { + const { conversationId } = req.params; + + if (!conversationId || conversationId === 'new') { + return res.json({ + success: true, + tasks: [], + }); + } + + // Get conversation to check for active tasks + const { getConvoById } = require('~/models'); + const conversation = await getConvoById(req, conversationId); + + if (!conversation?.metadata?.activeTaskId) { + return res.json({ + success: true, + tasks: [], + }); + } + + const taskId = conversation.metadata.activeTaskId; + const agentId = conversation.metadata.agentId; + + if (!agentId) { + console.warn(`No agent ID found in conversation metadata: ${conversationId}`); + return res.json({ + success: true, + tasks: [], + }); + } + + // Get the A2A client for the agent + const client = discoveryService.getClient(agentId); + if (!client) { + console.warn(`A2A client not found for agent: ${agentId}`); + return res.json({ + success: true, + tasks: [], + }); + } + + // Get current task status + try { + const taskStatus = await client.getTaskStatus(taskId); + + res.json({ + success: true, + tasks: [{ + id: taskId, + agentId: agentId, + status: taskStatus.status, + statusMessage: taskStatus.statusMessage, + lastUpdate: conversation.metadata.lastTaskUpdate, + artifacts: taskStatus.artifacts || [], + }], + }); + + } catch (taskError) { + // Task might not exist anymore, return empty array + console.warn(`Task ${taskId} not found:`, taskError.message); + res.json({ + success: true, + tasks: [], + }); + } + + } catch (error) { + console.error(`Error getting tasks for conversation ${req.params.conversationId}:`, error); + res.status(500).json({ + error: 'Failed to get conversation tasks', + message: error.message, + }); + } +}); + +/** + * A2A service status + * GET /api/a2a/status + */ +router.get('/status', requireJwtAuth, (req, res) => { + try { + const agents = discoveryService.getRegisteredAgents(); + const onlineAgents = discoveryService.getOnlineAgents(); + + res.json({ + service: { + running: discoveryService.isRunning, + healthCheckInterval: discoveryService.healthCheckInterval, + }, + agents: { + total: agents.length, + online: onlineAgents.length, + offline: agents.filter(a => a.status === 'offline').length, + error: agents.filter(a => a.status === 'error').length, + unknown: agents.filter(a => a.status === 'unknown').length, + }, + lastHealthCheck: agents.length > 0 ? + Math.max(...agents.map(a => new Date(a.lastHealthCheck || 0).getTime())) : + null, + }); + } catch (error) { + console.error('Error getting A2A status:', error); + res.status(500).json({ + error: 'Failed to get A2A status', + message: error.message + }); + } +}); + +/** + * Error handling middleware for A2A routes + */ +router.use((error, req, res, next) => { + console.error('A2A Route Error:', error); + + if (res.headersSent) { + return next(error); + } + + res.status(500).json({ + error: 'A2A service error', + message: error.message || 'Unknown error occurred', + path: req.path, + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/api/server/routes/index.js b/api/server/routes/index.js index adaca3859ad4..ac679cf26fcf 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -27,8 +27,10 @@ const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); const mcp = require('./mcp'); +const a2a = require('./a2a'); module.exports = { + a2a, mcp, edit, auth, diff --git a/api/server/services/A2AConfigLoader.js b/api/server/services/A2AConfigLoader.js new file mode 100644 index 000000000000..0aa2852739f1 --- /dev/null +++ b/api/server/services/A2AConfigLoader.js @@ -0,0 +1,337 @@ +const { logger } = require('@librechat/data-schemas'); +const discoveryService = require('./A2ADiscoveryService'); + +/** + * A2A Configuration Loader Service + * Loads A2A agents from librechat.yaml configuration and environment variables + */ +class A2AConfigLoader { + constructor() { + this.isLoaded = false; + this.configuredAgents = new Map(); + } + + /** + * Load A2A configuration from librechat.yaml and environment variables + * @param {Object} customConfig - The loaded librechat.yaml configuration + */ + async loadA2AConfig(customConfig) { + try { + logger.info('Loading A2A configuration...'); + + // Get A2A configuration from customConfig + const a2aConfig = customConfig?.endpoints?.a2a; + + if (!a2aConfig || !a2aConfig.enabled) { + logger.info('A2A endpoint not enabled in configuration'); + return; + } + + // Start discovery service + if (!discoveryService.isRunning) { + discoveryService.start(); + } + + // Load environment-based agents + await this.loadEnvironmentAgents(); + + // Load configured agents from yaml (with startup delay for container readiness) + if (a2aConfig.agents && Array.isArray(a2aConfig.agents)) { + // Add a small delay to allow Docker containers to fully initialize + logger.info('Waiting 3 seconds for A2A agent containers to be ready...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + await this.loadConfiguredAgents(a2aConfig.agents, a2aConfig.defaultOptions); + } + + // Configure discovery settings + if (a2aConfig.discovery) { + this.configureDiscovery(a2aConfig.discovery); + } + + this.isLoaded = true; + logger.info(`A2A configuration loaded successfully. ${this.configuredAgents.size} agents configured.`); + + } catch (error) { + logger.error('Failed to load A2A configuration:', error); + throw error; + } + } + + /** + * Load A2A agents from environment variables + */ + async loadEnvironmentAgents() { + const envAgents = []; + + // Check for additional environment agents + // Pattern: A2A_AGENT__URL and A2A_AGENT__API_KEY + const envKeys = Object.keys(process.env); + const agentUrlPattern = /^A2A_AGENT_(.+)_URL$/; + + for (const key of envKeys) { + const match = key.match(agentUrlPattern); + if (match) { + const agentName = match[1]; + const url = process.env[key]; + const apiKeyEnv = `A2A_AGENT_${agentName}_API_KEY`; + const apiKey = process.env[apiKeyEnv]; + + if (url) { + const agent = { + name: `${agentName.replace(/_/g, ' ')} (Environment)`, + url: url, + authentication: apiKey ? { + type: 'apikey', + credentials: { apikey: apiKey } + } : { type: 'none' }, + source: 'environment' + }; + + envAgents.push(agent); + } + } + } + + // Register environment agents + for (const agent of envAgents) { + try { + let agentId; + + // Special handling for LLM Explorer Agent - bypass HTTP discovery + if (agent.name === 'LLM EXPLORER GRPC (Environment)') { + // Hardcode the LLM Explorer Agent card since gRPC service doesn't serve HTTP + const hardcodedAgentCard = { + protocolVersion: "0.1.0", + id: "llm-explorer-agent-grpc", + name: "LLM Explorer Agent (gRPC)", + description: "Stripe internal LLM Explorer Agent", + version: "1.0.0", + url: agent.url, + preferredTransport: "GRPC", + capabilities: { + streaming: false, + push: false, + multiTurn: true, + taskBased: true, + tools: true + }, + skills: [ + { + id: "llm-foundations", + name: "LLM Foundations Knowledge", + description: "Knowledge about Stripe's LLM Foundations team" + } + ], + endpoints: { + grpcService: agent.url, + }, + securitySchemes: { + none: { + type: "none", + description: "No authentication required for this agent" + } + }, + metadata: { + environment: "development", + purpose: "llm-explorer", + librechat_integration: true, + transport_type: "grpc" + } + }; + + // Register directly with hardcoded agent card + agentId = await discoveryService.registerAgentWithCard( + hardcodedAgentCard, + agent.authentication, + { source: agent.source } + ); + + logger.info(`Registered LLM Explorer Agent with hardcoded card: ${agent.name}`); + } else { + // Normal HTTP discovery for other agents + agentId = await discoveryService.registerAgent( + agent.agentCardUrl, + agent.authentication, + { source: agent.source } + ); + } + + this.configuredAgents.set(agentId, { + ...agent, + id: agentId, + configuredAt: new Date(), + }); + + logger.info(`Registered environment A2A agent: ${agent.name}`); + } catch (error) { + logger.warn(`Failed to register environment agent ${agent.name}:`, error.message); + } + } + } + + /** + * Load A2A agents from yaml configuration + */ + async loadConfiguredAgents(agentConfigs, defaultOptions = {}) { + for (const agentConfig of agentConfigs) { + try { + logger.info(`Registering configured A2A agent: ${agentConfig.name}`); + + // Merge with default options + const options = { + ...defaultOptions, + ...agentConfig.options, + source: 'configuration' + }; + + // Process environment variable substitution in credentials + const authentication = this.processEnvironmentVariables(agentConfig.authentication); + + // Retry logic for agent registration + let agentId = null; + let lastError = null; + const maxRetries = 3; + const retryDelay = 2000; // 2 seconds + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + agentId = await discoveryService.registerAgent( + agentConfig.agentCardUrl, + authentication, + options + ); + break; // Success, exit retry loop + } catch (error) { + lastError = error; + logger.warn(`Attempt ${attempt}/${maxRetries} failed for agent ${agentConfig.name}: ${error.message}`); + + if (attempt < maxRetries) { + logger.info(`Retrying in ${retryDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + } + } + + if (agentId) { + this.configuredAgents.set(agentId, { + ...agentConfig, + id: agentId, + options, + configuredAt: new Date(), + }); + logger.info(`Successfully registered configured A2A agent: ${agentConfig.name}`); + } else { + throw lastError; + } + } catch (error) { + logger.warn(`Failed to register configured agent ${agentConfig.name} after retries:`, error.message); + } + } + } + + /** + * Configure discovery service settings + */ + configureDiscovery(discoveryConfig) { + if (discoveryConfig.refreshInterval) { + discoveryService.healthCheckInterval = discoveryConfig.refreshInterval; + logger.info(`A2A health check interval set to ${discoveryConfig.refreshInterval}ms`); + } + + if (!discoveryConfig.enabled && discoveryService.isRunning) { + logger.info('A2A discovery disabled, stopping discovery service'); + discoveryService.stop(); + } + } + + /** + * Process environment variable substitution in configuration + */ + processEnvironmentVariables(obj) { + if (typeof obj === 'string') { + // Process ${VAR_NAME} pattern + return obj.replace(/\${([^}]+)}/g, (match, varName) => { + return process.env[varName] || match; + }); + } else if (Array.isArray(obj)) { + return obj.map(item => this.processEnvironmentVariables(item)); + } else if (obj && typeof obj === 'object') { + const processed = {}; + for (const [key, value] of Object.entries(obj)) { + processed[key] = this.processEnvironmentVariables(value); + } + return processed; + } + return obj; + } + + /** + * Get all configured agents + */ + getConfiguredAgents() { + return Array.from(this.configuredAgents.values()); + } + + /** + * Get configured agent by ID + */ + getConfiguredAgent(agentId) { + return this.configuredAgents.get(agentId); + } + + /** + * Check if A2A is enabled and loaded + */ + isA2AEnabled() { + return this.isLoaded && discoveryService.isRunning; + } + + /** + * Reload configuration + */ + async reload(customConfig) { + logger.info('Reloading A2A configuration...'); + + // Clear existing configured agents + for (const [agentId, agent] of this.configuredAgents.entries()) { + if (agent.source === 'configuration' || agent.source === 'environment') { + try { + await discoveryService.unregisterAgent(agentId); + } catch (error) { + logger.warn(`Failed to unregister agent ${agentId} during reload:`, error.message); + } + } + } + + this.configuredAgents.clear(); + this.isLoaded = false; + + // Reload configuration + await this.loadA2AConfig(customConfig); + } + + /** + * Get configuration status + */ + getStatus() { + const agents = Array.from(this.configuredAgents.values()); + + return { + enabled: this.isA2AEnabled(), + loaded: this.isLoaded, + discoveryRunning: discoveryService.isRunning, + configuredAgents: agents.length, + agentsBySource: { + environment: agents.filter(a => a.source === 'environment').length, + configuration: agents.filter(a => a.source === 'configuration').length, + manual: agents.filter(a => a.source === 'manual' || !a.source).length, + }, + lastReload: this.isLoaded ? new Date().toISOString() : null, + }; + } +} + +// Singleton instance +const configLoader = new A2AConfigLoader(); + +module.exports = configLoader; \ No newline at end of file diff --git a/api/server/services/A2ADiscoveryService.js b/api/server/services/A2ADiscoveryService.js new file mode 100644 index 000000000000..c5fd495aef32 --- /dev/null +++ b/api/server/services/A2ADiscoveryService.js @@ -0,0 +1,425 @@ +const A2AClient = require('../../app/clients/A2AClient'); + +/** + * A2A Discovery Service for managing external A2A protocol agents + * This service handles agent discovery, registration, and health monitoring + */ +class A2ADiscoveryService { + constructor() { + /** @type {Map} */ + this.registeredAgents = new Map(); + + /** @type {Map} */ + this.agentClients = new Map(); + + this.healthCheckInterval = 5 * 60 * 1000; // 5 minutes + this.healthCheckTimer = null; + this.isRunning = false; + } + + /** + * Start the discovery service + */ + start() { + if (this.isRunning) { + return; + } + + this.isRunning = true; + this.startHealthCheckScheduler(); + console.log('A2A Discovery Service started'); + } + + /** + * Stop the discovery service + */ + stop() { + if (!this.isRunning) { + return; + } + + this.isRunning = false; + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + + // Cleanup agent clients + for (const client of this.agentClients.values()) { + client.destroy(); + } + this.agentClients.clear(); + + console.log('A2A Discovery Service stopped'); + } + + /** + * Discover an A2A agent by fetching its agent card + * @param {string} agentCardUrl - URL to the agent card + * @returns {Promise} + */ + async discoverAgent(agentCardUrl) { + try { + console.log(`Discovering A2A agent at: ${agentCardUrl}`); + + const response = await fetch(agentCardUrl, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'LibreChat-A2A-Discovery/1.0', + }, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const agentCard = await response.json(); + this.validateAgentCard(agentCard); + + console.log(`Successfully discovered agent: ${agentCard.name}`); + return agentCard; + } catch (error) { + console.error(`Failed to discover agent at ${agentCardUrl}:`, error); + throw new Error(`Agent discovery failed: ${error.message}`); + } + } + + /** + * Register an external A2A agent + * @param {string} agentCardUrl - URL to the agent card + * @param {import('../types/a2a').A2AAuthentication} [authentication] - Authentication config + * @param {Object} [options] - Additional options + * @returns {Promise} - Agent ID + */ + async registerAgent(agentCardUrl, authentication = { type: 'none' }, options = {}) { + try { + // Discover the agent card + const agentCard = await this.discoverAgent(agentCardUrl); + + // Generate unique agent ID + const agentId = this.generateAgentId(agentCard.name, agentCard.url); + + // Create agent configuration + const agentConfig = { + id: agentId, + name: agentCard.name, + description: agentCard.description, + agentCardUrl, + agentCard, + preferredTransport: agentCard.preferredTransport, + authentication, + timeout: options.timeout || 30000, + maxRetries: options.maxRetries || 3, + enableStreaming: options.enableStreaming !== false, + enableTasks: options.enableTasks !== false, + status: 'unknown', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Register the agent + this.registeredAgents.set(agentId, agentConfig); + + // Create client instance + const client = new A2AClient(agentConfig); + this.agentClients.set(agentId, client); + + // Perform initial health check + await this.performHealthCheck(agentId); + + console.log(`Registered A2A agent: ${agentCard.name} (${agentId})`); + return agentId; + } catch (error) { + console.error(`Failed to register agent from ${agentCardUrl}:`, error); + throw error; + } + } + + /** + * Register an A2A agent with a pre-built agent card (bypasses HTTP discovery) + * @param {Object} agentCard - Pre-built agent card object + * @param {import('../types/a2a').A2AAuthentication} [authentication] - Authentication config + * @param {Object} [options] - Additional options + * @returns {Promise} - Agent ID + */ + async registerAgentWithCard(agentCard, authentication = { type: 'none' }, options = {}) { + try { + // Generate unique agent ID + const agentId = this.generateAgentId(agentCard.name, agentCard.url); + + // Create agent configuration + const agentConfig = { + id: agentId, + name: agentCard.name, + description: agentCard.description, + // For hardcoded gRPC-only agents, don't set agentCardUrl (indicates no HTTP discovery) + agentCard, + preferredTransport: agentCard.preferredTransport, + authentication, + timeout: options.timeout || 30000, + maxRetries: options.maxRetries || 3, + enableStreaming: options.enableStreaming !== false, + enableTasks: options.enableTasks !== false, + status: 'unknown', + createdAt: new Date(), + updatedAt: new Date(), + }; + + // Register the agent + this.registeredAgents.set(agentId, agentConfig); + + // Create client instance + const client = new A2AClient(agentConfig); + this.agentClients.set(agentId, client); + + // Perform initial health check + await this.performHealthCheck(agentId); + + console.log(`Registered A2A agent: ${agentCard.name} (${agentId})`); + return agentId; + } catch (error) { + console.error(`Failed to register agent with hardcoded card:`, error); + throw error; + } + } + + /** + * Unregister an A2A agent + * @param {string} agentId - Agent identifier + */ + async unregisterAgent(agentId) { + if (!this.registeredAgents.has(agentId)) { + throw new Error(`Agent not found: ${agentId}`); + } + + // Cleanup client + const client = this.agentClients.get(agentId); + if (client) { + client.destroy(); + this.agentClients.delete(agentId); + } + + // Remove from registry + this.registeredAgents.delete(agentId); + + console.log(`Unregistered A2A agent: ${agentId}`); + } + + /** + * Get all registered agents + * @returns {import('../types/a2a').A2AExternalAgent[]} + */ + getRegisteredAgents() { + return Array.from(this.registeredAgents.values()); + } + + /** + * Get registered agent by ID + * @param {string} agentId - Agent identifier + * @returns {import('../types/a2a').A2AExternalAgent | null} + */ + getAgent(agentId) { + return this.registeredAgents.get(agentId) || null; + } + + /** + * Get A2A client for an agent + * @param {string} agentId - Agent identifier + * @returns {A2AClient | null} + */ + getClient(agentId) { + return this.agentClients.get(agentId) || null; + } + + /** + * Get agents by capability + * @param {string} capability - Capability name + * @returns {import('../types/a2a').A2AExternalAgent[]} + */ + getAgentsByCapability(capability) { + const agents = []; + + for (const agent of this.registeredAgents.values()) { + if (agent.agentCard?.capabilities && agent.agentCard.capabilities[capability]) { + agents.push(agent); + } + } + + return agents; + } + + /** + * Get online agents + * @returns {import('../types/a2a').A2AExternalAgent[]} + */ + getOnlineAgents() { + return Array.from(this.registeredAgents.values()).filter( + agent => agent.status === 'online' + ); + } + + /** + * Refresh agent card for a registered agent + * @param {string} agentId - Agent identifier + */ + async refreshAgentCard(agentId) { + const agent = this.registeredAgents.get(agentId); + if (!agent) { + throw new Error(`Agent not found: ${agentId}`); + } + + // Skip refresh for hardcoded agents (no agentCardUrl means no HTTP discovery) + if (!agent.agentCardUrl) { + console.log(`Skipping agent card refresh for hardcoded agent: ${agent.name}`); + return; + } + + try { + const agentCard = await this.discoverAgent(agent.agentCardUrl); + agent.agentCard = agentCard; + agent.updatedAt = new Date(); + + console.log(`Refreshed agent card for: ${agent.name}`); + } catch (error) { + console.error(`Failed to refresh agent card for ${agentId}:`, error); + agent.status = 'error'; + throw error; + } + } + + /** + * Perform health check for an agent + * @param {string} agentId - Agent identifier + */ + async performHealthCheck(agentId) { + const agent = this.registeredAgents.get(agentId); + const client = this.agentClients.get(agentId); + + if (!agent || !client) { + return; + } + + try { + const health = await client.getHealthStatus(); + agent.status = health.status; + agent.lastHealthCheck = health.timestamp; + + if (health.status === 'offline' || health.status === 'error') { + console.warn(`Agent ${agent.name} (${agentId}) is ${health.status}`); + } + } catch (error) { + agent.status = 'error'; + agent.lastHealthCheck = new Date(); + console.error(`Health check failed for agent ${agentId}:`, error); + } + } + + /** + * Perform health checks for all registered agents + */ + async performHealthChecks() { + const agents = Array.from(this.registeredAgents.keys()); + + console.log(`Performing health checks for ${agents.length} A2A agents`); + + const healthCheckPromises = agents.map(agentId => + this.performHealthCheck(agentId).catch(error => + console.error(`Health check failed for ${agentId}:`, error) + ) + ); + + await Promise.all(healthCheckPromises); + } + + /** + * Start health check scheduler + * @private + */ + startHealthCheckScheduler() { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + } + + this.healthCheckTimer = setInterval(async () => { + if (this.isRunning) { + await this.performHealthChecks(); + } + }, this.healthCheckInterval); + } + + /** + * Validate agent card according to A2A specification + * @private + */ + validateAgentCard(card) { + const required = [ + 'protocolVersion', + 'name', + 'description', + 'url', + 'preferredTransport', + 'version', + 'capabilities', + 'skills' + ]; + + for (const field of required) { + if (!card[field]) { + throw new Error(`Invalid agent card: missing required field '${field}'`); + } + } + + // Validate transport protocol + const validTransports = ['JSONRPC', 'HTTP+JSON', 'GRPC']; + if (!validTransports.includes(card.preferredTransport)) { + throw new Error(`Invalid transport protocol: ${card.preferredTransport}`); + } + + // Validate capabilities + if (typeof card.capabilities !== 'object') { + throw new Error('Invalid agent card: capabilities must be an object'); + } + + // Validate skills + if (!Array.isArray(card.skills)) { + throw new Error('Invalid agent card: skills must be an array'); + } + + for (const skill of card.skills) { + if (!skill.id || !skill.name || !skill.description) { + throw new Error('Invalid skill: must have id, name, and description'); + } + } + } + + /** + * Generate unique agent ID + * @private + */ + generateAgentId(name, url) { + const sanitized = name.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const urlHash = this.simpleHash(url); + return `a2a-${sanitized}-${urlHash}`; + } + + /** + * Simple hash function for generating agent IDs + * @private + */ + simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(16).substr(0, 8); + } +} + +// Singleton instance +const discoveryService = new A2ADiscoveryService(); + +module.exports = discoveryService; \ No newline at end of file diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index 5c5bf186e0cb..1c7b9532b8ab 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -10,6 +10,7 @@ const { loadOCRConfig, EModelEndpoint, getConfigDefaults, + logger, } = require('librechat-data-provider'); const { checkWebSearchConfig, @@ -21,6 +22,7 @@ const { initializeAzureBlobService } = require('./Files/Azure/initialize'); const { initializeFirebase } = require('./Files/Firebase/initialize'); const handleRateLimits = require('./Config/handleRateLimits'); const loadCustomConfig = require('./Config/loadCustomConfig'); +const a2aConfigLoader = require('./A2AConfigLoader'); const { loadTurnstileConfig } = require('./start/turnstile'); const { processModelSpecs } = require('./start/modelSpecs'); const { initializeS3 } = require('./Files/S3/initialize'); @@ -113,6 +115,14 @@ const AppService = async () => { checkConfig(config); handleRateLimits(config?.rateLimits); + + // Load A2A configuration + try { + await a2aConfigLoader.loadA2AConfig(config); + } catch (error) { + logger.warn('A2A configuration loading failed:', error.message); + } + const loadedEndpoints = loadEndpoints(config, agentsDefaults); const appConfig = { diff --git a/api/server/services/Config/EndpointService.js b/api/server/services/Config/EndpointService.js index d8277dd67f76..055d990e4a6a 100644 --- a/api/server/services/Config/EndpointService.js +++ b/api/server/services/Config/EndpointService.js @@ -49,5 +49,6 @@ module.exports = { ), /* key will be part of separate config */ [EModelEndpoint.agents]: generateConfig('true', undefined, EModelEndpoint.agents), + [EModelEndpoint.a2a]: generateConfig('true', undefined, EModelEndpoint.a2a), }, }; diff --git a/api/server/services/Config/getEndpointsConfig.js b/api/server/services/Config/getEndpointsConfig.js index 081f63d1da1b..f2b794c4649e 100644 --- a/api/server/services/Config/getEndpointsConfig.js +++ b/api/server/services/Config/getEndpointsConfig.js @@ -73,6 +73,19 @@ async function getEndpointsConfig(req) { }; } + if (mergedConfig[EModelEndpoint.a2a] && appConfig?.endpoints?.[EModelEndpoint.a2a]) { + const { enabled, discovery, agents, defaultOptions, ..._rest } = + appConfig.endpoints[EModelEndpoint.a2a]; + + mergedConfig[EModelEndpoint.a2a] = { + ...mergedConfig[EModelEndpoint.a2a], + enabled, + discovery, + agents, + defaultOptions, + }; + } + if ( mergedConfig[EModelEndpoint.azureAssistants] && appConfig?.endpoints?.[EModelEndpoint.azureAssistants] diff --git a/api/server/services/Config/loadDefaultEConfig.js b/api/server/services/Config/loadDefaultEConfig.js index f3c12a493347..06594426924a 100644 --- a/api/server/services/Config/loadDefaultEConfig.js +++ b/api/server/services/Config/loadDefaultEConfig.js @@ -16,6 +16,7 @@ async function loadDefaultEndpointsConfig(appConfig) { const endpointConfig = { [EModelEndpoint.openAI]: config[EModelEndpoint.openAI], [EModelEndpoint.agents]: config[EModelEndpoint.agents], + [EModelEndpoint.a2a]: config[EModelEndpoint.a2a], [EModelEndpoint.assistants]: assistants, [EModelEndpoint.azureAssistants]: azureAssistants, [EModelEndpoint.azureOpenAI]: azureOpenAI, diff --git a/api/server/services/Endpoints/a2a/build.js b/api/server/services/Endpoints/a2a/build.js new file mode 100644 index 000000000000..95ae6b7f372c --- /dev/null +++ b/api/server/services/Endpoints/a2a/build.js @@ -0,0 +1,41 @@ +const { removeNullishValues } = require('librechat-data-provider'); + +/** + * Build options for A2A (Agent-to-Agent) protocol endpoints + * A2A is for external agent communication, not LibreChat's internal agents + */ +const buildOptions = (endpoint, parsedBody, endpointType) => { + const { + modelLabel, + promptPrefix, + maxContextTokens, + fileTokenLimit, + resendFiles = false, + iconURL, + greeting, + spec, + model, // A2A agent ID (from model spec preset) + agent_id, // A2A agent identifier + instructions, + ...modelOptions + } = parsedBody; + + return removeNullishValues({ + endpoint, + endpointType, + modelLabel, + promptPrefix, + resendFiles, + iconURL, + greeting, + spec, + model, // A2A agent ID + maxContextTokens, + fileTokenLimit, + agent_id, + instructions, + modelOptions, + }); +}; + +module.exports = buildOptions; \ No newline at end of file diff --git a/api/server/services/Endpoints/a2a/index.js b/api/server/services/Endpoints/a2a/index.js new file mode 100644 index 000000000000..98b848a69915 --- /dev/null +++ b/api/server/services/Endpoints/a2a/index.js @@ -0,0 +1,5 @@ +const buildOptions = require('./build'); + +module.exports = { + buildOptions, +}; \ No newline at end of file diff --git a/api/server/services/Endpoints/agents/a2a.js b/api/server/services/Endpoints/agents/a2a.js new file mode 100644 index 000000000000..165fd97aa853 --- /dev/null +++ b/api/server/services/Endpoints/agents/a2a.js @@ -0,0 +1,61 @@ +const { EModelEndpoint } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); +const discoveryService = require('../../A2ADiscoveryService'); + +/** + * Initialize A2A agent for use with LibreChat's agents system + * @param {Object} params + * @param {Object} params.req - Request object + * @param {Object} params.res - Response object + * @param {Object} params.endpointOption - Endpoint configuration + * @returns {Object} A2A agent configuration + */ +const initializeA2AAgent = async ({ req, res, endpointOption }) => { + const { spec, model } = endpointOption; + + // Extract agent ID from the model field (A2A agents are represented as models) + console.log('initializeA2AAgent input - spec:', spec, 'model:', model); + const agentId = model || spec; // Use model (agent ID) if available, fallback to spec + + // Get the A2A agent details + const agent = discoveryService.getAgent(agentId); + console.log('A2A agent', agent); + if (!agent) { + throw new Error(`A2A agent not found: ${agentId}`); + } + + // Check agent status + if (agent.status !== 'online') { + throw new Error(`A2A agent is not available. Status: ${agent.status}`); + } + + // Get the A2A client + const a2aClient = discoveryService.getClient(agentId); + if (!a2aClient) { + throw new Error(`A2A client not available for agent: ${agentId}`); + } + + logger.info(`Initialized A2A agent: ${agent.name} (${agentId})`); + + // Return agent configuration compatible with LibreChat's agents system + return { + id: agentId, + name: agent.name, + description: agent.description, + endpoint: EModelEndpoint.a2a, + model: agentId, + provider: 'a2a', + a2aClient: a2aClient, + a2aAgent: agent, + // Agent capabilities + tools: [], + tool_resources: {}, + attachments: [], + resendFiles: true, + maxContextTokens: 100000, // Default context window + }; +}; + +module.exports = { + initializeA2AAgent, +}; \ No newline at end of file diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 7cc0a39fba95..f2b3660d0bb4 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -12,9 +12,11 @@ const { getDefaultHandlers, } = require('~/server/controllers/agents/callbacks'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); +const { initializeA2AAgent } = require('~/server/services/Endpoints/agents/a2a'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); +const A2AAgentClient = require('~/server/controllers/agents/A2AAgentClient'); const { getAgent } = require('~/models/Agent'); const { logViolation } = require('~/cache'); @@ -73,6 +75,48 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedUsage, }); + // Handle A2A endpoint differently + if (endpointOption.endpoint === EModelEndpoint.a2a) { + const primaryAgent = await initializeA2AAgent({ req, res, endpointOption }); + + if (!primaryAgent) { + throw new Error('A2A agent not found'); + } + + // Create a simple client for A2A that uses LibreChat's conversation system + const client = new A2AAgentClient({ + req, + res, + contentParts, + eventHandlers, + collectedUsage, + aggregateContent, + artifactPromises, + agent: primaryAgent, + spec: endpointOption.spec, + iconURL: endpointOption.iconURL, + endpointType: endpointOption.endpointType, + endpoint: EModelEndpoint.a2a, // Keep A2A endpoint for proper conversation management + }); + + // If there's a conversationId, check for active tasks and recover if needed + const conversationId = req.body.conversationId; + if (conversationId && conversationId !== 'new') { + logger.info(`A2A Agent Initialization - checking for active tasks in conversation: ${conversationId}`); + try { + await client.recoverActiveTasks(conversationId, req.user); + logger.info(`A2A Agent Initialization - task recovery completed for conversation: ${conversationId}`); + } catch (error) { + logger.warn(`A2A Agent Initialization - failed to recover tasks for conversation ${conversationId}:`, error); + // Don't fail initialization if task recovery fails + } + } else { + logger.debug(`A2A Agent Initialization - no task recovery needed (conversationId: ${conversationId})`); + } + + return { client, userMCPAuthMap: {} }; + } + if (!endpointOption.agent) { throw new Error('No agent promise provided'); } diff --git a/api/server/stripe/patch-fetch.js b/api/server/stripe/patch-fetch.js index b082a7428f5e..f07cad2cd3c1 100644 --- a/api/server/stripe/patch-fetch.js +++ b/api/server/stripe/patch-fetch.js @@ -76,8 +76,19 @@ function formatUrl(url) { function formatHeaders(headers) { const obj = {}; - for (const [key, value] of headers) { - obj[key] = redactValue(key, value); + + // Handle Headers object or other iterable + if (headers && typeof headers[Symbol.iterator] === 'function') { + for (const [key, value] of headers) { + obj[key] = redactValue(key, value); + } + } + // Handle plain object + else if (headers && typeof headers === 'object') { + for (const [key, value] of Object.entries(headers)) { + obj[key] = redactValue(key, value); + } } + return obj; } diff --git a/api/server/types/a2a.js b/api/server/types/a2a.js new file mode 100644 index 000000000000..c1712be9b165 --- /dev/null +++ b/api/server/types/a2a.js @@ -0,0 +1,121 @@ +/** + * A2A Protocol Type Definitions for External Agents + * These are separate from LibreChat's internal agent system + */ + +/** + * @typedef {Object} A2ACapabilities + * @property {boolean} [streaming] - Supports streaming responses + * @property {boolean} [push] - Supports push notifications + * @property {boolean} [multiTurn] - Supports multi-turn conversations + * @property {boolean} [taskBased] - Supports task-based workflows + * @property {boolean} [tools] - Supports external tools + */ + +/** + * @typedef {Object} A2ASkill + * @property {string} id - Unique skill identifier + * @property {string} name - Human-readable skill name + * @property {string} description - Skill description + * @property {string[]} [inputModes] - Supported input modes + * @property {string[]} [outputModes] - Supported output modes + */ + +/** + * @typedef {Object} A2ASecurityScheme + * @property {'apikey'|'oauth2'|'openid'|'http'|'mutual_tls'} type - Authentication type + * @property {string} [scheme] - HTTP authentication scheme + * @property {string} [bearerFormat] - Bearer token format + * @property {Object} [flows] - OAuth2 flows configuration + * @property {string} [openIdConnectUrl] - OpenID Connect URL + */ + +/** + * @typedef {Object} A2AAgentCard + * @property {string} protocolVersion - A2A protocol version + * @property {string} name - Agent name + * @property {string} description - Agent description + * @property {string} url - Agent endpoint URL + * @property {'JSONRPC'|'HTTP+JSON'|'GRPC'} preferredTransport - Preferred transport protocol + * @property {string} version - Agent version + * @property {A2ACapabilities} capabilities - Agent capabilities + * @property {A2ASkill[]} skills - Agent skills + * @property {Object.} [securitySchemes] - Security schemes + * @property {Object} [metadata] - Additional metadata + */ + +/** + * @typedef {Object} A2AAuthentication + * @property {'apikey'|'oauth2'|'openid'|'http'|'mutual_tls'|'none'} type - Auth type + * @property {Object.} [credentials] - Authentication credentials + * @property {Object.} [headers] - Custom headers + */ + +/** + * @typedef {Object} A2AExternalAgent + * @property {string} id - Unique agent identifier + * @property {string} name - Agent display name + * @property {string} description - Agent description + * @property {string} agentCardUrl - URL to agent card + * @property {A2AAgentCard} [agentCard] - Cached agent card data + * @property {'JSONRPC'|'HTTP+JSON'|'GRPC'} preferredTransport - Transport protocol + * @property {A2AAuthentication} authentication - Authentication configuration + * @property {number} [timeout] - Request timeout in ms + * @property {number} [maxRetries] - Maximum retry attempts + * @property {boolean} [enableStreaming] - Enable streaming responses + * @property {boolean} [enableTasks] - Enable task-based workflows + * @property {'online'|'offline'|'error'|'unknown'} status - Current status + * @property {Date} [lastHealthCheck] - Last health check timestamp + * @property {Date} createdAt - Agent registration timestamp + * @property {Date} updatedAt - Last update timestamp + */ + +/** + * @typedef {Object} A2ATask + * @property {string} id - Unique task identifier + * @property {string} contextId - Context identifier for related tasks + * @property {'submitted'|'working'|'completed'|'failed'|'canceled'} status - Task status + * @property {string} [statusMessage] - Optional status message + * @property {A2AMessage[]} history - Message history + * @property {A2AArtifact[]} artifacts - Generated artifacts + * @property {Object} [metadata] - Task metadata + * @property {Date} createdAt - Task creation timestamp + * @property {Date} updatedAt - Last update timestamp + */ + +/** + * @typedef {Object} A2AMessage + * @property {'user'|'agent'} role - Message role + * @property {A2APart[]} parts - Message parts + * @property {Date} timestamp - Message timestamp + */ + +/** + * @typedef {Object} A2APart + * @property {'text'|'file'|'data'} type - Part type + * @property {string|Buffer|Object} content - Part content + * @property {Object} [metadata] - Part metadata + */ + +/** + * @typedef {Object} A2AArtifact + * @property {string} id - Artifact identifier + * @property {string} type - Artifact type + * @property {string} name - Artifact name + * @property {Object} content - Artifact content + * @property {Date} createdAt - Creation timestamp + */ + +/** + * @typedef {Object} A2AResponse + * @property {boolean} success - Response success status + * @property {Object} [data] - Response data + * @property {string} [error] - Error message if failed + * @property {A2ATask} [task] - Task information for task-based responses + * @property {A2AMessage} [message] - Direct message response + * @property {A2AArtifact[]} [artifacts] - Response artifacts + */ + +module.exports = { + // Export types for JSDoc usage +}; \ No newline at end of file diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 91c2ff25ecaa..752fca916939 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -3,6 +3,7 @@ const { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint, + isA2AEndpoint, defaultRetrievalModels, defaultAssistantsVersion, defaultAgentCapabilities, @@ -182,6 +183,8 @@ function generateConfig(key, baseURL, endpoint) { const assistants = isAssistantsEndpoint(endpoint); const agents = isAgentsEndpoint(endpoint); + const a2a = isA2AEndpoint(endpoint); + if (assistants) { config.retrievalModels = defaultRetrievalModels; config.capabilities = [ @@ -197,6 +200,11 @@ function generateConfig(key, baseURL, endpoint) { config.capabilities = defaultAgentCapabilities; } + if (a2a) { + config.type = EModelEndpoint.a2a; + config.capabilities = ['messaging', 'streaming']; + } + if (assistants && endpoint === EModelEndpoint.azureAssistants) { config.version = defaultAssistantsVersion.azureAssistants; } else if (assistants) { diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index a4527d56e755..5797bf492df3 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -1,6 +1,6 @@ import debounce from 'lodash/debounce'; import React, { createContext, useContext, useState, useMemo } from 'react'; -import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint, isA2AEndpoint } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { Endpoint, SelectedValues } from '~/common'; import { @@ -156,6 +156,8 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector onSelectSpec?.(spec); if (isAgentsEndpoint(spec.preset.endpoint)) { model = spec.preset.agent_id ?? ''; + } else if (isA2AEndpoint(spec.preset.endpoint)) { + model = spec.preset.agent_id ?? ''; } else if (isAssistantsEndpoint(spec.preset.endpoint)) { model = spec.preset.assistant_id ?? ''; } @@ -185,6 +187,11 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector agent_id: model, model: agentsMap?.[model]?.model ?? '', }); + } else if (isA2AEndpoint(endpoint.value)) { + onSelectEndpoint?.(endpoint.value, { + agent_id: model, + model: model, + }); } else if (isAssistantsEndpoint(endpoint.value)) { onSelectEndpoint?.(endpoint.value, { assistant_id: model, diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 6541383f3913..51119b835b72 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { SettingsIcon } from 'lucide-react'; import { TooltipAnchor, Spinner } from '@librechat/client'; -import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint, isA2AEndpoint } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; import { useModelSelectorContext } from '../ModelSelectorContext'; @@ -110,7 +110,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { ) : null; const placeholder = - isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value) + isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value) || isA2AEndpoint(endpoint.value) ? localize('com_endpoint_search_var', { 0: endpoint.label }) : localize('com_endpoint_search_endpoint_models', { 0: endpoint.label }); return ( diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 6a9b6fd3364d..5e9b63d8ae3b 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { EarthIcon } from 'lucide-react'; -import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import { isAgentsEndpoint, isAssistantsEndpoint, isA2AEndpoint } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; @@ -43,7 +43,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
{modelName
- ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && + ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value) || isA2AEndpoint(endpoint.value)) && endpoint.icon ? (
{endpoint.icon} diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index ffefbc44d42e..80b1653aed66 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from 'react'; import { EarthIcon } from 'lucide-react'; -import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import { isAgentsEndpoint, isAssistantsEndpoint, isA2AEndpoint } from 'librechat-data-provider'; import type { TModelSpec } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { useModelSelectorContext } from '../ModelSelectorContext'; @@ -109,6 +109,12 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP endpoint.agentNames[model.name] ) { modelName = endpoint.agentNames[model.name]; + } else if ( + isA2AEndpoint(endpoint.value) && + endpoint.agentNames && + endpoint.agentNames[model.name] + ) { + modelName = endpoint.agentNames[model.name]; } else if ( isAssistantsEndpoint(endpoint.value) && endpoint.assistantNames && @@ -146,6 +152,14 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP modelName = endpoint.agentNames[modelId]; const modelInfo = endpoint?.models?.find((m) => m.name === modelId); isGlobal = modelInfo?.isGlobal ?? false; + } else if ( + isA2AEndpoint(endpoint.value) && + endpoint.agentNames && + endpoint.agentNames[modelId] + ) { + modelName = endpoint.agentNames[modelId]; + const modelInfo = endpoint?.models?.find((m) => m.name === modelId); + isGlobal = modelInfo?.isGlobal ?? false; } else if ( isAssistantsEndpoint(endpoint.value) && endpoint.assistantNames && diff --git a/client/src/components/Endpoints/MessageEndpointIcon.tsx b/client/src/components/Endpoints/MessageEndpointIcon.tsx index 0a9782ce9941..c04edeaaf65c 100644 --- a/client/src/components/Endpoints/MessageEndpointIcon.tsx +++ b/client/src/components/Endpoints/MessageEndpointIcon.tsx @@ -1,5 +1,5 @@ import { memo } from 'react'; -import { Feather } from 'lucide-react'; +import { Feather, Network } from 'lucide-react'; import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider'; import { Plugin, @@ -131,6 +131,16 @@ const MessageEndpointIcon: React.FC = (props) => { } = { [EModelEndpoint.assistants]: assistantsIcon, [EModelEndpoint.agents]: agentsIcon, + [EModelEndpoint.a2a]: { + icon: ( +
+
+ +
+
+ ), + name: 'A2A Agents', + }, [EModelEndpoint.azureAssistants]: assistantsIcon, [EModelEndpoint.azureOpenAI]: { icon: , diff --git a/client/src/components/Endpoints/MinimalIcon.tsx b/client/src/components/Endpoints/MinimalIcon.tsx index f21cad1e0811..5f0c324dc552 100644 --- a/client/src/components/Endpoints/MinimalIcon.tsx +++ b/client/src/components/Endpoints/MinimalIcon.tsx @@ -1,4 +1,4 @@ -import { Feather } from 'lucide-react'; +import { Feather, Network } from 'lucide-react'; import { EModelEndpoint, alternateName } from 'librechat-data-provider'; import { AzureMinimalIcon, @@ -50,6 +50,10 @@ const MinimalIcon: React.FC = (props) => { icon: , name: props.modelLabel ?? alternateName[EModelEndpoint.agents], }, + [EModelEndpoint.a2a]: { + icon: , + name: props.modelLabel ?? 'A2A Agents', + }, [EModelEndpoint.bedrock]: { icon: , name: props.modelLabel ?? alternateName[EModelEndpoint.bedrock], diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 4ea6ef7168c2..eb0e6c6bb9e3 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -89,6 +89,7 @@ export default function AgentPanel() { !isAssistantsEndpoint(key) && (allowedProviders.size > 0 ? allowedProviders.has(key) : true) && key !== EModelEndpoint.agents && + key !== EModelEndpoint.a2a && key !== EModelEndpoint.chatGPTBrowser && key !== EModelEndpoint.gptPlugins, ) diff --git a/client/src/hooks/A2A/index.ts b/client/src/hooks/A2A/index.ts new file mode 100644 index 000000000000..c9469f3880cf --- /dev/null +++ b/client/src/hooks/A2A/index.ts @@ -0,0 +1,15 @@ +export { useA2AAgents, useA2AStatus, useA2ADiscovery } from './useA2AAgents'; +export { useA2AChat } from './useA2AChat'; + +// Export types for external use +export type { + A2AAgent, + RegisterAgentParams, + RegisterAgentResponse +} from './useA2AAgents'; + +export type { + A2AChatMessage, + A2AChatOptions, + A2AChatState +} from './useA2AChat'; \ No newline at end of file diff --git a/client/src/hooks/A2A/useA2AAgents.ts b/client/src/hooks/A2A/useA2AAgents.ts new file mode 100644 index 000000000000..03a1bc7d6f6b --- /dev/null +++ b/client/src/hooks/A2A/useA2AAgents.ts @@ -0,0 +1,323 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; + +interface A2AAgent { + id: string; + name: string; + description: string; + status: 'online' | 'offline' | 'error' | 'unknown'; + capabilities: { + streaming?: boolean; + push?: boolean; + multiTurn?: boolean; + taskBased?: boolean; + tools?: boolean; + }; + skills: Array<{ + id: string; + name: string; + description: string; + }>; + transport: 'JSONRPC' | 'HTTP+JSON' | 'GRPC'; + lastHealthCheck?: string; + createdAt: string; +} + +interface A2AAgentsResponse { + agents: A2AAgent[]; +} + +interface RegisterAgentParams { + agentCardUrl: string; + authentication?: { + type: 'none' | 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls'; + credentials?: Record; + headers?: Record; + }; + options?: { + timeout?: number; + maxRetries?: number; + enableStreaming?: boolean; + enableTasks?: boolean; + }; +} + +interface RegisterAgentResponse { + success: boolean; + agentId: string; + agent: { + id: string; + name: string; + description: string; + status: string; + }; +} + +/** + * Hook for managing A2A agents + */ +export const useA2AAgents = () => { + const queryClient = useQueryClient(); + + // Fetch all A2A agents + const { + data: agentsData, + isLoading: loading, + error: queryError, + refetch, + } = useQuery({ + queryKey: ['a2a-agents'], + queryFn: async () => { + const response = await fetch('/api/a2a/agents', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch A2A agents: ${response.status} ${response.statusText}`); + } + + return response.json(); + }, + refetchInterval: 30000, // Refetch every 30 seconds + staleTime: 10000, // Consider data stale after 10 seconds + }); + + const agents = agentsData?.agents || []; + const error = queryError?.message || null; + + // Register new agent mutation + const registerMutation = useMutation({ + mutationFn: async (params) => { + const response = await fetch('/api/a2a/agents/register', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Failed to register agent: ${response.status}`); + } + + return response.json(); + }, + onSuccess: () => { + // Invalidate and refetch agents list + queryClient.invalidateQueries({ queryKey: ['a2a-agents'] }); + }, + }); + + // Unregister agent mutation + const unregisterMutation = useMutation({ + mutationFn: async (agentId) => { + const response = await fetch(`/api/a2a/agents/${agentId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Failed to unregister agent: ${response.status}`); + } + }, + onSuccess: () => { + // Invalidate and refetch agents list + queryClient.invalidateQueries({ queryKey: ['a2a-agents'] }); + }, + }); + + // Health check mutation + const healthCheckMutation = useMutation({ + mutationFn: async (agentId) => { + const response = await fetch(`/api/a2a/agents/${agentId}/health`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Health check failed: ${response.status}`); + } + }, + onSuccess: () => { + // Invalidate and refetch agents list to get updated health status + queryClient.invalidateQueries({ queryKey: ['a2a-agents'] }); + }, + }); + + // Refresh agent card mutation + const refreshAgentMutation = useMutation({ + mutationFn: async (agentId) => { + const response = await fetch(`/api/a2a/agents/${agentId}/refresh`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Failed to refresh agent: ${response.status}`); + } + }, + onSuccess: () => { + // Invalidate and refetch agents list + queryClient.invalidateQueries({ queryKey: ['a2a-agents'] }); + }, + }); + + // Callbacks + const refreshAgents = useCallback(() => { + refetch(); + }, [refetch]); + + const registerAgent = useCallback(async (params: RegisterAgentParams) => { + return registerMutation.mutateAsync(params); + }, [registerMutation]); + + const unregisterAgent = useCallback(async (agentId: string) => { + return unregisterMutation.mutateAsync(agentId); + }, [unregisterMutation]); + + const performHealthCheck = useCallback(async (agentId: string) => { + return healthCheckMutation.mutateAsync(agentId); + }, [healthCheckMutation]); + + const refreshAgent = useCallback(async (agentId: string) => { + return refreshAgentMutation.mutateAsync(agentId); + }, [refreshAgentMutation]); + + // Filter helpers + const getOnlineAgents = useCallback(() => { + return agents.filter(agent => agent.status === 'online'); + }, [agents]); + + const getAgentsByCapability = useCallback((capability: keyof A2AAgent['capabilities']) => { + return agents.filter(agent => agent.capabilities[capability]); + }, [agents]); + + const getAgentsBySkill = useCallback((skillName: string) => { + return agents.filter(agent => + agent.skills.some(skill => + skill.name.toLowerCase().includes(skillName.toLowerCase()) + ) + ); + }, [agents]); + + return { + // Data + agents, + loading, + error, + + // Actions + refreshAgents, + registerAgent, + unregisterAgent, + performHealthCheck, + refreshAgent, + + // Filters + getOnlineAgents, + getAgentsByCapability, + getAgentsBySkill, + + // Mutation states + isRegistering: registerMutation.isPending, + registerError: registerMutation.error?.message || null, + isUnregistering: unregisterMutation.isPending, + unregisterError: unregisterMutation.error?.message || null, + isPerformingHealthCheck: healthCheckMutation.isPending, + healthCheckError: healthCheckMutation.error?.message || null, + isRefreshingAgent: refreshAgentMutation.isPending, + refreshAgentError: refreshAgentMutation.error?.message || null, + }; +}; + +/** + * Hook for A2A service status + */ +export const useA2AStatus = () => { + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['a2a-status'], + queryFn: async () => { + const response = await fetch('/api/a2a/status', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch A2A status: ${response.status}`); + } + + return response.json(); + }, + refetchInterval: 60000, // Refetch every minute + }); + + return { + status: data || null, + loading: isLoading, + error: error?.message || null, + refresh: refetch, + }; +}; + +/** + * Hook for discovering agents at URLs + */ +export const useA2ADiscovery = () => { + const [isDiscovering, setIsDiscovering] = useState(false); + const [discoveryError, setDiscoveryError] = useState(null); + + const discoverAgent = useCallback(async (agentCardUrl: string) => { + setIsDiscovering(true); + setDiscoveryError(null); + + try { + const response = await fetch('/api/a2a/discover', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ agentCardUrl }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Discovery failed: ${response.status}`); + } + + const result = await response.json(); + return result.agentCard; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown discovery error'; + setDiscoveryError(errorMessage); + throw error; + } finally { + setIsDiscovering(false); + } + }, []); + + return { + discoverAgent, + isDiscovering, + discoveryError, + }; +}; \ No newline at end of file diff --git a/client/src/hooks/A2A/useA2AChat.ts b/client/src/hooks/A2A/useA2AChat.ts new file mode 100644 index 000000000000..fe86d1243424 --- /dev/null +++ b/client/src/hooks/A2A/useA2AChat.ts @@ -0,0 +1,446 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +interface A2AChatMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + timestamp: Date; + agentId?: string; + agentName?: string; + taskId?: string; + artifacts?: Array<{ + id: string; + type: string; + name: string; + content: unknown; + }>; + metadata?: Record; +} + +interface A2AChatOptions { + agentId: string; + taskBased?: boolean; + streaming?: boolean; + conversationId?: string; +} + +interface A2AChatState { + messages: A2AChatMessage[]; + isLoading: boolean; + error: string | null; + conversationId: string | null; + currentTaskId: string | null; + connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; +} + +/** + * Hook for A2A chat communication + */ +export const useA2AChat = () => { + const [chatState, setChatState] = useState({ + messages: [], + isLoading: false, + error: null, + conversationId: null, + currentTaskId: null, + connectionStatus: 'disconnected', + }); + + const eventSourceRef = useRef(null); + const abortControllerRef = useRef(null); + + // Cleanup function + const cleanup = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + + /** + * Send message to A2A agent + */ + const sendMessage = useCallback(async ( + message: string, + options: A2AChatOptions + ): Promise => { + const { agentId, taskBased = false, streaming = true, conversationId } = options; + + // Cleanup any existing connections + cleanup(); + + setChatState(prev => ({ + ...prev, + isLoading: true, + error: null, + connectionStatus: 'connecting', + })); + + // Add user message to state + const userMessage: A2AChatMessage = { + id: uuidv4(), + role: 'user', + text: message, + timestamp: new Date(), + agentId, + }; + + setChatState(prev => ({ + ...prev, + messages: [...prev.messages, userMessage], + conversationId: conversationId || prev.conversationId || uuidv4(), + })); + + try { + const token = localStorage.getItem('token'); + if (!token) { + throw new Error('Authentication token not found'); + } + + if (streaming) { + // Use Server-Sent Events for streaming + await handleStreamingResponse(message, options, token); + } else { + // Use regular HTTP request + await handleRegularResponse(message, options, token); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + + setChatState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage, + connectionStatus: 'error', + })); + } + }, [cleanup]); + + /** + * Handle streaming response via SSE + */ + const handleStreamingResponse = async ( + message: string, + options: A2AChatOptions, + token: string + ): Promise => { + return new Promise((resolve, reject) => { + const { agentId, taskBased = false, conversationId } = options; + + // Create abort controller for this request + abortControllerRef.current = new AbortController(); + + // Prepare request data + const requestData = { + agentId, + message, + taskBased, + streaming: true, + conversationId: conversationId || chatState.conversationId, + }; + + // Create EventSource-like functionality with fetch + fetch('/api/a2a/chat', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + }, + body: JSON.stringify(requestData), + signal: abortControllerRef.current.signal, + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + if (!response.body) { + throw new Error('Response body is null'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let assistantMessage: A2AChatMessage | null = null; + + const processStream = async (): Promise => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + await handleStreamEvent(data, assistantMessage, resolve, reject); + + // Update assistant message reference + if (data.type === 'created' || data.created) { + assistantMessage = { + id: data.message?.messageId || uuidv4(), + role: 'assistant', + text: '', + timestamp: new Date(), + agentId: options.agentId, + }; + } + } catch (parseError) { + console.warn('Failed to parse SSE data:', parseError); + } + } + } + } + } catch (streamError) { + if (streamError.name !== 'AbortError') { + reject(streamError); + } + } + }; + + processStream(); + }) + .catch(reject); + }); + }; + + /** + * Handle regular HTTP response + */ + const handleRegularResponse = async ( + message: string, + options: A2AChatOptions, + token: string + ): Promise => { + const { agentId, taskBased = false, conversationId } = options; + + const requestData = { + agentId, + message, + taskBased, + streaming: false, + conversationId: conversationId || chatState.conversationId, + }; + + const response = await fetch('/api/a2a/chat', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.message || `Request failed: ${response.status}`); + } + + const responseData = await response.json(); + + // Add response message + const assistantMessage: A2AChatMessage = { + id: responseData.responseMessage?.messageId || uuidv4(), + role: 'assistant', + text: responseData.responseMessage?.text || 'No response received', + timestamp: new Date(), + agentId: options.agentId, + metadata: responseData.responseMessage?.metadata, + }; + + setChatState(prev => ({ + ...prev, + messages: [...prev.messages, assistantMessage], + isLoading: false, + connectionStatus: 'connected', + conversationId: responseData.conversation?.conversationId || prev.conversationId, + })); + }; + + /** + * Handle individual stream events + */ + const handleStreamEvent = async ( + data: any, + assistantMessage: A2AChatMessage | null, + resolve: (value: void) => void, + reject: (reason?: any) => void + ): Promise => { + switch (data.type) { + case 'connection': + setChatState(prev => ({ + ...prev, + connectionStatus: 'connected', + error: null, + })); + break; + + case 'created': + if (data.created) { + setChatState(prev => ({ + ...prev, + conversationId: data.conversationId || prev.conversationId, + })); + } + break; + + case 'content': + if (assistantMessage && data.text) { + setChatState(prev => { + const updatedMessages = [...prev.messages]; + let messageIndex = updatedMessages.findIndex(msg => msg.id === assistantMessage.id); + + if (messageIndex === -1) { + // Add new assistant message + updatedMessages.push({ + ...assistantMessage, + text: data.text, + }); + } else { + // Update existing message + updatedMessages[messageIndex] = { + ...updatedMessages[messageIndex], + text: updatedMessages[messageIndex].text + data.text, + }; + } + + return { + ...prev, + messages: updatedMessages, + }; + }); + } + break; + + case 'task_created': + setChatState(prev => ({ + ...prev, + currentTaskId: data.taskId, + })); + break; + + case 'task_update': + // Handle task status updates + console.log('Task update:', data); + break; + + case 'final': + if (data.final) { + const finalMessage: A2AChatMessage = { + id: data.responseMessage?.messageId || uuidv4(), + role: 'assistant', + text: data.responseMessage?.text || 'Response completed', + timestamp: new Date(), + agentId: data.responseMessage?.metadata?.agentId, + agentName: data.responseMessage?.metadata?.agentName, + taskId: data.responseMessage?.metadata?.taskId, + artifacts: data.responseMessage?.metadata?.artifacts, + metadata: data.responseMessage?.metadata, + }; + + setChatState(prev => ({ + ...prev, + messages: [...prev.messages.filter(msg => msg.id !== finalMessage.id), finalMessage], + isLoading: false, + connectionStatus: 'connected', + conversationId: data.conversation?.conversationId || prev.conversationId, + })); + + resolve(); + } + break; + + case 'error': + default: + if (data.error) { + const errorMessage = data.message || 'Unknown error occurred'; + setChatState(prev => ({ + ...prev, + error: errorMessage, + isLoading: false, + connectionStatus: 'error', + })); + reject(new Error(errorMessage)); + } + break; + } + }; + + /** + * Clear chat history + */ + const clearChat = useCallback(() => { + cleanup(); + setChatState({ + messages: [], + isLoading: false, + error: null, + conversationId: null, + currentTaskId: null, + connectionStatus: 'disconnected', + }); + }, [cleanup]); + + /** + * Cancel current request + */ + const cancelRequest = useCallback(() => { + cleanup(); + setChatState(prev => ({ + ...prev, + isLoading: false, + connectionStatus: 'disconnected', + })); + }, [cleanup]); + + /** + * Retry last message + */ + const retryLastMessage = useCallback((options: A2AChatOptions) => { + const lastUserMessage = chatState.messages + .filter(msg => msg.role === 'user') + .pop(); + + if (lastUserMessage) { + // Remove any assistant messages after the last user message + const messageIndex = chatState.messages.findIndex(msg => msg.id === lastUserMessage.id); + setChatState(prev => ({ + ...prev, + messages: prev.messages.slice(0, messageIndex + 1), + error: null, + })); + + // Resend the message + sendMessage(lastUserMessage.text, options); + } + }, [chatState.messages, sendMessage]); + + return { + // State + messages: chatState.messages, + isLoading: chatState.isLoading, + error: chatState.error, + conversationId: chatState.conversationId, + currentTaskId: chatState.currentTaskId, + connectionStatus: chatState.connectionStatus, + + // Actions + sendMessage, + clearChat, + cancelRequest, + retryLastMessage, + }; +}; \ No newline at end of file diff --git a/client/src/hooks/A2A/useA2ASSE.ts b/client/src/hooks/A2A/useA2ASSE.ts new file mode 100644 index 000000000000..c7d09492bab8 --- /dev/null +++ b/client/src/hooks/A2A/useA2ASSE.ts @@ -0,0 +1,77 @@ +// A2A SSE helpers: keep A2A-specific parsing/logging out of the generic useSSE hook. + +export function extractMetaFromFinal(data: any): { + endpoint?: string; + taskId?: string; + agentId?: string; + conversationId?: string; + } { + try { + const endpoint = data?.responseMessage?.endpoint as string | undefined; + const taskId = data?.responseMessage?.metadata?.taskId as string | undefined; + const agentId = data?.responseMessage?.metadata?.agentId as string | undefined; + const conversationId = data?.conversation?.conversationId as string | undefined; + return { endpoint, taskId, agentId, conversationId }; + } catch (_e) { + return {}; + } + } + + // Debug helper to log created events with minimal noise. + export function logA2ACreated( + data: any, + getMessages: () => Array<{ messageId: string; parentMessageId?: string | null; conversationId?: string | null }> | undefined, + ) { + try { + const msg = data?.message ?? {}; + const current = getMessages?.() ?? []; + const last = current.length ? current[current.length - 1] : null; + // Keep logs compact and consistent for troubleshooting forks + // eslint-disable-next-line no-console + console.log('[A2A][SSE] created event', { + incoming: { + messageId: msg?.messageId, + parentMessageId: msg?.parentMessageId, + conversationId: msg?.conversationId, + }, + currentLast: last + ? { + messageId: last.messageId, + parentMessageId: last.parentMessageId, + conversationId: last.conversationId, + } + : null, + }); + } catch (_e) { + // no-op + } + } + + // Debug helper to log final events with request/response linkage for A2A tasks. + export function logA2AFinal(data: any) { + try { + const rm = data?.responseMessage ?? {}; + const reqm = data?.requestMessage ?? {}; + // eslint-disable-next-line no-console + console.log('[A2A][SSE] final event', { + endpoint: rm?.endpoint, + request: { + messageId: reqm?.messageId, + parentMessageId: reqm?.parentMessageId, + conversationId: reqm?.conversationId, + }, + response: { + messageId: rm?.messageId, + parentMessageId: rm?.parentMessageId, + conversationId: rm?.conversationId, + metadata: rm?.metadata, + }, + convo: data?.conversation?.conversationId, + }); + } catch (_e) { + // no-op + } + } + + + \ No newline at end of file diff --git a/client/src/hooks/A2A/useA2ATaskPolling.ts b/client/src/hooks/A2A/useA2ATaskPolling.ts new file mode 100644 index 000000000000..a74624a3be6b --- /dev/null +++ b/client/src/hooks/A2A/useA2ATaskPolling.ts @@ -0,0 +1,154 @@ +import { useEffect, useRef } from 'react'; +import { v4 } from 'uuid'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; + +type StartParams = { + taskId: string; + agentId: string; + conversationId?: string | null; + getMessages: () => TMessage[] | undefined; + setMessages: (messages: TMessage[]) => void; +}; + +// Polls A2A task status and triggers UI to refetch server-saved messages. +// We intentionally avoid injecting local placeholder messages because +// server decides authoritative parentMessageId, preventing mid-task forks. +export default function useA2ATaskPolling() { + const queryClient = useQueryClient(); + const cancelRef = useRef(false); + const timerRef = useRef(null); + const backoffRef = useRef(3000); + const lastStateRef = useRef>({}); + + // Stop polling loop and reset backoff. + const stop = () => { + cancelRef.current = true; + if (timerRef.current != null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + backoffRef.current = 3000; + }; + + const start = ({ taskId, agentId, conversationId, getMessages, setMessages }: StartParams) => { + stop(); + cancelRef.current = false; + lastStateRef.current[taskId] = {}; + + const poll = async () => { + if (cancelRef.current) { + return; + } + try { + const res = await fetch( + `/api/a2a/tasks/${encodeURIComponent(taskId)}/status?agentId=${encodeURIComponent(agentId)}`, + { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }, + ); + if (res.ok) { + const json = await res.json(); + const status = json?.task?.status as string | undefined; + const statusMessage = json?.task?.statusMessage as string | undefined; + if (status) { + const statusText = `Task ${taskId}: ${status}${statusMessage ? ` — ${statusMessage}` : ''}`; + const current = getMessages() ?? []; + const last = lastStateRef.current[taskId]; + const changed = !last || last.status !== status || last.statusMessage !== statusMessage; + + if (changed) { + //Append a lightweight local placeholder + // so the user sees progress immediately; server-saved messages will + // normalize on completion/refresh. + const parentId = current.length ? current[current.length - 1].messageId : null; + const messageId = v4(); + const statusMsg: TMessage = { + messageId, + conversationId: (conversationId as string) ?? null, + parentMessageId: parentId, + role: 'assistant', + text: statusText, + isCreatedByUser: false, + } as TMessage; + const next = [...current, statusMsg]; + setMessages(next); + if (conversationId) { + queryClient.setQueryData([QueryKeys.messages, conversationId], next); + } + lastStateRef.current[taskId] = { status, statusMessage, messageId }; + } + + if (status === 'completed' || status === 'failed' || status === 'canceled') { + if (conversationId) { + queryClient.invalidateQueries({ queryKey: [QueryKeys.messages, conversationId] }); + } + stop(); + return; + } + } + } + } catch (_e) { + // ignore transient errors + } + + const delay = Math.min(backoffRef.current, 30000); + backoffRef.current = Math.min(delay * 2, 30000); + timerRef.current = window.setTimeout(poll, delay); + }; + + timerRef.current = window.setTimeout(poll, 0); + }; + + return { start, stop }; +} + + +// Optional: stop polling on full page unload to avoid orphaned loops. +export function useA2APollingOnUnload() { + const cancelRef = useRef<() => void>(); + const attach = (stop: () => void) => (cancelRef.current = stop); + useEffect(() => { + const onBeforeUnload = () => { + try { + cancelRef.current?.(); + } catch {} + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeunload); + }, []); + return { attach }; +} +// Small helper to wire A2A polling from a final SSE event. +// Keeps A2A logic out of the generic useSSE hook. +export function startPollingFromFinalEvent( + a2a: + | { + start: (args: { + taskId: string; + agentId: string; + conversationId?: string | null; + getMessages: () => TMessage[] | undefined; + setMessages: (messages: TMessage[]) => void; + }) => void; + } + | null, + data: any, + getMessages: () => TMessage[] | undefined, + setMessages: (messages: TMessage[]) => void, +) { + try { + const endpoint = data?.responseMessage?.endpoint as string | undefined; + const taskId = data?.responseMessage?.metadata?.taskId as string | undefined; + const agentId = data?.responseMessage?.metadata?.agentId as string | undefined; + const conversationId = data?.conversation?.conversationId as string | undefined; + + if (a2a && endpoint === 'a2a' && taskId && agentId) { + a2a.start({ taskId, agentId, conversationId, getMessages, setMessages }); + } + } catch (_e) { + // no-op + } +} diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 1d860fbb7ae7..ab9eccbbc6dc 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -560,6 +560,12 @@ export default function useEventHandlers({ if (!cachedConvo) { queryClient.setQueryData([QueryKeys.conversation, conversation.conversationId], update); } + // Ensure new conversations appear in the sidebar immediately + try { + if (isNewConvo) { + addConvoToAllQueries(queryClient, update); + } + } catch {} return update; }); @@ -576,6 +582,13 @@ export default function useEventHandlers({ } } + // Ensure new conversations appear in sidebar even if created-handler path was skipped + try { + if (isNewConvo && requestMessage?.parentMessageId === Constants.NO_PARENT) { + addConvoToAllQueries(queryClient, conversation as TConversation); + } + } catch {} + setIsSubmitting(false); }, [ diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 397f5623601f..b739a60a77e4 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -1,10 +1,14 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import useA2ATaskPolling, { startPollingFromFinalEvent } from '~/hooks/A2A/useA2ATaskPolling'; +import { extractMetaFromFinal, logA2ACreated, logA2AFinal } from '~/hooks/A2A/useA2ASSE'; import { v4 } from 'uuid'; import { SSE } from 'sse.js'; import { useSetRecoilState } from 'recoil'; import { request, Constants, + QueryKeys, /* @ts-ignore */ createPayload, LocalStorageKeys, @@ -45,6 +49,7 @@ export default function useSSE( runIndex = 0, ) { const genTitle = useGenTitleMutation(); + const queryClient = useQueryClient(); const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex)); const { token, isAuthenticated } = useAuthContext(); @@ -61,6 +66,9 @@ export default function useSSE( resetLatestMessage, } = chatHelpers; + const a2aPolling = useA2ATaskPolling(); + + const { clearStepMaps, stepHandler, @@ -118,6 +126,9 @@ export default function useSSE( } }); + // Main SSE handler: routes server events to existing chat handlers. + // For A2A tasks: we only start polling after the final event, + // and we avoid mutating local message parents to prevent permanent forks. sse.addEventListener('message', (e: MessageEvent) => { const data = JSON.parse(e.data); @@ -126,11 +137,19 @@ export default function useSSE( const { plugins } = data; finalHandler(data, { ...submission, plugins } as EventSubmission); (startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch(); - console.log('final', data); + // Debug aid: log A2A final for troubleshooting + logA2AFinal(data); + + // A2A-only: use extracted meta and delegate to helper + startPollingFromFinalEvent(a2aPolling, data, getMessages, setMessages); return; } else if (data.created != null) { const runId = v4(); setActiveRunId(runId); + // Debug aid: log A2A created + logA2ACreated(data, getMessages); + // Do not override parents here. The server determines final parentage + // to avoid mid-task forks. We simply forward the created event. userMessage = { ...userMessage, ...data.message, @@ -222,6 +241,8 @@ export default function useSSE( sse.stream(); return () => { + // Do not stop A2A polling here; starting a new request should not cancel + // background task polling. Polling stops on terminal status or page unload. const isCancelled = sse.readyState <= 1; sse.close(); if (isCancelled) { @@ -233,3 +254,19 @@ export default function useSSE( // eslint-disable-next-line react-hooks/exhaustive-deps }, [submission]); } + +// Ensure background polling is halted on full page unload/navigation +// without interfering with in-app sync chats between polls +export function useA2APollingOnUnload() { + const a2aPolling = useA2ATaskPolling(); + useEffect(() => { + const onBeforeUnload = () => { + a2aPolling.stop(); + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => { + window.removeEventListener('beforeunload', onBeforeUnload); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/client/src/utils/endpoints.ts b/client/src/utils/endpoints.ts index 60585132d4bc..bcca177d7ae8 100644 --- a/client/src/utils/endpoints.ts +++ b/client/src/utils/endpoints.ts @@ -5,6 +5,7 @@ import { LocalStorageKeys, isAgentsEndpoint, isAssistantsEndpoint, + isA2AEndpoint, } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { LocalizeFunction, IconsRecord } from '~/common'; diff --git a/containers/mock-a2a-agent-grpc/Dockerfile b/containers/mock-a2a-agent-grpc/Dockerfile new file mode 100644 index 000000000000..31d3365b87ee --- /dev/null +++ b/containers/mock-a2a-agent-grpc/Dockerfile @@ -0,0 +1,40 @@ +# Use Node.js 18 Alpine for smaller image size +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Install system dependencies for gRPC +RUN apk add --no-cache \ + curl \ + && rm -rf /var/cache/apk/* + +# Copy package files +COPY containers/mock-a2a-agent-grpc/package*.json ./ + +# Install Node.js dependencies +RUN npm install --production && npm cache clean --force + +# Copy application files +COPY containers/mock-a2a-agent-grpc/server.js ./ +COPY proto/ ../../proto/ + +# Create non-root user for security +RUN addgroup -g 1001 -S a2agrpc && \ + adduser -S a2agrpc -u 1001 -G a2agrpc + +# Change ownership of app directory +RUN chown -R a2agrpc:a2agrpc /app + +# Switch to non-root user +USER a2agrpc + +# Expose gRPC port +EXPOSE 3002 + +# Health check (gRPC health check is more complex, so we'll use process check) +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD pgrep -f "node server.js" || exit 1 + +# Start the gRPC server +CMD ["node", "server.js"] diff --git a/containers/mock-a2a-agent-grpc/package.json b/containers/mock-a2a-agent-grpc/package.json new file mode 100644 index 000000000000..5f798cdc16f5 --- /dev/null +++ b/containers/mock-a2a-agent-grpc/package.json @@ -0,0 +1,21 @@ +{ + "name": "mock-a2a-agent-grpc", + "version": "1.0.0", + "description": "Mock gRPC A2A agent for testing LibreChat gRPC transport", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "@grpc/grpc-js": "^1.9.0", + "@grpc/proto-loader": "^0.7.8", + "@grpc/reflection": "^1.0.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "nodemon": "^3.0.0" + }, + "author": "LibreChat", + "license": "MIT" +} diff --git a/containers/mock-a2a-agent-grpc/server.js b/containers/mock-a2a-agent-grpc/server.js new file mode 100644 index 000000000000..bdc0c2c218d2 --- /dev/null +++ b/containers/mock-a2a-agent-grpc/server.js @@ -0,0 +1,338 @@ +const grpc = require('@grpc/grpc-js'); +const protoLoader = require('@grpc/proto-loader'); +const { v4: uuidv4 } = require('uuid'); +const path = require('path'); +const http = require('http'); + +// For gRPC reflection support +const grpcReflection = require('@grpc/reflection'); + +// Load the protobuf definition from submodule +const PROTO_PATH = path.join(__dirname, '../../proto/a2a/grpc/a2a.proto'); +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [ + path.join(__dirname, '../../proto/googleapis'), // Google API protos + path.join(__dirname, '../../proto/protobuf/src'), // Core Google protos + path.join(__dirname, '../../proto/a2a/grpc') // A2A proto directory + ] +}); + +// Official A2A proto now uses Stripe package name +const a2aProto = grpc.loadPackageDefinition(packageDefinition).com.stripe.agent.a2a.v1; + +/** + * Mock A2A gRPC Agent Server + * Implements the Stripe A2A gRPC interface for testing + */ +class MockA2AAgentGrpc { + constructor() { + this.server = new grpc.Server(); + this.tasks = new Map(); // Store tasks for potential future retrieval + this.setupHandlers(); + } + + setupHandlers() { + // Add the A2A service to the server + this.server.addService(a2aProto.A2AService.service, { + SendMessage: this.handleSendMessage.bind(this) + }); + + // Enable gRPC reflection for grpcurl compatibility + const reflection = new grpcReflection.ReflectionService(packageDefinition); + reflection.addToServer(this.server); + } + + /** + * Handle SendMessage gRPC call (Official A2A Protocol) + * @param {Object} call - gRPC call object + * @param {Function} callback - Response callback + */ + handleSendMessage(call, callback) { + const request = call.request; + + console.log('šŸ“Ø Received grpc A2A SendMessage request (Official Protocol):'); + console.log(' Request:', JSON.stringify(request, null, 2)); + + // Extract the message from the official A2A format + const message = request.request; // The Message object + const configuration = request.configuration || {}; + const metadata = request.metadata || {}; + + // Extract text content from the message + let textContent = message?.text || ''; + if (message?.content && Array.isArray(message.content)) { + const textParts = message.content.filter(c => c.type === 'text').map(c => c.value || c.text); + if (textParts.length > 0) { + textContent = textParts.join(' '); + } + } + + console.log(`šŸ¤– Mock gRPC Agent processing: "${textContent}"`); + console.log(`šŸ“‹ Configuration:`, configuration); + console.log(`šŸ·ļø Metadata:`, metadata); + + // Generate response message using official A2A format + const responseText = `Mock gRPC Agent received: "${textContent}" via official A2A protocol`; + const messageId = uuidv4(); + const contextId = request.context_id || uuidv4(); + const timestamp = new Date().toISOString(); + + // Create response message using official A2A format + const responseMessage = { + id: messageId, + conversation_id: message?.conversation_id || contextId, + text: responseText, + sender: 'Mock gRPC Agent', + timestamp: { + seconds: Math.floor(Date.now() / 1000), + nanos: (Date.now() % 1000) * 1000000 + }, + metadata: { + processed_via: 'grpc', + protocol: 'a2a_official', + original_text: textContent + }, + content: [ + { + type: 'text', + value: responseText, + metadata: {} + } + ] + }; + + // Return direct message response (not a task) using official A2A format + const response = { + msg: responseMessage // Use 'msg' field as per official A2A SendMessageResponse + }; + + console.log('šŸ“¤ Sending A2A response:'); + console.log(' Message ID:', messageId); + console.log(' Text:', responseText); + + // Send A2A protocol response + callback(null, response); + } + + /** + * Create HTTP server for agent card discovery + */ + createHttpServer() { + const httpPort = process.env.PORT || 3002; + const grpcPort = parseInt(httpPort) + 1000; // gRPC on 4002 + + const agentCard = { + protocolVersion: "0.1.0", + name: "Mock gRPC Agent", + description: "A mock gRPC agent for testing LibreChat gRPC transport with Stripe agent-srv compatibility", + version: "1.0.0", + url: `http://mock-a2a-agent-grpc:${httpPort}`, + preferredTransport: "GRPC", + capabilities: { + streaming: false, + push: false, + multiTurn: true, + taskBased: true, + tools: false + }, + skills: [ + { + id: "grpc-conversation", + name: "gRPC Conversational AI", + description: "Engage in natural language conversations via gRPC transport", + inputModes: ["text"], + outputModes: ["text"] + }, + { + id: "grpc-task-processing", + name: "gRPC Task Processing", + description: "Handle task-based workflows using gRPC SendMessage protocol", + inputModes: ["text"], + outputModes: ["text", "status"] + }, + { + id: "grpc-protocol-demo", + name: "gRPC Protocol Demonstration", + description: "Demonstrate A2A protocol features over gRPC transport", + inputModes: ["text"], + outputModes: ["text", "status"] + } + ], + endpoints: { + grpcService: `mock-a2a-agent-grpc:${grpcPort}`, // gRPC endpoint without http:// + health: "/health" + }, + securitySchemes: { + none: { + type: "none", + description: "No authentication required for this mock gRPC agent" + } + }, + metadata: { + environment: "development", + purpose: "grpc-testing", + librechat_integration: true, + mock_agent: true, + transport_type: "grpc" + } + }; + + const httpServer = http.createServer((req, res) => { + if (req.url === '/.well-known/agent-card' && req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }); + res.end(JSON.stringify(agentCard, null, 2)); + } else if (req.url === '/health' && req.method === 'GET') { + // Health check endpoint for LibreChat + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'mock-a2a-agent-grpc', + timestamp: new Date().toISOString(), + grpcPort: grpcPort, + httpPort: httpPort + })); + // } else if (req.url === '/v1/message:send' && req.method === 'POST') { + // // HTTP-to-gRPC gateway endpoint for A2AClient + // let body = ''; + // req.on('data', chunk => { + // body += chunk.toString(); + // }); + // req.on('end', async () => { + // try { + // const requestData = JSON.parse(body); + // console.log('🌐 HTTP-to-gRPC gateway received:', requestData); + + // // Make internal gRPC call (simulate gRPC client call) + // const mockGrpcResponse = { + // task: { + // id: require('crypto').randomUUID(), + // context_id: requestData.contextId || require('crypto').randomUUID(), + // status: { + // state: 'TASK_STATE_SUBMITTED', + // timestamp: new Date().toISOString() + // } + // } + // }; + + // // Convert gRPC response back to A2A format + // const a2aResponse = { + // success: true, + // message: { + // messageId: mockGrpcResponse.task.id, + // conversationId: mockGrpcResponse.task.context_id, + // text: `gRPC response: Task ${mockGrpcResponse.task.status.state}`, + // sender: 'Mock gRPC Agent', + // timestamp: mockGrpcResponse.task.status.timestamp + // }, + // contextId: mockGrpcResponse.task.context_id + // }; + + // console.log('šŸš€ HTTP-to-gRPC gateway responding:', a2aResponse); + + // res.writeHead(200, { + // 'Content-Type': 'application/json', + // 'Access-Control-Allow-Origin': '*' + // }); + // res.end(JSON.stringify(a2aResponse)); + // } catch (error) { + // console.error('āŒ HTTP-to-gRPC gateway error:', error); + // res.writeHead(500, { 'Content-Type': 'application/json' }); + // res.end(JSON.stringify({ error: error.message })); + // } + // }); + } else if (req.method === 'OPTIONS') { + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }); + res.end(); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + }); + + return httpServer; + } + + /** + * Start the gRPC server + */ + start() { + const httpPort = process.env.PORT || 3002; + const grpcPort = parseInt(httpPort) + 1000; // gRPC on 4002 + const host = process.env.HOST || '0.0.0.0'; + const grpcAddress = `${host}:${grpcPort}`; + + // Start gRPC server on internal port + this.server.bindAsync( + grpcAddress, + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) { + console.error('āŒ Failed to start gRPC server:', err); + process.exit(1); + } + + console.log(`šŸš€ Mock A2A gRPC Agent running on ${grpcAddress}`); + console.log(`šŸ“” Service: a2a.v1.A2AService`); + console.log(`šŸ”§ Method: SendMessage`); + console.log(`šŸ“Š Proto file: ${PROTO_PATH}`); + + this.server.start(); + + // Start HTTP server for agent card discovery on main port + const httpServer = this.createHttpServer(); + httpServer.listen(httpPort, host, () => { + console.log(`🌐 HTTP server for agent card running on http://${host}:${httpPort}`); + console.log(`šŸ”— Agent card available at: http://${host}:${httpPort}/.well-known/agent-card`); + }); + + // Log some example commands + console.log('\nšŸ“‹ Test commands:'); + console.log(`grpcurl -plaintext -d '{"request":{"text":"hello","sender":"test","content":[{"type":"text","value":"hello"}]},"configuration":{"blocking":false}}' localhost:${port} a2a.v1.A2AService/SendMessage`); + console.log('\nšŸ”— Use this URL in LibreChat A2A config:'); + console.log(`http://localhost:${port}`); + } + ); + } + + /** + * Graceful shutdown + */ + shutdown() { + console.log('šŸ›‘ Shutting down gRPC server...'); + this.server.tryShutdown((error) => { + if (error) { + console.error('Error during shutdown:', error); + this.server.forceShutdown(); + } else { + console.log('āœ… Server shut down gracefully'); + } + }); + } +} + +// Create and start the server +const mockAgent = new MockA2AAgentGrpc(); + +// Handle graceful shutdown +process.on('SIGINT', () => mockAgent.shutdown()); +process.on('SIGTERM', () => mockAgent.shutdown()); + +// Start the server +mockAgent.start(); diff --git a/containers/mock-a2a-agent/.dockerignore b/containers/mock-a2a-agent/.dockerignore new file mode 100644 index 000000000000..a7d47fcf0e1f --- /dev/null +++ b/containers/mock-a2a-agent/.dockerignore @@ -0,0 +1,11 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +README.md +.env +.nyc_output +coverage +.cache \ No newline at end of file diff --git a/containers/mock-a2a-agent/Dockerfile b/containers/mock-a2a-agent/Dockerfile new file mode 100644 index 000000000000..5b92efa2f234 --- /dev/null +++ b/containers/mock-a2a-agent/Dockerfile @@ -0,0 +1,49 @@ +# Mock A2A Agent Container +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Install dependencies for better container security and debugging +RUN apk add --no-cache \ + curl \ + && rm -rf /var/cache/apk/* + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --production && npm cache clean --force + +# Copy application code +COPY server.js ./ + +# Create non-root user for security +RUN addgroup -g 1001 -S a2a && \ + adduser -S a2a -u 1001 -G a2a + +# Change ownership of app directory +RUN chown -R a2a:a2a /app +USER a2a + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3001/health || exit 1 + +# Environment variables +ENV NODE_ENV=production +ENV PORT=3001 +ENV HOST=0.0.0.0 + +# Labels for better container management +LABEL maintainer="LibreChat A2A Integration" +LABEL version="1.0.0" +LABEL description="Mock A2A Protocol Agent for LibreChat Integration" +LABEL a2a.protocol.version="0.1.0" +LABEL a2a.agent.type="mock" + +# Start the server +CMD ["node", "server.js"] \ No newline at end of file diff --git a/containers/mock-a2a-agent/README.md b/containers/mock-a2a-agent/README.md new file mode 100644 index 000000000000..2321bb44a26b --- /dev/null +++ b/containers/mock-a2a-agent/README.md @@ -0,0 +1,205 @@ +# Mock A2A Agent + +This is a mock A2A (Agent-to-Agent) protocol agent designed for LibreChat integration testing and development. + +## Features + +- **A2A Protocol Compliance**: Implements the A2A protocol specification +- **Multiple Transports**: Supports JSON-RPC and REST API endpoints +- **Task Workflows**: Handles both direct messages and task-based interactions +- **Conversation Context**: Maintains conversation history across interactions +- **Mock Responses**: Generates contextual responses based on input +- **Health Monitoring**: Built-in health check and status endpoints +- **Docker Support**: Containerized for easy deployment + +## Quick Start + +### Local Development + +```bash +# Install dependencies +npm install + +# Start the server +npm start + +# For development with auto-restart +npm run dev +``` + +### Docker + +```bash +# Build the container +docker build -t mock-a2a-agent . + +# Run the container +docker run -p 8080:8080 mock-a2a-agent +``` + +### Docker Compose (with LibreChat) + +Add to your `docker-compose.yml`: + +```yaml +services: + mock-a2a-agent: + build: ./containers/mock-a2a-agent + ports: + - "8080:8080" + environment: + - AGENT_URL=http://mock-a2a-agent:8080 + - CORS_ORIGIN=http://localhost:3080 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +## API Endpoints + +### A2A Protocol Endpoints + +- `GET /.well-known/agent-card` - Agent card (A2A specification) +- `POST /jsonrpc` - JSON-RPC endpoint for A2A communication +- `POST /v1/message/send` - REST endpoint for direct messages +- `POST /v1/tasks/create` - REST endpoint for task creation +- `GET /v1/tasks/:taskId` - Get task status +- `DELETE /v1/tasks/:taskId` - Cancel task + +### Utility Endpoints + +- `GET /health` - Health check +- `GET /status` - Agent status and statistics + +## Agent Card + +The agent advertises the following capabilities: + +```json +{ + "protocolVersion": "0.1.0", + "name": "Mock HTTP Agent", + "description": "A mock A2A protocol agent for LibreChat integration testing", + "preferredTransport": "JSONRPC", + "capabilities": { + "streaming": true, + "multiTurn": true, + "taskBased": true + }, + "skills": [ + { + "id": "conversation", + "name": "Conversational AI", + "description": "Engage in natural language conversations" + }, + { + "id": "task-processing", + "name": "Task Processing", + "description": "Handle complex, multi-step tasks" + } + ] +} +``` + +## Usage Examples + +### Direct Message (JSON-RPC) + +```bash +curl -X POST http://localhost:8080/jsonrpc \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "content": "Hello!"}] + }, + "contextId": "test-context" + }, + "id": 1 + }' +``` + +### Task Creation + +```bash +curl -X POST http://localhost:8080/jsonrpc \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "tasks/create", + "params": { + "message": { + "role": "user", + "parts": [{"type": "text", "content": "Analyze this data"}] + }, + "contextId": "task-context" + }, + "id": 2 + }' +``` + +### LibreChat Registration + +To register this mock agent with LibreChat: + +```bash +curl -X POST http://localhost:3080/api/a2a/agents/register \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{ + "agentCardUrl": "http://mock-a2a-agent:8080/.well-known/agent-card", + "authentication": { + "type": "none" + }, + "options": { + "enableStreaming": true, + "enableTasks": true + } + }' +``` + +## Environment Variables + +- `PORT` - Server port (default: 8080) +- `HOST` - Server host (default: 0.0.0.0) +- `AGENT_URL` - Public URL for the agent (used in agent card) +- `CORS_ORIGIN` - CORS allowed origins +- `NODE_ENV` - Node environment (development/production) + +## Mock Behavior + +The mock agent provides intelligent responses based on input: + +- **Greetings**: Responds to hello, hi, hey with friendly greetings +- **Questions**: Handles what/how questions with informative responses +- **A2A Protocol**: Explains A2A protocol when asked +- **Tasks**: Processes task-based workflows with realistic delays +- **Context**: Maintains conversation history and context +- **Artifacts**: Generates mock artifacts for task-based interactions + +## Development + +The mock agent is designed for: + +- **Testing A2A Integration**: Verify LibreChat's A2A protocol implementation +- **Development**: Develop A2A features without external dependencies +- **Debugging**: Test message flows, task workflows, and error handling +- **Demo**: Demonstrate A2A protocol capabilities + +## Limitations + +This is a mock implementation for testing purposes: + +- No actual AI/LLM integration +- Responses are generated programmatically +- No persistent storage (data is lost on restart) +- Limited to demonstration use cases + +## License + +MIT License - See LICENSE file for details. \ No newline at end of file diff --git a/containers/mock-a2a-agent/package.json b/containers/mock-a2a-agent/package.json new file mode 100644 index 000000000000..ceb69ff0a839 --- /dev/null +++ b/containers/mock-a2a-agent/package.json @@ -0,0 +1,28 @@ +{ + "name": "mock-a2a-agent", + "version": "1.0.0", + "description": "Mock A2A protocol agent for LibreChat integration testing", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js" + }, + "dependencies": { + "@a2a-js/sdk": "latest", + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.0.0" + }, + "devDependencies": { + "nodemon": "^3.0.0" + }, + "keywords": [ + "a2a", + "agent", + "protocol", + "librechat", + "mock" + ], + "author": "LibreChat A2A Integration", + "license": "MIT" +} \ No newline at end of file diff --git a/containers/mock-a2a-agent/server.js b/containers/mock-a2a-agent/server.js new file mode 100644 index 000000000000..d551c5465cbb --- /dev/null +++ b/containers/mock-a2a-agent/server.js @@ -0,0 +1,544 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +// Note: @a2a-js/sdk would be installed in a real implementation +// For this mock, we'll simulate the SDK functionality + +/** + * Mock A2A Agent Server + * Simulates an A2A protocol-compliant agent for LibreChat integration testing + */ + +// Mock A2A SDK classes +class MockAgentExecutor { + constructor() { + this.conversationHistory = new Map(); + this.activeWorkflows = new Map(); + } + + /** + * Execute a message and return a response + */ + async execute(message, contextId) { + console.log(`Processing message for context ${contextId}:`, message); + + // Simulate processing time + await this.delay(500 + Math.random() * 1000); + + // Get or create conversation history + const history = this.conversationHistory.get(contextId) || []; + + // Add user message to history + history.push({ + role: 'user', + content: message.parts[0].content, + timestamp: new Date(), + }); + + // Generate response based on message content + const response = this.generateResponse(message.parts[0].content, history); + + // Add response to history + history.push({ + role: 'agent', + content: response, + timestamp: new Date(), + }); + + // Store updated history + this.conversationHistory.set(contextId, history.slice(-20)); // Keep last 20 messages + + return { + role: 'agent', + parts: [{ type: 'text', content: response }] + }; + } + + /** + * Create a task-based workflow + */ + async createTask(message, contextId) { + const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + console.log(`Creating task ${taskId} for context ${contextId}`); + + const task = { + id: taskId, + contextId, + status: 'working', + statusMessage: 'Processing your request...', + history: [ + { + role: 'user', + parts: [{ type: 'text', content: message.parts[0].content }], + timestamp: new Date(), + } + ], + artifacts: [], + createdAt: new Date(), + }; + + this.activeWorkflows.set(taskId, task); + + // Simulate async processing + this.processTaskAsync(taskId, message.parts[0].content); + + return task; + } + + /** + * Get task status + */ + async getTaskStatus(taskId) { + const task = this.activeWorkflows.get(taskId); + if (!task) { + throw new Error(`Task not found: ${taskId}`); + } + + return task; + } + + /** + * Cancel a task + */ + async cancelTask(taskId) { + const task = this.activeWorkflows.get(taskId); + if (task) { + task.status = 'canceled'; + task.statusMessage = 'Task was canceled by user'; + } + return { success: true }; + } + + /** + * Process task asynchronously + */ + async processTaskAsync(taskId, content) { + const task = this.activeWorkflows.get(taskId); + if (!task) return; + + try { + // Simulate multi-step processing + await this.delay(2000); + + task.status = 'working'; + task.statusMessage = 'Analyzing request...'; + + await this.delay(3000); + + task.status = 'working'; + task.statusMessage = 'Generating response...'; + + await this.delay(2000); + + // Generate final response + const response = this.generateResponse(content, [], true); + + task.history.push({ + role: 'agent', + parts: [{ type: 'text', content: response }], + timestamp: new Date(), + }); + + // Add artifacts for demonstration + task.artifacts.push({ + id: `artifact-${Date.now()}`, + type: 'text/analysis', + name: 'Response Analysis', + content: { + wordCount: response.split(' ').length, + sentiment: 'positive', + topics: ['conversation', 'assistance'], + }, + createdAt: new Date(), + }); + + task.status = 'completed'; + task.statusMessage = 'Task completed successfully'; + task.updatedAt = new Date(); + + } catch (error) { + task.status = 'failed'; + task.statusMessage = `Task failed: ${error.message}`; + task.updatedAt = new Date(); + } + } + + /** + * Generate response based on input + */ + generateResponse(input, history = [], isTask = false) { + const lowerInput = input.toLowerCase(); + + // Context-aware responses based on conversation history + const recentMessages = history.slice(-3).map(h => h.content).join(' ').toLowerCase(); + + // Task-based responses + if (isTask) { + if (lowerInput.includes('analyze') || lowerInput.includes('analysis')) { + return `I've completed a detailed analysis of your request: "${input}". Based on my processing, I found several key insights and have generated a comprehensive response with supporting artifacts.`; + } + + if (lowerInput.includes('create') || lowerInput.includes('generate')) { + return `I've successfully created the requested content based on your specifications: "${input}". The generated output has been processed through multiple validation steps and is ready for your review.`; + } + + return `Task completed successfully! I've processed your request: "${input}" and generated a comprehensive response with detailed analysis and supporting documentation.`; + } + + // Greeting responses + if (lowerInput.includes('hello') || lowerInput.includes('hi') || lowerInput.includes('hey')) { + const greetings = [ + 'Hello! I\'m your A2A protocol agent. How can I assist you today?', + 'Hi there! I\'m ready to help with your tasks using the A2A protocol.', + 'Hey! Welcome to the A2A agent interface. What would you like to work on?' + ]; + return greetings[Math.floor(Math.random() * greetings.length)]; + } + + // Question responses + if (lowerInput.includes('what') || lowerInput.includes('how') || lowerInput.includes('?')) { + if (lowerInput.includes('a2a') || lowerInput.includes('protocol')) { + return 'The A2A (Agent-to-Agent) protocol enables interoperable communication between AI agents. I\'m a mock implementation that demonstrates the protocol\'s capabilities including message exchange, task management, and streaming responses.'; + } + + if (lowerInput.includes('capabilities') || lowerInput.includes('can you do')) { + return 'I can handle various tasks including: direct conversations, task-based workflows, multi-turn interactions, and artifact generation. I support both synchronous and asynchronous processing modes as defined by the A2A protocol specification.'; + } + + return 'That\'s an interesting question! Based on my A2A protocol implementation, I can provide information, process tasks, and maintain conversation context. What specific aspect would you like me to elaborate on?'; + } + + // Task-related responses + if (lowerInput.includes('task') || lowerInput.includes('workflow')) { + return 'I can help you with task-based workflows! I support creating tasks, tracking their progress, and providing status updates throughout the process. Would you like me to create a task for your request?'; + } + + // Help responses + if (lowerInput.includes('help') || lowerInput.includes('assistance')) { + return 'I\'m here to help! I\'m a mock A2A protocol agent that can:\n- Engage in conversational interactions\n- Process task-based requests\n- Maintain conversation context\n- Generate structured responses and artifacts\n\nWhat would you like assistance with?'; + } + + // Context-aware responses + if (recentMessages.includes('thank')) { + return 'You\'re very welcome! I\'m glad I could help. Is there anything else you\'d like me to assist you with using the A2A protocol?'; + } + + // Programming/technical responses + if (lowerInput.includes('code') || lowerInput.includes('program') || lowerInput.includes('technical')) { + return 'I can help with technical discussions! While I\'m a mock implementation, I demonstrate how A2A protocol agents can handle technical queries, provide code insights, and support development workflows.'; + } + + // Default response with context + const responses = [ + `I understand you mentioned: "${input}". As an A2A protocol agent, I'm processing your request and can provide assistance across various domains.`, + `Thanks for your message about "${input}". I'm here to help using the A2A protocol's capabilities for agent communication and task management.`, + `I've received your request regarding "${input}". Through the A2A protocol, I can engage in meaningful conversations and help with complex tasks.` + ]; + + return responses[Math.floor(Math.random() * responses.length)]; + } + + /** + * Utility delay function + */ + delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +// Mock AgentCard class +class MockAgentCard { + constructor(config) { + this.protocolVersion = config.protocolVersion || '0.1.0'; + this.name = config.name; + this.description = config.description; + this.version = config.version; + this.url = config.url; + this.preferredTransport = config.preferredTransport || 'JSONRPC'; + this.capabilities = config.capabilities || {}; + this.skills = config.skills || []; + this.securitySchemes = config.securitySchemes || {}; + this.metadata = config.metadata || {}; + } + + toJSON() { + return { + protocolVersion: this.protocolVersion, + name: this.name, + description: this.description, + version: this.version, + url: this.url, + preferredTransport: this.preferredTransport, + capabilities: this.capabilities, + skills: this.skills, + securitySchemes: this.securitySchemes, + metadata: this.metadata, + }; + } +} + +// Mock A2AExpressApp class +class MockA2AExpressApp { + constructor({ agentCard, executor }) { + this.agentCard = agentCard; + this.executor = executor; + this.router = express.Router(); + this.setupRoutes(); + } + + setupRoutes() { + // Agent card endpoint (A2A protocol requirement) + this.router.get('/.well-known/agent-card', (req, res) => { + res.json(this.agentCard.toJSON()); + }); + + // JSON-RPC endpoint + this.router.post('/jsonrpc', async (req, res) => { + try { + const { method, params, id } = req.body; + + let result; + switch (method) { + case 'message/send': + result = await this.executor.execute(params.message, params.contextId); + break; + + case 'tasks/create': + result = await this.executor.createTask(params.message, params.contextId); + break; + + case 'tasks/get': + result = await this.executor.getTaskStatus(params.taskId); + break; + + case 'tasks/cancel': + result = await this.executor.cancelTask(params.taskId); + break; + + default: + throw new Error(`Unknown method: ${method}`); + } + + res.json({ + jsonrpc: '2.0', + result, + id, + }); + } catch (error) { + console.error('JSON-RPC error:', error); + res.json({ + jsonrpc: '2.0', + error: { + code: -1, + message: error.message, + }, + id: req.body.id, + }); + } + }); + + // REST API endpoints (alternative to JSON-RPC) + this.router.post('/v1/message/send', async (req, res) => { + try { + const result = await this.executor.execute(req.body.message, req.body.contextId); + res.json({ message: result }); + } catch (error) { + console.error('REST API error:', error); + res.status(500).json({ error: error.message }); + } + }); + + this.router.post('/v1/tasks/create', async (req, res) => { + try { + const result = await this.executor.createTask(req.body.message, req.body.contextId); + res.json(result); + } catch (error) { + console.error('Task creation error:', error); + res.status(500).json({ error: error.message }); + } + }); + + this.router.get('/v1/tasks/:taskId', async (req, res) => { + try { + const result = await this.executor.getTaskStatus(req.params.taskId); + res.json(result); + } catch (error) { + console.error('Task status error:', error); + res.status(404).json({ error: error.message }); + } + }); + + this.router.delete('/v1/tasks/:taskId', async (req, res) => { + try { + const result = await this.executor.cancelTask(req.params.taskId); + res.json(result); + } catch (error) { + console.error('Task cancellation error:', error); + res.status(500).json({ error: error.message }); + } + }); + } +} + +// Create agent configuration +const agentCard = new MockAgentCard({ + protocolVersion: '0.1.0', + name: 'Mock HTTP Agent', + description: 'A mock A2A protocol agent for LibreChat integration testing and development', + version: '1.0.0', + url: process.env.AGENT_URL || 'http://localhost:3001', + preferredTransport: 'JSONRPC', + capabilities: { + streaming: true, + push: false, + multiTurn: true, + taskBased: true, + tools: false, + }, + skills: [ + { + id: 'conversation', + name: 'Conversational AI', + description: 'Engage in natural language conversations with context awareness', + inputModes: ['text'], + outputModes: ['text'], + }, + { + id: 'task-processing', + name: 'Task Processing', + description: 'Handle complex, multi-step tasks with progress tracking and artifacts', + inputModes: ['text'], + outputModes: ['text', 'artifacts'], + }, + { + id: 'context-management', + name: 'Context Management', + description: 'Maintain conversation history and context across interactions', + inputModes: ['text'], + outputModes: ['text'], + }, + { + id: 'protocol-demo', + name: 'A2A Protocol Demonstration', + description: 'Demonstrate A2A protocol features including streaming and task workflows', + inputModes: ['text'], + outputModes: ['text', 'status', 'artifacts'], + }, + ], + securitySchemes: { + none: { + type: 'none', + description: 'No authentication required for this mock agent', + }, + }, + metadata: { + environment: 'development', + purpose: 'testing', + librechat_integration: true, + mock_agent: true, + }, +}); + +// Create Express application +const app = express(); + +// Middleware +app.use(helmet()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || ['http://localhost:3080', 'http://localhost:3000'], + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], +})); +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// Request logging +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); +}); + +// Create agent executor and A2A app +const executor = new MockAgentExecutor(); +const a2aApp = new MockA2AExpressApp({ + agentCard, + executor, +}); + +// Use A2A routes +app.use('/', a2aApp.router); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + agent: agentCard.name, + version: agentCard.version, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); +}); + +// Status endpoint with statistics +app.get('/status', (req, res) => { + res.json({ + agent: agentCard.toJSON(), + statistics: { + activeConversations: executor.conversationHistory.size, + activeWorkflows: executor.activeWorkflows.size, + uptime: process.uptime(), + memoryUsage: process.memoryUsage(), + }, + timestamp: new Date().toISOString(), + }); +}); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Path ${req.path} not found`, + availableEndpoints: [ + '/.well-known/agent-card', + '/jsonrpc', + '/v1/message/send', + '/v1/tasks/*', + '/health', + '/status', + ], + }); +}); + +// Error handler +app.use((error, req, res, next) => { + console.error('Server Error:', error); + res.status(500).json({ + error: 'Internal Server Error', + message: error.message, + }); +}); + +// Start server +const PORT = process.env.PORT || 8080; +const HOST = process.env.HOST || '0.0.0.0'; + +app.listen(PORT, HOST, () => { + console.log(`šŸ¤– Mock A2A Agent Server running on http://${HOST}:${PORT}`); + console.log(`šŸ“‹ Agent Card: http://${HOST}:${PORT}/.well-known/agent-card`); + console.log(`šŸ”Œ JSON-RPC: http://${HOST}:${PORT}/jsonrpc`); + console.log(`šŸ„ Health: http://${HOST}:${PORT}/health`); + console.log(`šŸ“Š Status: http://${HOST}:${PORT}/status`); + console.log(`šŸŽÆ Agent: ${agentCard.name} v${agentCard.version}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\nšŸ›‘ Shutting down Mock A2A Agent Server...'); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('\nšŸ›‘ Shutting down Mock A2A Agent Server...'); + process.exit(0); +}); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index dfd11af09f9c..c2ec1f8bfc93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,5 +68,25 @@ services: env_file: - .env + # Mock A2A Agent for development and testing + mock-a2a-agent: + build: ./containers/mock-a2a-agent + container_name: librechat-mock-a2a-agent + ports: + - "8080:8080" + environment: + - NODE_ENV=development + - PORT=8080 + - HOST=0.0.0.0 + - AGENT_URL=http://mock-a2a-agent:8080 + - CORS_ORIGIN=http://localhost:${PORT:-3080} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + restart: unless-stopped + volumes: pgdata2: diff --git a/package-lock.json b/package-lock.json index 2a67a0c3f924..cb3d230a83aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,9 @@ "@azure/storage-blob": "^12.27.0", "@google/generative-ai": "^0.24.0", "@googleapis/youtube": "^20.0.0", + "@grpc/grpc-js": "^1.9.0", + "@grpc/proto-loader": "^0.7.8", + "@grpc/reflection": "^1.0.4", "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", "@langchain/core": "^0.3.62", @@ -19410,6 +19413,19 @@ "node": ">=6" } }, + "node_modules/@grpc/reflection": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@grpc/reflection/-/reflection-1.0.4.tgz", + "integrity": "sha512-znA8v4AviOD3OPOxy11pxrtP8k8DanpefeTymS8iGW1fVr1U2cHuzfhYqDPHnVNDf4qvF9E25KtSihPy2DBWfQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "protobufjs": "^7.2.5" + }, + "peerDependencies": { + "@grpc/grpc-js": "^1.8.21" + } + }, "node_modules/@headlessui/react": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz", diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6c7f2ec31b23..759fe63a3f27 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -287,6 +287,65 @@ export const agentsEndpointSchema = baseEndpointSchema export type TAgentsEndpoint = z.infer; +export const a2aEndpointSchema = baseEndpointSchema + .merge( + z.object({ + /* A2A specific */ + enabled: z.boolean().optional().default(true), + discovery: z.object({ + enabled: z.boolean().optional().default(true), + refreshInterval: z.number().optional().default(300000), // 5 minutes + }).optional().default({ + enabled: true, + refreshInterval: 300000, + }), + agents: z.array( + z.object({ + name: z.string(), + agentCardUrl: z.string().url(), + authentication: z.object({ + type: z.enum(['apikey', 'oauth2', 'openid', 'http', 'mutual_tls', 'none']), + credentials: z.record(z.string()).optional(), + headers: z.record(z.string()).optional(), + }), + options: z.object({ + timeout: z.number().optional().default(30000), + maxRetries: z.number().optional().default(3), + enableStreaming: z.boolean().optional().default(true), + enableTasks: z.boolean().optional().default(true), + }).optional(), + }) + ).optional().default([]), + defaultOptions: z.object({ + timeout: z.number().optional().default(30000), + maxRetries: z.number().optional().default(3), + enableStreaming: z.boolean().optional().default(true), + enableTasks: z.boolean().optional().default(true), + }).optional().default({ + timeout: 30000, + maxRetries: 3, + enableStreaming: true, + enableTasks: true, + }), + }), + ) + .default({ + enabled: true, + discovery: { + enabled: true, + refreshInterval: 300000, + }, + agents: [], + defaultOptions: { + timeout: 30000, + maxRetries: 3, + enableStreaming: true, + enableTasks: true, + }, + }); + +export type TA2AEndpoint = z.infer; + export const endpointSchema = baseEndpointSchema.merge( z.object({ name: z.string().refine((value) => !eModelEndpointSchema.safeParse(value).success, { @@ -854,6 +913,7 @@ export const configSchema = z.object({ [EModelEndpoint.azureAssistants]: assistantEndpointSchema.optional(), [EModelEndpoint.assistants]: assistantEndpointSchema.optional(), [EModelEndpoint.agents]: agentsEndpointSchema.optional(), + [EModelEndpoint.a2a]: a2aEndpointSchema.optional(), [EModelEndpoint.custom]: customEndpointsSchema.optional(), [EModelEndpoint.bedrock]: baseEndpointSchema.optional(), }) @@ -904,6 +964,7 @@ export const defaultEndpoints: EModelEndpoint[] = [ EModelEndpoint.azureAssistants, EModelEndpoint.azureOpenAI, EModelEndpoint.agents, + EModelEndpoint.a2a, EModelEndpoint.chatGPTBrowser, EModelEndpoint.gptPlugins, EModelEndpoint.google, @@ -916,6 +977,7 @@ export const alternateName = { [EModelEndpoint.openAI]: 'OpenAI', [EModelEndpoint.assistants]: 'Assistants', [EModelEndpoint.agents]: 'My Agents', + [EModelEndpoint.a2a]: 'A2A Agents', [EModelEndpoint.azureAssistants]: 'Azure Assistants', [EModelEndpoint.azureOpenAI]: 'Azure OpenAI', [EModelEndpoint.chatGPTBrowser]: 'ChatGPT', @@ -1060,6 +1122,7 @@ export const EndpointURLs = { [EModelEndpoint.assistants]: `${apiBaseUrl()}/api/assistants/v2/chat`, [EModelEndpoint.azureAssistants]: `${apiBaseUrl()}/api/assistants/v1/chat`, [EModelEndpoint.agents]: `${apiBaseUrl()}/api/${EModelEndpoint.agents}/chat`, + [EModelEndpoint.a2a]: `${apiBaseUrl()}/api/${EModelEndpoint.agents}/chat`, } as const; export const modularEndpoints = new Set([ diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index a0eacb244db7..fe9383f25e69 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -22,13 +22,21 @@ export default function createPayload(submission: t.TSubmission) { const endpoint = _e as s.EModelEndpoint; let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`; + console.log('Creatign payload for endpoint', endpoint); if (s.isAssistantsEndpoint(endpoint)) { server = EndpointURLs[(endpointType ?? endpoint) as 'assistants' | 'azureAssistants'] + (isEdited ? '/modify' : ''); + } else if (endpoint === s.EModelEndpoint.a2a) { + // TODO: Remove this after testing + server = EndpointURLs[s.EModelEndpoint.a2a]; + console.log('A2A server - ', server); } - const payload: t.TPayload = { + let payload: t.TPayload; + + // A2A uses the standard agents payload format but with A2A-specific agent model + payload = { ...userMessage, ...endpointOption, endpoint, diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 7d4016449a25..c0826e300441 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -41,6 +41,7 @@ const endpointSchemas: Record = { [EModelEndpoint.assistants]: assistantSchema, [EModelEndpoint.azureAssistants]: assistantSchema, [EModelEndpoint.agents]: compactAgentsSchema, + [EModelEndpoint.a2a]: compactAgentsSchema, [EModelEndpoint.bedrock]: bedrockInputSchema, }; @@ -53,6 +54,7 @@ export function getEnabledEndpoints() { const defaultEndpoints: string[] = [ EModelEndpoint.openAI, EModelEndpoint.agents, + EModelEndpoint.a2a, EModelEndpoint.assistants, EModelEndpoint.azureAssistants, EModelEndpoint.azureOpenAI, @@ -308,6 +310,7 @@ const compactEndpointSchemas: Record = [EModelEndpoint.assistants]: compactAssistantSchema, [EModelEndpoint.azureAssistants]: compactAssistantSchema, [EModelEndpoint.agents]: compactAgentsSchema, + [EModelEndpoint.a2a]: compactAgentsSchema, [EModelEndpoint.google]: compactGoogleSchema, [EModelEndpoint.bedrock]: bedrockInputSchema, [EModelEndpoint.anthropic]: anthropicSchema, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 8ae503ef25c1..323498a48009 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -23,6 +23,7 @@ export enum EModelEndpoint { assistants = 'assistants', azureAssistants = 'azureAssistants', agents = 'agents', + a2a = 'a2a', custom = 'custom', bedrock = 'bedrock', /** @deprecated */ @@ -33,6 +34,7 @@ export enum EModelEndpoint { export const paramEndpoints = new Set([ EModelEndpoint.agents, + EModelEndpoint.a2a, EModelEndpoint.openAI, EModelEndpoint.bedrock, EModelEndpoint.azureOpenAI, @@ -90,6 +92,14 @@ export const isAgentsEndpoint = (_endpoint?: EModelEndpoint.agents | null | stri return endpoint === EModelEndpoint.agents; }; +export const isA2AEndpoint = (_endpoint?: EModelEndpoint.a2a | null | string): boolean => { + const endpoint = _endpoint ?? ''; + if (!endpoint) { + return false; + } + return endpoint === EModelEndpoint.a2a; +}; + export const isParamEndpoint = ( endpoint: EModelEndpoint | string, endpointType?: EModelEndpoint | string, diff --git a/packages/data-provider/src/types/a2a.ts b/packages/data-provider/src/types/a2a.ts new file mode 100644 index 000000000000..ba8192a2c4de --- /dev/null +++ b/packages/data-provider/src/types/a2a.ts @@ -0,0 +1,131 @@ +/** + * A2A (Agent-to-Agent) Protocol Types + * Types for external A2A agent integration + */ + +export interface A2ACapabilities { + streaming?: boolean; + push?: boolean; + multiTurn?: boolean; + taskBased?: boolean; + tools?: boolean; +} + +export interface A2ASkill { + id: string; + name: string; + description: string; + inputModes?: string[]; + outputModes?: string[]; +} + +export interface A2ASecurityScheme { + type: 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls'; + scheme?: string; + bearerFormat?: string; + flows?: Record; + openIdConnectUrl?: string; +} + +export interface A2AAgentCard { + protocolVersion: string; + name: string; + description: string; + url: string; + preferredTransport: 'JSONRPC' | 'HTTP+JSON' | 'GRPC'; + version: string; + capabilities: A2ACapabilities; + skills: A2ASkill[]; + securitySchemes?: Record; + metadata?: Record; +} + +export interface A2AAuthentication { + type: 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls' | 'none'; + credentials?: Record; + headers?: Record; +} + +export interface A2AAgentConfig { + name: string; + agentCardUrl: string; + authentication: A2AAuthentication; + options?: { + timeout?: number; + maxRetries?: number; + enableStreaming?: boolean; + enableTasks?: boolean; + }; +} + +export interface A2AExternalAgent { + id: string; + name: string; + description: string; + status: 'online' | 'offline' | 'error' | 'unknown'; + agentCardUrl: string; + agentCard?: A2AAgentCard; + preferredTransport: 'JSONRPC' | 'HTTP+JSON' | 'GRPC'; + authentication: A2AAuthentication; + capabilities?: A2ACapabilities; + skills?: A2ASkill[]; + lastHealthCheck?: string; + createdAt: string; + updatedAt?: string; +} + +export interface A2ATask { + id: string; + contextId: string; + status: 'submitted' | 'working' | 'completed' | 'failed' | 'canceled'; + statusMessage?: string; + history: A2AMessage[]; + artifacts: A2AArtifact[]; + metadata?: Record; + createdAt: string; + updatedAt?: string; +} + +export interface A2AMessage { + role: 'user' | 'agent'; + parts: A2APart[]; + timestamp?: string; +} + +export interface A2APart { + type: 'text' | 'file' | 'data'; + content: string | Buffer | unknown; + metadata?: Record; +} + +export interface A2AArtifact { + id: string; + type: string; + name: string; + content: unknown; + createdAt: string; +} + +export interface A2AConversationUpdate { + agentId: string; + agentName: string; + taskId?: string; + artifacts?: A2AArtifact[]; + transport?: string; +} + +// Configuration types for librechat.yaml +export interface A2AEndpointConfig { + enabled?: boolean; + discovery?: { + enabled?: boolean; + refreshInterval?: number; + }; + agents?: A2AAgentConfig[]; + defaultOptions?: { + timeout?: number; + maxRetries?: number; + enableStreaming?: boolean; + enableTasks?: boolean; + }; +} \ No newline at end of file diff --git a/packages/data-provider/src/types/index.ts b/packages/data-provider/src/types/index.ts index 3cf1ef310b48..110f041d6f34 100644 --- a/packages/data-provider/src/types/index.ts +++ b/packages/data-provider/src/types/index.ts @@ -1 +1,2 @@ export * from './queries'; +export * from './a2a'; diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index 29d5191567f7..22fa6fd8f541 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -5,6 +5,63 @@ export interface ISupportContact { email?: string; } +export interface IA2ACapabilities { + streaming?: boolean; + push?: boolean; + multiTurn?: boolean; + taskBased?: boolean; + tools?: boolean; +} + +export interface IA2ASkill { + id: string; + name: string; + description: string; + inputModes?: string[]; + outputModes?: string[]; +} + +export interface IA2ASecurityScheme { + type: 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls'; + scheme?: string; + bearerFormat?: string; + flows?: Record; + openIdConnectUrl?: string; +} + +export interface IA2AAuthentication { + type: 'apikey' | 'oauth2' | 'openid' | 'http' | 'mutual_tls' | 'none'; + credentials?: Record; + headers?: Record; +} + +export interface IA2AAgentCard { + protocolVersion: string; + name: string; + description: string; + url: string; + preferredTransport: 'JSONRPC' | 'HTTP+JSON' | 'GRPC'; + version: string; + capabilities: IA2ACapabilities; + skills: IA2ASkill[]; + securitySchemes?: Record; + metadata?: Record; +} + +export interface IA2AConfig { + agent_card_url: string; + agent_card?: IA2AAgentCard; + preferred_transport: 'JSONRPC' | 'HTTP+JSON' | 'GRPC'; + authentication: IA2AAuthentication; + timeout?: number; + max_retries?: number; + enable_streaming?: boolean; + enable_tasks?: boolean; + health_check_interval?: number; + last_health_check?: Date; + status?: 'online' | 'offline' | 'error' | 'unknown'; +} + export interface IAgent extends Omit { id: string; name?: string; diff --git a/proto/a2a/grpc/a2a.proto b/proto/a2a/grpc/a2a.proto new file mode 100644 index 000000000000..80e9bf5c31d6 --- /dev/null +++ b/proto/a2a/grpc/a2a.proto @@ -0,0 +1,764 @@ +// Older protoc compilers don't understand edition yet. +syntax = "proto3"; + +// ------------------------------------------------------------ +// A copy of the A2A protocol spec that matches with the Stripe internal spec +// but leaves out internal privacy annotations, imports, and options. +// To be kept in sync with the open source spec and the Stripe internal spec. + +// - Spec: https://github.com/google-a2a/A2A/blob/main/specification/grpc/a2a.proto +// - Internal doc: Agent Protocol at Stripe (http://go/th/trh_doc_SL9iN2fvO8zHqx) +// ------------------------------------------------------------ +package com.stripe.agent.a2a.v1; + +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +// import "google/protobuf/empty.proto"; +option java_package = "com.stripe.proto.agent.a2a.v1"; +option java_outer_classname = "A2A"; +// option (com.stripe.vext.privacy_setting_version) = "1.0"; +// option (com.stripe.ext.deployed_clusters_csv) = "northwest,cmh,bom"; + +// A2AService defines the gRPC version of the A2A protocol. This has a slightly +// different shape than the JSONRPC version to better conform to AIP-127, +// where appropriate. The nouns are AgentCard, Message, Task and +// TaskPushNotificationConfig. +// - Messages are not a standard resource so there is no get/delete/update/list +// interface, only a send and stream custom methods. +// - Tasks have a get interface and custom cancel and subscribe methods. +// - TaskPushNotificationConfig are a resource whose parent is a task. +// They have get, list and create methods. +// - AgentCard is a static resource with only a get method. +service A2AService { + // Send a message to the agent. This is a blocking call that will return the + // task once it is completed, or a LRO if requested. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) { + option (google.api.http) = { + post: "/v1/message:send" + body: "*" + }; + } + + // Get the current state of a task from the agent. + rpc GetTask(GetTaskRequest) returns (Task) { + option (google.api.http) = { + get: "/v1/{name=tasks/*}" + }; + option (google.api.method_signature) = "name"; + } +} + +///////// Data Model //////////// + +// --8<-- [start:MessageSendConfiguration] +// Configuration of a send message request. +message SendMessageConfiguration { + // The output modes that the agent is expected to respond with. + repeated string accepted_output_modes = 1; + // A configuration of a webhook that can be used to receive updates + PushNotificationConfig push_notification = 2; + // The maximum number of messages to include in the history. if 0, the + // history will be unlimited. + int32 history_length = 3; + // If true, the message will be blocking until the task is completed. If + // false, the message will be non-blocking and the task will be returned + // immediately. It is the caller's responsibility to check for any task + // updates. + bool blocking = 4; +} +// --8<-- [end:MessageSendConfiguration] + +// --8<-- [start:Task] +// Task is the core unit of action for A2A. It has a current status +// and when results are created for the task they are stored in the +// artifact. If there are multiple turns for a task, these are stored in +// history. +message Task { + // Unique identifier (e.g. UUID) for the task, generated by the server for a + // new task. + string id = 1; + // Unique identifier (e.g. UUID) for the contextual collection of interactions + // (tasks and messages). Created by the A2A server. + string context_id = 2; + // The current status of a Task, including state and a message. + TaskStatus status = 3; + // A set of output artifacts for a Task. + repeated Artifact artifacts = 4; + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // The history of interactions from a task. + repeated Message history = 5; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // A key/value object to store custom metadata about a task. + google.protobuf.Struct metadata = 6; +} +// --8<-- [end:Task] + +// --8<-- [start:TaskState] +// The set of states a Task can be in. +enum TaskState { + TASK_STATE_UNSPECIFIED = 0; + // Represents the status that acknowledges a task is created + TASK_STATE_SUBMITTED = 1; + // Represents the status that a task is actively being processed + TASK_STATE_WORKING = 2; + // Represents the status a task is finished. This is a terminal state + TASK_STATE_COMPLETED = 3; + // Represents the status a task is done but failed. This is a terminal state + TASK_STATE_FAILED = 4; + // Represents the status a task was cancelled before it finished. + // This is a terminal state. + TASK_STATE_CANCELLED = 5; + // Represents the status that the task requires information to complete. + // This is an interrupted state. + TASK_STATE_INPUT_REQUIRED = 6; + // Represents the status that the agent has decided to not perform the task. + // This may be done during initial task creation or later once an agent + // has determined it can't or won't proceed. This is a terminal state. + TASK_STATE_REJECTED = 7; + // Represents the state that some authentication is needed from the upstream + // client. Authentication is expected to come out-of-band thus this is not + // an interrupted or terminal state. + TASK_STATE_AUTH_REQUIRED = 8; +} +// --8<-- [end:TaskState] + +// --8<-- [start:TaskStatus] +// A container for the status of a task +message TaskStatus { + // The current state of this task + TaskState state = 1; + // A message associated with the status. + Message update = 2 [json_name = "message"]; + // Timestamp when the status was recorded. + // Example: "2023-10-27T10:00:00Z" + google.protobuf.Timestamp timestamp = 3; +} +// --8<-- [end:TaskStatus] + +// --8<-- [start:Part] +// Part represents a container for a section of communication content. +// Parts can be purely textual, some sort of file (image, video, etc) or +// a structured data blob (i.e. JSON). +message Part { + oneof part { + string text = 1; + FilePart file = 2; + DataPart data = 3; + } + // Optional metadata associated with this part. + google.protobuf.Struct metadata = 4; +} +// --8<-- [end:Part] + +// --8<-- [start:FilePart] +// FilePart represents the different ways files can be provided. If files are +// small, directly feeding the bytes is supported via file_with_bytes. If the +// file is large, the agent should read the content as appropriate directly +// from the file_with_uri source. +message FilePart { + oneof file { + string file_with_uri = 1; + bytes file_with_bytes = 2; + } + string mime_type = 3; + string name = 4; +} +// --8<-- [end:FilePart] + +// --8<-- [start:DataPart] +// DataPart represents a structured blob. This is most commonly a JSON payload. +message DataPart { + google.protobuf.Struct data = 1; +} +// --8<-- [end:DataPart] + +enum Role { + ROLE_UNSPECIFIED = 0; + // USER role refers to communication from the client to the server. + ROLE_USER = 1; + // AGENT role refers to communication from the server to the client. + ROLE_AGENT = 2; +} + +// --8<-- [start:Message] +// Message is one unit of communication between client and server. It is +// associated with a context and optionally a task. Since the server is +// responsible for the context definition, it must always provide a context_id +// in its messages. The client can optionally provide the context_id if it +// knows the context to associate the message to. Similarly for task_id, +// except the server decides if a task is created and whether to include the +// task_id. +message Message { + // The unique identifier (e.g. UUID)of the message. This is required and + // created by the message creator. + string message_id = 1; + // The context id of the message. This is optional and if set, the message + // will be associated with the given context. + string context_id = 2; + // The task id of the message. This is optional and if set, the message + // will be associated with the given task. + string task_id = 3; + // A role for the message. + Role role = 4; + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // Content is the container of the message content. + repeated Part content = 5; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // Any optional metadata to provide along with the message. + google.protobuf.Struct metadata = 6; + // The URIs of extensions that are present or contributed to this Message. + repeated string extensions = 7; +} +// --8<-- [end:Message] + +// --8<-- [start:Artifact] +// Artifacts are the container for task completed results. These are similar +// to Messages but are intended to be the product of a task, as opposed to +// point-to-point communication. +message Artifact { + // Unique identifier (e.g. UUID) for the artifact. It must be at least unique + // within a task. + string artifact_id = 1; + // A human readable name for the artifact. + string name = 3; + // A human readable description of the artifact, optional. + string description = 4; + // The content of the artifact. + repeated Part parts = 5; + // Optional metadata included with the artifact. + google.protobuf.Struct metadata = 6; + // The URIs of extensions that are present or contributed to this Artifact. + repeated string extensions = 7; +} +// --8<-- [end:Artifact] + +// --8<-- [start:TaskStatusUpdateEvent] +// TaskStatusUpdateEvent is a delta even on a task indicating that a task +// has changed. +message TaskStatusUpdateEvent { + // The id of the task that is changed + string task_id = 1; + // The id of the context that the task belongs to + string context_id = 2; + // The new status of the task. + TaskStatus status = 3; + // Whether this is the last status update expected for this task. + bool final = 4; + // Optional metadata to associate with the task update. + google.protobuf.Struct metadata = 5; +} +// --8<-- [end:TaskStatusUpdateEvent] + +// --8<-- [start:TaskArtifactUpdateEvent] +// TaskArtifactUpdateEvent represents a task delta where an artifact has +// been generated. +message TaskArtifactUpdateEvent { + // The id of the task for this artifact + string task_id = 1; + // The id of the context that this task belongs too + string context_id = 2; + // The artifact itself + Artifact artifact = 3; + // Whether this should be appended to a prior one produced + bool append = 4; + // Whether this represents the last part of an artifact + bool last_chunk = 5; + // Optional metadata associated with the artifact update. + google.protobuf.Struct metadata = 6; +} +// --8<-- [end:TaskArtifactUpdateEvent] + +// --8<-- [start:PushNotificationConfig] +// Configuration for setting up push notifications for task updates. +message PushNotificationConfig { + // A unique identifier (e.g. UUID) for this push notification. + string id = 1; + // Url to send the notification too + string url = 2; + // Token unique for this task/session + string token = 3; + // Information about the authentication to sent with the notification + AuthenticationInfo authentication = 4; +} +// --8<-- [end:PushNotificationConfig] + +// --8<-- [start:PushNotificationAuthenticationInfo] +// Defines authentication details, used for push notifications. +message AuthenticationInfo { + // Supported authentication schemes - e.g. Basic, Bearer, etc + repeated string schemes = 1; + // Optional credentials + string credentials = 2; +} +// --8<-- [end:PushNotificationAuthenticationInfo] + +// --8<-- [start:AgentInterface] +// Defines additional transport information for the agent. +message AgentInterface { + // The url this interface is found at. + string url = 1; + // The transport supported this url. This is an open form string, to be + // easily extended for many transport protocols. The core ones officially + // supported are JSONRPC, GRPC and HTTP+JSON. + string transport = 2; +} +// --8<-- [end:AgentInterface] + +// --8<-- [start:AgentCard] +// AgentCard conveys key information: +// - Overall details (version, name, description, uses) +// - Skills; a set of actions/solutions the agent can perform +// - Default modalities/content types supported by the agent. +// - Authentication requirements +// Next ID: 19 +message AgentCard { + // The version of the A2A protocol this agent supports. + string protocol_version = 16; + // A human readable name for the agent. + // Example: "Recipe Agent" + string name = 1; + // A description of the agent's domain of action/solution space. + // Example: "Agent that helps users with recipes and cooking." + string description = 2; + // A URL to the address the agent is hosted at. This represents the + // preferred endpoint as declared by the agent. + string url = 3; + // The transport of the preferred endpoint. If empty, defaults to JSONRPC. + string preferred_transport = 14; + // Announcement of additional supported transports. Client can use any of + // the supported transports. + repeated AgentInterface additional_interfaces = 15; + // The service provider of the agent. + AgentProvider provider = 4; + // The version of the agent. + // Example: "1.0.0" + string version = 5; + // A url to provide additional documentation about the agent. + string documentation_url = 6; + // A2A Capability set supported by the agent. + AgentCapabilities capabilities = 7; + // The security scheme details used for authenticating with this agent. + map security_schemes = 8; + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // Security requirements for contacting the agent. + // This list can be seen as an OR of ANDs. Each object in the list describes + // one possible set of security requirements that must be present on a + // request. This allows specifying, for example, "callers must either use + // OAuth OR an API Key AND mTLS." + // Example: + // security { + // schemes { key: "oauth" value { list: ["read"] } } + // } + // security { + // schemes { key: "api-key" } + // schemes { key: "mtls" } + // } + repeated Security security = 9; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // The set of interaction modes that the agent supports across all skills. + // This can be overridden per skill. Defined as mime types. + repeated string default_input_modes = 10; + // The mime types supported as outputs from this agent. + repeated string default_output_modes = 11; + // Skills represent a unit of ability an agent can perform. This may + // somewhat abstract but represents a more focused set of actions that the + // agent is highly likely to succeed at. + repeated AgentSkill skills = 12; + // Whether the agent supports providing an extended agent card when + // the user is authenticated, i.e. is the card from .well-known + // different than the card from GetAgentCard. + bool supports_authenticated_extended_card = 13; + // JSON Web Signatures computed for this AgentCard. + repeated AgentCardSignature signatures = 17; + // An optional URL to an icon for the agent. + string icon_url = 18; +} +// --8<-- [end:AgentCard] + +// --8<-- [start:AgentProvider] +// Represents information about the service provider of an agent. +message AgentProvider { + // The providers reference url + // Example: "https://ai.google.dev" + string url = 1; + // The providers organization name + // Example: "Google" + string organization = 2; +} +// --8<-- [end:AgentProvider] + +// --8<-- [start:AgentCapabilities] +// Defines the A2A feature set supported by the agent +message AgentCapabilities { + // If the agent will support streaming responses + bool streaming = 1; + // If the agent can send push notifications to the clients webhook + bool push_notifications = 2; + // Extensions supported by this agent. + repeated AgentExtension extensions = 3; +} +// --8<-- [end:AgentCapabilities] + +// --8<-- [start:AgentExtension] +// A declaration of an extension supported by an Agent. +message AgentExtension { + // The URI of the extension. + // Example: "https://developers.google.com/identity/protocols/oauth2" + string uri = 1; + // A description of how this agent uses this extension. + // Example: "Google OAuth 2.0 authentication" + string description = 2; + // Whether the client must follow specific requirements of the extension. + // Example: false + bool required = 3; + // Optional configuration for the extension. + google.protobuf.Struct params = 4; +} +// --8<-- [end:AgentExtension] + +// --8<-- [start:AgentSkill] +// AgentSkill represents a unit of action/solution that the agent can perform. +// One can think of this as a type of highly reliable solution that an agent +// can be tasked to provide. Agents have the autonomy to choose how and when +// to use specific skills, but clients should have confidence that if the +// skill is defined that unit of action can be reliably performed. +message AgentSkill { + // Unique identifier of the skill within this agent. + string id = 1; + // A human readable name for the skill. + string name = 2; + // A human (or llm) readable description of the skill + // details and behaviors. + string description = 3; + // A set of tags for the skill to enhance categorization/utilization. + // Example: ["cooking", "customer support", "billing"] + repeated string tags = 4; + // A set of example queries that this skill is designed to address. + // These examples should help the caller to understand how to craft requests + // to the agent to achieve specific goals. + // Example: ["I need a recipe for bread"] + repeated string examples = 5; + // Possible input modalities supported. + repeated string input_modes = 6; + // Possible output modalities produced + repeated string output_modes = 7; + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // Security schemes necessary for the agent to leverage this skill. + // As in the overall AgentCard.security, this list represents a logical OR of + // security requirement objects. Each object is a set of security schemes + // that must be used together (a logical AND). + repeated Security security = 8; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED +} +// --8<-- [end:AgentSkill] + +// --8<-- [start:AgentCardSignature] +// AgentCardSignature represents a JWS signature of an AgentCard. +// This follows the JSON format of an RFC 7515 JSON Web Signature (JWS). +message AgentCardSignature { + // The protected JWS header for the signature. This is always a + // base64url-encoded JSON object. Required. + string protected = 1 [(google.api.field_behavior) = REQUIRED]; + // The computed signature, base64url-encoded. Required. + string signature = 2 [(google.api.field_behavior) = REQUIRED]; + // The unprotected JWS header values. + google.protobuf.Struct header = 3; +} +// --8<-- [end:AgentCardSignature] + +// --8<-- [start:TaskPushNotificationConfig] +message TaskPushNotificationConfig { + // The resource name of the config. + // Format: tasks/{task_id}/pushNotificationConfigs/{config_id} + string name = 1; + // The push notification configuration details. + PushNotificationConfig push_notification_config = 2; +} +// --8<-- [end:TaskPushNotificationConfig] + +// protolint:disable REPEATED_FIELD_NAMES_PLURALIZED +message StringList { + repeated string list = 1; +} +// protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + +message Security { + map schemes = 1; +} + +// --8<-- [start:SecurityScheme] +message SecurityScheme { + oneof scheme { + APIKeySecurityScheme api_key_security_scheme = 1; + HTTPAuthSecurityScheme http_auth_security_scheme = 2; + OAuth2SecurityScheme oauth2_security_scheme = 3; + OpenIdConnectSecurityScheme open_id_connect_security_scheme = 4; + MutualTlsSecurityScheme mtls_security_scheme = 5; + } +} +// --8<-- [end:SecurityScheme] + +// --8<-- [start:APIKeySecurityScheme] +message APIKeySecurityScheme { + // Description of this security scheme. + string description = 1; + // Location of the API key, valid values are "query", "header", or "cookie" + string location = 2; + // Name of the header, query or cookie parameter to be used. + string name = 3; +} +// --8<-- [end:APIKeySecurityScheme] + +// --8<-- [start:HTTPAuthSecurityScheme] +message HTTPAuthSecurityScheme { + // Description of this security scheme. + string description = 1; + // The name of the HTTP Authentication scheme to be used in the + // Authorization header as defined in RFC7235. The values used SHOULD be + // registered in the IANA Authentication Scheme registry. + // The value is case-insensitive, as defined in RFC7235. + string scheme = 2; + // A hint to the client to identify how the bearer token is formatted. + // Bearer tokens are usually generated by an authorization server, so + // this information is primarily for documentation purposes. + string bearer_format = 3; +} +// --8<-- [end:HTTPAuthSecurityScheme] + +// --8<-- [start:OAuth2SecurityScheme] +message OAuth2SecurityScheme { + // Description of this security scheme. + string description = 1; + // An object containing configuration information for the flow types supported + OAuthFlows flows = 2; + // URL to the oauth2 authorization server metadata + // [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414). TLS is required. + string oauth2_metadata_url = 3; +} +// --8<-- [end:OAuth2SecurityScheme] + +// --8<-- [start:OpenIdConnectSecurityScheme] +message OpenIdConnectSecurityScheme { + // Description of this security scheme. + string description = 1; + // Well-known URL to discover the [[OpenID-Connect-Discovery]] provider + // metadata. + string open_id_connect_url = 2; +} +// --8<-- [end:OpenIdConnectSecurityScheme] + +// --8<-- [start:MutualTLSSecurityScheme] +message MutualTlsSecurityScheme { + // Description of this security scheme. + string description = 1; +} +// --8<-- [end:MutualTLSSecurityScheme] + +// --8<-- [start:OAuthFlows] +message OAuthFlows { + oneof flow { + AuthorizationCodeOAuthFlow authorization_code = 1; + ClientCredentialsOAuthFlow client_credentials = 2; + ImplicitOAuthFlow implicit = 3; + PasswordOAuthFlow password = 4; + } +} +// --8<-- [end:OAuthFlows] + +// --8<-- [start:AuthorizationCodeOAuthFlow] +message AuthorizationCodeOAuthFlow { + // The authorization URL to be used for this flow. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS + string authorization_url = 1; + // The token URL to be used for this flow. This MUST be in the form of a URL. + // The OAuth2 standard requires the use of TLS. + string token_url = 2; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 3; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 4; +} +// --8<-- [end:AuthorizationCodeOAuthFlow] + +// --8<-- [start:ClientCredentialsOAuthFlow] +message ClientCredentialsOAuthFlow { + // The token URL to be used for this flow. This MUST be in the form of a URL. + // The OAuth2 standard requires the use of TLS. + string token_url = 1; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 3; +} +// --8<-- [end:ClientCredentialsOAuthFlow] + +// --8<-- [start:ImplicitOAuthFlow] +message ImplicitOAuthFlow { + // The authorization URL to be used for this flow. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS + string authorization_url = 1; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 3; +} +// --8<-- [end:ImplicitOAuthFlow] + +// --8<-- [start:PasswordOAuthFlow] +message PasswordOAuthFlow { + // The token URL to be used for this flow. This MUST be in the form of a URL. + // The OAuth2 standard requires the use of TLS. + string token_url = 1; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 3; +} +// --8<-- [end:PasswordOAuthFlow] + +///////////// Request Messages /////////// +// --8<-- [start:MessageSendParams] +message SendMessageRequest { + // The message to send to the agent. + Message request = 1 + [(google.api.field_behavior) = REQUIRED, json_name = "message"]; + // Configuration for the send request. + SendMessageConfiguration configuration = 2; + // Optional metadata for the request. + google.protobuf.Struct metadata = 3; +} +// --8<-- [end:MessageSendParams] + +// --8<-- [start:GetTaskRequest] +message GetTaskRequest { + // The resource name of the task. + // Format: tasks/{task_id} + string name = 1 [(google.api.field_behavior) = REQUIRED]; + // The number of most recent messages from the task's history to retrieve. + int32 history_length = 2; +} +// --8<-- [end:GetTaskRequest] + +// --8<-- [start:CancelTaskRequest] +message CancelTaskRequest { + // The resource name of the task to cancel. + // Format: tasks/{task_id} + string name = 1; +} +// --8<-- [end:CancelTaskRequest] + +// --8<-- [start:GetTaskPushNotificationConfigRequest] +message GetTaskPushNotificationConfigRequest { + // The resource name of the config to retrieve. + // Format: tasks/{task_id}/pushNotificationConfigs/{config_id} + string name = 1; +} +// --8<-- [end:GetTaskPushNotificationConfigRequest] + +// --8<-- [start:DeleteTaskPushNotificationConfigRequest] +message DeleteTaskPushNotificationConfigRequest { + // The resource name of the config to delete. + // Format: tasks/{task_id}/pushNotificationConfigs/{config_id} + string name = 1; +} +// --8<-- [end:DeleteTaskPushNotificationConfigRequest] + +// --8<-- [start:SetTaskPushNotificationConfigRequest] +message CreateTaskPushNotificationConfigRequest { + // The parent task resource for this config. + // Format: tasks/{task_id} + string parent = 1 [(google.api.field_behavior) = REQUIRED]; + // The ID for the new config. + string config_id = 2 [(google.api.field_behavior) = REQUIRED]; + // The configuration to create. + TaskPushNotificationConfig config = 3 + [(google.api.field_behavior) = REQUIRED]; +} +// --8<-- [end:SetTaskPushNotificationConfigRequest] + +// --8<-- [start:TaskResubscriptionRequest] +message TaskSubscriptionRequest { + // The resource name of the task to subscribe to. + // Format: tasks/{task_id} + string name = 1; +} +// --8<-- [end:TaskResubscriptionRequest] + +// --8<-- [start:ListTaskPushNotificationConfigRequest] +message ListTaskPushNotificationConfigRequest { + // The parent task resource. + // Format: tasks/{task_id} + string parent = 1; + // For AIP-158 these fields are present. Usually not used/needed. + // The maximum number of configurations to return. + // If unspecified, all configs will be returned. + int32 page_size = 2; + + // A page token received from a previous + // ListTaskPushNotificationConfigRequest call. + // Provide this to retrieve the subsequent page. + // When paginating, all other parameters provided to + // `ListTaskPushNotificationConfigRequest` must match the call that provided + // the page token. + string page_token = 3; +} +// --8<-- [end:ListTaskPushNotificationConfigRequest] + +// --8<-- [start:GetAuthenticatedExtendedCardRequest] +message GetAgentCardRequest { + // Empty. Added to fix linter violation. +} +// --8<-- [end:GetAuthenticatedExtendedCardRequest] + +//////// Response Messages /////////// +// --8<-- [start:SendMessageSuccessResponse] +message SendMessageResponse { + oneof payload { + Task task = 1; + Message msg = 2 [json_name = "message"]; + } +} +// --8<-- [end:SendMessageSuccessResponse] + +// --8<-- [start:SendStreamingMessageSuccessResponse] +// The stream response for a message. The stream should be one of the following +// sequences: +// If the response is a message, the stream should contain one, and only one, +// message and then close +// If the response is a task lifecycle, the first response should be a Task +// object followed by zero or more TaskStatusUpdateEvents and +// TaskArtifactUpdateEvents. The stream should complete when the Task +// if in an interrupted or terminal state. A stream that ends before these +// conditions are met are +message StreamResponse { + oneof payload { + Task task = 1; + Message msg = 2 [json_name = "message"]; + TaskStatusUpdateEvent status_update = 3; + TaskArtifactUpdateEvent artifact_update = 4; + } +} +// --8<-- [end:SendStreamingMessageSuccessResponse] + +// --8<-- [start:ListTaskPushNotificationConfigSuccessResponse] +message ListTaskPushNotificationConfigResponse { + // The list of push notification configurations. + repeated TaskPushNotificationConfig configs = 1; + // A token, which can be sent as `page_token` to retrieve the next page. + // If this field is omitted, there are no subsequent pages. + string next_page_token = 2; +} +// --8<-- [end:ListTaskPushNotificationConfigSuccessResponse] diff --git a/proto/googleapis b/proto/googleapis new file mode 160000 index 000000000000..3d9a46d77218 --- /dev/null +++ b/proto/googleapis @@ -0,0 +1 @@ +Subproject commit 3d9a46d77218aa1cf200f732ff39960a0e3f3d9e diff --git a/proto/protobuf b/proto/protobuf new file mode 160000 index 000000000000..93547f70ef7e --- /dev/null +++ b/proto/protobuf @@ -0,0 +1 @@ +Subproject commit 93547f70ef7ef384f767a7cf318f45e09744da7c