diff --git a/A2A_CONFIGURATION.md b/A2A_CONFIGURATION.md new file mode 100644 index 00000000..63d51cd1 --- /dev/null +++ b/A2A_CONFIGURATION.md @@ -0,0 +1,91 @@ +# Agent-to-Agent (A2A) Protocol Configuration & Usage Guide + +This repository integrates the official **Agent-to-Agent (A2A) Protocol** ([a2a-protocol.org](https://a2a-protocol.org)) using [`@a2a-js/sdk`](https://github.com/a2aproject/a2a-js). QCX-Terra can act both as an **A2A Client** (invoking specialized remote agents on other systems) and as an **A2A Server** (allowing external agentic systems to discover and send tasks to QCX-Terra). + +--- + +## 1. Environment Configuration + +### Remote Agents (`A2A_REMOTE_AGENTS`) + +Configure remote A2A agents that QCX-Terra can communicate with using the `A2A_REMOTE_AGENTS` environment variable in `.env.local` or your runtime environment: + +```env +A2A_REMOTE_AGENTS='[ + { + "name": "weather-agent", + "url": "https://weather.example.com", + "apiKey": "sk_live_12345" + }, + { + "name": "satellite-imagery-agent", + "url": "https://sat.example.com" + } +]' +``` + +- **`name`**: Friendly identifier for the remote agent (used by internal agents for routing). +- **`url`**: The base HTTP/HTTPS URL of the remote agent serving `/.well-known/agent-card.json`. +- **`apiKey`** *(optional)*: Bearer token or authorization token injected into the `Authorization` request header. + +--- + +## 2. A2A Client Capabilities + +QCX-Terra's multi-agent system (`lib/agents/researcher.tsx`) includes the `a2aQueryTool` tool. + +When a user asks QCX-Terra to consult or delegate sub-tasks to external agents (e.g., *"Ask the weather agent for the atmospheric forecast in Oregon"* or *"Delegate satellite task to https://sat.example.com"*), the agent automatically uses `a2aQueryTool` to: + +1. Resolve the remote agent's URL and credentials from `A2A_REMOTE_AGENTS` or prompt argument. +2. Fetch the remote agent card via `@a2a-js/sdk` `ClientFactory`. +3. Negotiate transport (JSON-RPC or HTTP REST). +4. Stream or send the request message and return the synthesized result to the user interface. + +--- + +## 3. A2A Server Endpoints + +QCX-Terra exposes compliant A2A v1.0 server endpoints: + +### Agent Card Discovery +- **Endpoint**: `GET /.well-known/agent-card.json` +- **Description**: Returns QCX-Terra's agent card, detailing supported interfaces (`JSON-RPC`, `HTTP+REST`), streaming capabilities, and available skills (`geospatial-analysis`, `satellite-embeddings`, `planet-computer`). + +### Message & Task Execution Endpoint +- **Endpoint**: `POST /api/a2a` +- **Description**: Handles inbound A2A v1.0 JSON-RPC requests (`SendMessage`, `SendStreamingMessage`, `GetTask`, `CancelTask`, etc.). + +#### Example Inbound JSON-RPC Request: +```bash +curl -X POST http://localhost:3000/api/a2a \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-001", + "role": "ROLE_USER", + "parts": [ + { + "content": { + "$case": "text", + "value": "Query latest geospatial analysis for target region." + } + } + ] + } + } + }' +``` + +--- + +## 4. Testing & Verification + +Run the A2A integration test suite using Bun: + +```bash +bun test tests-unit/a2a.test.ts +``` diff --git a/app/.well-known/agent-card.json/route.ts b/app/.well-known/agent-card.json/route.ts new file mode 100644 index 00000000..02259f35 --- /dev/null +++ b/app/.well-known/agent-card.json/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const host = request.headers.get('host') || 'localhost:3000' + const protocol = request.headers.get('x-forwarded-proto') || 'http' + const baseUrl = `${protocol}://${host}` + + const agentCard = { + name: 'QCX-Terra Agent', + description: + 'Environment-aware planetary computer platform offering geospatial analysis, remote sensing satellite imagery processing, and multi-agent exploration.', + supportedInterfaces: [ + { + protocolBinding: 'JSON-RPC', + protocolVersion: '1.0', + url: `${baseUrl}/api/a2a` + }, + { + protocolBinding: 'HTTP+JSON/REST', + protocolVersion: '1.0', + url: `${baseUrl}/api/a2a` + } + ], + capabilities: { + streaming: true, + pushNotifications: false + }, + skills: [ + { + id: 'geospatial-analysis', + name: 'Geospatial Analysis & Mapping', + description: 'Perform spatial queries, location search, routing, and map feature analysis.' + }, + { + id: 'satellite-embeddings', + name: 'Satellite Image Vector Search', + description: 'Natural language and vector similarity search over satellite and aerial imagery.' + }, + { + id: 'planet-computer', + name: 'Planetary Computing & Forecasting', + description: 'Provide climate forecasting, planetary model exploration, and environmental insights.' + } + ] + } + + return NextResponse.json(agentCard, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, A2A-Version' + } + }) +} + +export async function OPTIONS() { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, A2A-Version' + } + }) +} diff --git a/app/api/a2a/route.ts b/app/api/a2a/route.ts new file mode 100644 index 00000000..0d15fec6 --- /dev/null +++ b/app/api/a2a/route.ts @@ -0,0 +1,177 @@ +import { NextResponse } from 'next/server' +import { + DefaultRequestHandler, + JsonRpcTransportHandler, + InMemoryTaskStore, + AgentEvent, + RequestContext, + ExecutionEventBus, + ServerCallContext +} from '@a2a-js/sdk/server' +import { Role, TaskState } from '@a2a-js/sdk' + +const agentCard = { + name: 'QCX-Terra Agent', + description: + 'Environment-aware planetary computer platform offering geospatial analysis, remote sensing satellite imagery processing, and multi-agent exploration.', + supportedInterfaces: [ + { + protocolBinding: 'JSON-RPC', + protocolVersion: '1.0', + url: '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/api/a2a', + tenant: '' + } + ], + provider: undefined, + version: '1.0.0', + capabilities: { + streaming: true, + pushNotifications: false, + extensions: [] + }, + securitySchemes: {}, + securityRequirements: [], + defaultInputModes: ['text/plain'], + defaultOutputModes: ['text/plain'], + skills: [], + signatures: [] +} + +const taskStore = new InMemoryTaskStore() + +const agentExecutor = { + async execute(requestContext: RequestContext, eventBus: ExecutionEventBus) { + const userMessage = requestContext.userMessage + let textPart = '' + if (userMessage.parts) { + for (const p of userMessage.parts) { + if (p.content?.$case === 'text') { + textPart += p.content.value + } + } + } + + eventBus.publish( + AgentEvent.task({ + id: requestContext.taskId, + contextId: requestContext.contextId, + status: { + state: TaskState.TASK_STATE_WORKING, + message: undefined, + timestamp: undefined + }, + artifacts: [], + history: [userMessage], + metadata: undefined + }) + ) + + const responseText = `QCX-Terra Agent received your request: "${textPart}". Processed via A2A Protocol.` + + eventBus.publish( + AgentEvent.message({ + messageId: `msg-${Date.now()}`, + taskId: requestContext.taskId, + contextId: requestContext.contextId, + role: Role.ROLE_AGENT, + parts: [ + { + content: { + $case: 'text', + value: responseText + }, + metadata: undefined, + filename: '', + mediaType: 'text/plain' + } + ], + metadata: undefined, + extensions: [], + referenceTaskIds: [] + }) + ) + + eventBus.publish( + AgentEvent.statusUpdate({ + taskId: requestContext.taskId, + contextId: requestContext.contextId, + status: { + state: TaskState.TASK_STATE_COMPLETED, + message: undefined, + timestamp: undefined + }, + metadata: undefined + }) + ) + }, + async cancelTask(_taskId: string, _eventBus: ExecutionEventBus) {} +} + +const handler = new DefaultRequestHandler(agentCard, taskStore, agentExecutor) +const jsonRpcHandler = new JsonRpcTransportHandler(handler) + +export async function POST(request: Request) { + try { + const body = await request.json() + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + + const rpcResult = await jsonRpcHandler.handle( + body, + new ServerCallContext({ state: new Map([['headers', headers]]) }) + ) + + if (rpcResult && typeof (rpcResult as any)[Symbol.asyncIterator] === 'function') { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + for await (const chunk of rpcResult as AsyncGenerator) { + controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n')) + } + controller.close() + } + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Access-Control-Allow-Origin': '*' + } + }) + } + + return NextResponse.json(rpcResult, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, A2A-Version' + } + }) + } catch (error: any) { + console.error('A2A Server POST Handler error:', error) + return NextResponse.json( + { + jsonrpc: '2.0', + id: null, + error: { + code: -32603, + message: error?.message || 'Internal error' + } + }, + { status: 500 } + ) + } +} + +export async function OPTIONS() { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, A2A-Version' + } + }) +} diff --git a/bun.lock b/bun.lock index d3d476c6..27973585 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "QCX", "dependencies": { + "@a2a-js/sdk": "^1.3.0", "@ai-sdk/amazon-bedrock": "^1.1.6", "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", @@ -118,6 +118,8 @@ }, }, "packages": { + "@a2a-js/sdk": ["@a2a-js/sdk@1.3.0", "", { "dependencies": { "jose": "^6.2.3" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "better-sqlite3": "^12.0.0 || ^13.0.0", "express": "^4.21.2 || ^5.1.0", "kysely": "^0.28.17 || ^0.29.0", "mysql2": "^3.0.0", "pg": "^8.0.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "better-sqlite3", "express", "kysely", "mysql2", "pg"], "bin": { "a2a-db": "dist/cli/a2a_db.js" } }, "sha512-OpK4nygUnLKlpTFZlM2IrYu7f3c/B7c4u4Z14+vgMq24lEINTVCxH/BqZx+GL92rNf7CoBL5oTP5UhNsl75yyg=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@1.1.6", "", { "dependencies": { "@ai-sdk/provider": "1.0.7", "@ai-sdk/provider-utils": "2.1.6", "@aws-sdk/client-bedrock-runtime": "^3.663.0" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-h6SJWpku+i8OsSz0A4RT2g2uD+3E0SUgWHsWRIpxmPNgM1DnH6lgSby5sxqAZDY5xJyJtRFW5vB9G3GEBjHy/g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="], diff --git a/components/chat-share.tsx b/components/chat-share.tsx index ff2548f7..c431e409 100644 --- a/components/chat-share.tsx +++ b/components/chat-share.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition, useEffect } from 'react' +import { useState, useTransition, useEffect, useCallback } from 'react' import { Button } from './ui/button' import { Share, UserPlus, Trash2 } from 'lucide-react' import { @@ -27,7 +27,7 @@ export function ChatShare({ chatId, className }: ChatShareProps) { const [participants, setParticipants] = useState([]) const [isLoadingParticipants, setIsLoadingParticipants] = useState(false) - const fetchParticipants = async () => { + const fetchParticipants = useCallback(async () => { setIsLoadingParticipants(true) try { const res = await fetch(`/api/chats/${chatId}/participants`) @@ -40,13 +40,13 @@ export function ChatShare({ chatId, className }: ChatShareProps) { } finally { setIsLoadingParticipants(false) } - } + }, [chatId]) useEffect(() => { if (open) { fetchParticipants() } - }, [open, chatId]) + }, [open, fetchParticipants]) const handleAddCollaborator = async (e: React.FormEvent) => { e.preventDefault() diff --git a/components/chat.tsx b/components/chat.tsx index b20f4b1c..9cec6fe7 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -91,7 +91,7 @@ export function Chat({ id }: ChatProps) { }, 300); return () => clearTimeout(timer); } - }, [aiState.messages.length, router, isStandalone, lastMessage?.type]) + }, [aiState.messages.length, router, isStandalone, lastMessage?.type, lastMessage?.id]) // Subscribe to real-time database changes for messages and collaborators useEffect(() => { diff --git a/components/compare-slider.tsx b/components/compare-slider.tsx index 34877148..72be9ba2 100644 --- a/components/compare-slider.tsx +++ b/components/compare-slider.tsx @@ -46,6 +46,7 @@ export function CompareSlider({ leftImage, rightImage, className }: CompareSlide onTouchMove={handleMove} > {/* Right Image (Google Satellite) */} + {/* eslint-disable-next-line @next/next/no-img-element */} Google Satellite
+ {/* eslint-disable-next-line @next/next/no-img-element */} Mapbox = React.memo(({ inquiry }: CopilotP useEffect(() => { const anyCheckboxChecked = Object.values(checkedOptions).some(checked => checked) setIsButtonDisabled(!(anyCheckboxChecked || query)) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // OPTIMIZATION: Memoize form submission handler diff --git a/components/copilot.tsx b/components/copilot.tsx index 246a5b9b..d0ca7de3 100644 --- a/components/copilot.tsx +++ b/components/copilot.tsx @@ -67,6 +67,7 @@ export const Copilot: React.FC = React.memo(({ inquiry }: CopilotP useEffect(() => { const anyCheckboxChecked = Object.values(checkedOptions).some(checked => checked) setIsButtonDisabled(!(anyCheckboxChecked || query)) + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // OPTIMIZATION: Memoize form submission handler diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index a1b6580f..c31eafec 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -370,6 +370,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number }, [startRotation]) // Initialize map (only once) + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (mapContainer.current && !map.current) { try { @@ -478,7 +479,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number geolocationWatchIdRef.current = null } } - }, [setMap, setIsMapLoaded, captureMapCenter, handleUserInteraction, stopRotation]) + }, [setMap, setIsMapLoaded, captureMapCenter, handleUserInteraction, stopRotation, mapData.cameraState, position?.latitude, position?.longitude]) // Handle map mode changes useEffect(() => { @@ -547,6 +548,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number }, [mapType, isMapReady, updateMeasurementLabels, setupGeolocationWatcher, captureMapCenter, setupDrawingTools, startRotation, stopRotation]) // Handle position updates from props + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (map.current && position?.latitude && position?.longitude && mapType === MapToggleEnum.RealTimeMode) { updateMapPosition(position.latitude, position.longitude) @@ -554,6 +556,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number }, [position, updateMapPosition, mapType]) // Effect to handle map updates from MapDataContext + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { if (mapData.targetPosition && map.current) { const { lat, lng } = mapData.targetPosition; diff --git a/components/report-template.tsx b/components/report-template.tsx index 71f232b3..1bab4e9e 100644 --- a/components/report-template.tsx +++ b/components/report-template.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @next/next/no-img-element */ import React from 'react' import { AIMessage } from '@/lib/types' import ReactMarkdown from 'react-markdown' diff --git a/components/search-related.tsx b/components/search-related.tsx index 2c183381..4e7b8924 100644 --- a/components/search-related.tsx +++ b/components/search-related.tsx @@ -82,7 +82,7 @@ export const SearchRelated: React.FC = React.memo(({
)) || [] - }, [data?.items, handleRelatedClick]) + }, [data?.items, handleRelatedClick, pendingQuery]) return (
diff --git a/components/usage-view.tsx b/components/usage-view.tsx index 2de3043c..9018654a 100644 --- a/components/usage-view.tsx +++ b/components/usage-view.tsx @@ -1,5 +1,6 @@ 'use client' +/* eslint-disable @next/next/no-img-element */ import React, { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' diff --git a/lib/agents/researcher.tsx b/lib/agents/researcher.tsx index 2c726aae..1b8413f8 100644 --- a/lib/agents/researcher.tsx +++ b/lib/agents/researcher.tsx @@ -95,6 +95,11 @@ ${selectedModel === 'SkyFi' ? `#### **3. SkyFi Satellite Imagery and AOI** - “Give me driving directions from LAX to Hollywood” - “What is the nearest museum to Central Park?” +#### **6. Remote Agent Interoperability (A2A Protocol)** +- **Tool**: \`a2aQueryTool\` +- **When to use**: + Any request to delegate tasks, ask questions, or query external AI agents on remote systems via the Agent-to-Agent (A2A) protocol. + ${selectedModel === 'SkyFi' ? `**Behavior when using \`skyfiQueryTool\`:** - Issue the tool call immediately. - Clearly present the search results, imagery options, or order prices returned by SkyFi in your final response. @@ -105,12 +110,13 @@ ${selectedModel === 'SkyFi' ? `**Behavior when using \`skyfiQueryTool\`:** - → Trust the system handles map rendering automatically`} #### **Summary of Decision Flow** -1. User gave explicit URLs? → \`retrieve\` -2. Query relates to uploaded documents, attachments, or custom user knowledge files? → \`documentRetrieve\` (mandatory) -3. Satellite/aerial imagery similarity, land-use embeddings, or natural-language spatial vector search? → \`locationEmbeddingsQuery\` -${selectedModel === 'SkyFi' ? `4. SkyFi account, satellite imagery search, geocoding AOI, or ordering? → \`skyfiQueryTool\` (mandatory)` : `4. Location/distance/direction/maps/POIs? → \`geospatialQueryTool\` (mandatory)`} -5. Everything else needing external data? → \`search\` -6. Otherwise → answer from knowledge +1. Request specifies delegating to or asking a remote/external A2A agent? → \`a2aQueryTool\` +2. User gave explicit URLs? → \`retrieve\` +3. Query relates to uploaded documents, attachments, or custom user knowledge files? → \`documentRetrieve\` (mandatory) +4. Satellite/aerial imagery similarity, land-use embeddings, or natural-language spatial vector search? → \`locationEmbeddingsQuery\` +${selectedModel === 'SkyFi' ? `5. SkyFi account, satellite imagery search, geocoding AOI, or ordering? → \`skyfiQueryTool\` (mandatory)` : `5. Location/distance/direction/maps/POIs? → \`geospatialQueryTool\` (mandatory)`} +6. Everything else needing external data? → \`search\` +7. Otherwise → answer from knowledge These rules override all previous instructions. diff --git a/lib/agents/tools/a2a.tsx b/lib/agents/tools/a2a.tsx new file mode 100644 index 00000000..dfb9c0f8 --- /dev/null +++ b/lib/agents/tools/a2a.tsx @@ -0,0 +1,227 @@ +import { createStreamableValue } from 'ai/rsc' +import { a2aSchema } from '@/lib/schema/a2a' +import { Card } from '@/components/ui/card' +import { ToolProps } from '.' +import { ClientFactory } from '@a2a-js/sdk/client' +import { Role, type Part } from '@a2a-js/sdk' + +const getPartText = (part: Part) => + part.content?.$case === 'text' ? part.content.value : '' + +export interface RemoteAgentConfig { + name: string + url: string + apiKey?: string +} + +export const a2aTool = ({ uiStream, fullResponse }: ToolProps) => ({ + description: + 'Call an external or remote A2A (Agent-to-Agent) protocol agent on another system to delegate tasks, ask domain questions, or query specialized remote agents.', + parameters: a2aSchema, + execute: async ({ + message, + targetAgent, + parameters + }: { + message: string + targetAgent?: string + parameters?: string + }) => { + uiStream.append( + +
+
+ Communicating with A2A Remote Agent... +
+ + ) + + // Parse configured agents from environment variable + let configuredAgents: RemoteAgentConfig[] = [] + if (process.env.A2A_REMOTE_AGENTS) { + try { + configuredAgents = JSON.parse(process.env.A2A_REMOTE_AGENTS) + } catch (e) { + console.error('Failed to parse A2A_REMOTE_AGENTS env var:', e) + } + } + + let agentUrl = '' + let apiKey: string | undefined + + if (targetAgent && /^https?:\/\//i.test(targetAgent)) { + agentUrl = targetAgent + } else if (targetAgent) { + const found = configuredAgents.find( + a => a.name.toLowerCase() === targetAgent.toLowerCase() + ) + if (found) { + agentUrl = found.url + apiKey = found.apiKey + } + } + + if (!agentUrl && configuredAgents.length > 0) { + agentUrl = configuredAgents[0].url + apiKey = configuredAgents[0].apiKey + } + + if (!agentUrl) { + const errorMsg = + 'No valid remote A2A agent target found. Specify a direct agent URL or configure A2A_REMOTE_AGENTS in environment.' + uiStream.update( + {errorMsg} + ) + return { error: errorMsg } + } + + try { + const factory = new ClientFactory() + const client = await factory.createFromUrl(agentUrl) + + const requestOptions: any = {} + if (apiKey) { + requestOptions.serviceParameters = { + authorization: `Bearer ${apiKey}` + } + } + + let payloadMessage = message + if (parameters) { + payloadMessage += `\n\nParameters: ${parameters}` + } + + let accumulatedResponse = '' + let taskState = 'completed' + + try { + const stream = client.sendMessageStream( + { + message: { + role: Role.ROLE_USER, + parts: [ + { + content: { $case: 'text', value: payloadMessage }, + metadata: undefined, + filename: '', + mediaType: 'text/plain' + } + ], + messageId: `msg-${Date.now()}`, + contextId: '', + taskId: '', + metadata: undefined, + extensions: [], + referenceTaskIds: [] + }, + tenant: '', + configuration: undefined, + metadata: undefined + }, + requestOptions + ) + + for await (const chunk of stream) { + let hasNewText = false + if (chunk.payload?.$case === 'message') { + for (const part of chunk.payload.value.parts) { + const partText = getPartText(part) + if (partText) { + accumulatedResponse += partText + hasNewText = true + } + } + } else if (chunk.payload?.$case === 'statusUpdate') { + taskState = String(chunk.payload.value.status?.state) + } else if ( + chunk.payload?.$case === 'artifactUpdate' && + chunk.payload.value.artifact?.parts + ) { + for (const part of chunk.payload.value.artifact.parts) { + const partText = getPartText(part) + if (partText) { + accumulatedResponse += partText + hasNewText = true + } + } + } + + if (hasNewText) { + uiStream.update( + +
+ A2A Remote Agent ({agentUrl}) +
+
{accumulatedResponse}
+
+ ) + } + } + } catch (streamError) { + // Fallback to single sendMessage if stream unsupported + const sendResult = await client.sendMessage( + { + message: { + role: Role.ROLE_USER, + parts: [ + { + content: { $case: 'text', value: payloadMessage }, + metadata: undefined, + filename: '', + mediaType: 'text/plain' + } + ], + messageId: `msg-${Date.now()}`, + contextId: '', + taskId: '', + metadata: undefined, + extensions: [], + referenceTaskIds: [] + }, + tenant: '', + configuration: undefined, + metadata: undefined + }, + requestOptions + ) + + if ('parts' in sendResult) { + accumulatedResponse = sendResult.parts + .map(getPartText) + .join('') + } else { + accumulatedResponse = JSON.stringify(sendResult) + } + } + + if (!accumulatedResponse) { + accumulatedResponse = 'Remote A2A agent task completed successfully.' + } + + uiStream.update( + +
+ A2A Remote Agent ({agentUrl}) +
+
{accumulatedResponse}
+
+ ) + + return { + agentUrl, + response: accumulatedResponse, + status: taskState + } + } catch (error: any) { + console.error('A2A Client Tool Execution Error:', error) + const errorText = `Failed to invoke remote A2A agent at ${agentUrl}: ${error?.message || error}` + uiStream.update( + {errorText} + ) + return { + agentUrl, + error: errorText + } + } + } +}) diff --git a/lib/agents/tools/index.tsx b/lib/agents/tools/index.tsx index 4c026e7f..4277a4c9 100644 --- a/lib/agents/tools/index.tsx +++ b/lib/agents/tools/index.tsx @@ -6,6 +6,7 @@ import { geospatialTool } from './geospatial' import { documentRetrieveTool } from './document-retrieve' import { skyfiTool } from './skyfi' import { locationEmbeddingsTool } from './location-embeddings' +import { a2aTool } from './a2a' import { DrawnFeature } from '@/lib/agents/resolution-search' import { MapProvider } from '@/lib/store/settings' @@ -35,6 +36,10 @@ export const getTools = ({ uiStream, fullResponse, mapProvider, selectedModel, d locationEmbeddingsQuery: locationEmbeddingsTool({ uiStream, fullResponse + }), + a2aQueryTool: a2aTool({ + uiStream, + fullResponse }) } diff --git a/lib/schema/a2a.ts b/lib/schema/a2a.ts new file mode 100644 index 00000000..948e6adc --- /dev/null +++ b/lib/schema/a2a.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const a2aSchema = z.object({ + message: z.string().describe('The task prompt or query to send to the remote A2A agent'), + targetAgent: z + .string() + .optional() + .describe('The name, ID, or direct base URL of the target remote A2A agent'), + parameters: z + .string() + .optional() + .describe('Optional JSON string or text parameters for additional task context') +}) + +export type A2ASchema = z.infer diff --git a/package.json b/package.json index 2f11764d..b8c432d1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@a2a-js/sdk": "^1.3.0", "@ai-sdk/amazon-bedrock": "^1.1.6", "@ai-sdk/anthropic": "^1.2.12", "@ai-sdk/google": "^1.2.22", diff --git a/tests-unit/a2a.test.ts b/tests-unit/a2a.test.ts new file mode 100644 index 00000000..edb35ce9 --- /dev/null +++ b/tests-unit/a2a.test.ts @@ -0,0 +1,96 @@ +import { test, expect } from 'bun:test' + +const { a2aSchema } = await import('../lib/schema/a2a') +const { GET: getAgentCard } = await import('../app/.well-known/agent-card.json/route') +const { POST: handleA2aPost } = await import('../app/api/a2a/route') +const { a2aTool } = await import('../lib/agents/tools/a2a') + +test('A2A Zod schema validation', () => { + const validSchemaInput = { + message: 'Query weather agent', + targetAgent: 'weather-agent', + parameters: JSON.stringify({ location: 'Oregon' }) + } + const parsedSchema = a2aSchema.parse(validSchemaInput) + expect(parsedSchema.message).toBe(validSchemaInput.message) + expect(parsedSchema.targetAgent).toBe(validSchemaInput.targetAgent) +}) + +test('Agent Card discovery endpoint (.well-known/agent-card.json)', async () => { + const cardReq = new Request('http://localhost:3000/.well-known/agent-card.json', { + headers: { + host: 'localhost:3000', + 'x-forwarded-proto': 'http' + } + }) + const cardRes = await getAgentCard(cardReq) + const cardData = await cardRes.json() + + expect(cardData.name).toBe('QCX-Terra Agent') + expect(Array.isArray(cardData.supportedInterfaces)).toBe(true) + expect(cardData.supportedInterfaces.length).toBeGreaterThan(0) +}) + +test('A2A Server POST route SendMessage handling', async () => { + const rpcMessageReq = new Request('http://localhost:3000/api/a2a', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 101, + method: 'SendMessage', + params: { + message: { + messageId: 'msg-test-01', + role: 'ROLE_USER', + parts: [{ content: { $case: 'text', value: 'Hello QCX Agent' } }] + } + } + }) + }) + + const rpcRes = await handleA2aPost(rpcMessageReq) + const rpcData = await rpcRes.json() + + expect(rpcData.jsonrpc).toBe('2.0') + expect(rpcData.id).toBe(101) + expect(rpcData.result).toBeDefined() +}) + +test('A2A Server invalid method error handling', async () => { + const invalidRpcReq = new Request('http://localhost:3000/api/a2a', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 102, + method: 'InvalidMethodName', + params: {} + }) + }) + + const invalidRes = await handleA2aPost(invalidRpcReq) + const invalidData = await invalidRes.json() + + expect(invalidData.error).toBeDefined() + expect(invalidData.error.code).toBeDefined() +}) + +test('A2A Client tool unconfigured target fallback', async () => { + const mockUiStream = { + append: () => {}, + update: () => {} + } as any + + const toolInstance = a2aTool({ uiStream: mockUiStream, fullResponse: '' }) + const toolResult = await toolInstance.execute({ + message: 'Test message', + targetAgent: 'non-existent-agent' + }) + + expect(toolResult.error).toBeDefined() +}) diff --git a/tests-unit/mock-setup.ts b/tests-unit/mock-setup.ts new file mode 100644 index 00000000..8a57ac84 --- /dev/null +++ b/tests-unit/mock-setup.ts @@ -0,0 +1,32 @@ +import { mock } from 'bun:test' + +class MockNextRequest extends Request {} + +class MockNextResponse extends Response { + static json(body: any, init?: any) { + return new Response(JSON.stringify(body), { + status: init?.status || 200, + headers: { + 'Content-Type': 'application/json', + ...(init?.headers || {}) + } + }) + } +} + +mock.module('next/server', () => ({ + NextRequest: MockNextRequest, + NextResponse: MockNextResponse +})) + +mock.module('next/cache', () => ({ + revalidatePath: () => {}, + revalidateTag: () => {}, + unstable_noStore: () => {} +})) + +mock.module('@clerk/nextjs/server', () => ({ + auth: async () => ({ userId: 'user_123' }), + currentUser: async () => ({ id: 'user_123', emailAddresses: [] }), + clerkClient: async () => ({}) +})) diff --git a/tsconfig.json b/tsconfig.json index aec11c44..1f0d5273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,6 +36,7 @@ ], "exclude": [ "node_modules", - "edge_function" + "edge_function", + "tests-unit" ] }