diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f81914c..cc676ef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,6 +22,10 @@ This is a Next.js application serving as the UI for the Inference Gateway projec - [TypeScript SDK](https://github.com/inference-gateway/typescript-sdk) - [Python SDK](https://github.com/inference-gateway/python-sdk) - [Documentation](https://docs.inference-gateway.com) + - [A2A ADK](https://github.com/inference-gateway/adk) + - [A2A Agents](https://github.com/inference-gateway/awesome-a2a) + - [A2A Debugger](https://github.com/inference-gateway/a2a-debugger) + - [A2A Google Calendar Agent](https://github.com/inference-gateway/google-calendar-agent) ## Available Tools diff --git a/.husky/pre-commit b/.husky/pre-commit index a1eb5c6..a7c7e76 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,3 +3,4 @@ npm run format:check npm run lint npm run typecheck npm run test:fast +npm run build diff --git a/app/api/v1/a2a/agents/[id]/route.ts b/app/api/v1/a2a/agents/[id]/route.ts new file mode 100644 index 0000000..a130d05 --- /dev/null +++ b/app/api/v1/a2a/agents/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + + const inferenceGatewayUrl = process.env.INFERENCE_GATEWAY_URL || 'http://localhost:8080'; + const url = `${inferenceGatewayUrl}/v1/a2a/agents/${id}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + console.error( + `Failed to fetch A2A agent details from inference gateway: ${response.statusText}` + ); + return NextResponse.json( + { error: 'Failed to fetch agent details from inference gateway' }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching A2A agent details:', error); + return NextResponse.json({ error: 'Failed to fetch agent details' }, { status: 500 }); + } +} diff --git a/app/api/v1/a2a/agents/route.ts b/app/api/v1/a2a/agents/route.ts new file mode 100644 index 0000000..84e7162 --- /dev/null +++ b/app/api/v1/a2a/agents/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const inferenceGatewayUrl = process.env.INFERENCE_GATEWAY_URL || 'http://localhost:8080/v1'; + const url = `${inferenceGatewayUrl}/a2a/agents`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error(`Failed to fetch A2A agents from inference gateway: ${response.statusText}`); + return NextResponse.json( + { error: 'Failed to fetch A2A agents from inference gateway' }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error('Error fetching A2A agents:', error); + return NextResponse.json({ error: 'Failed to fetch A2A agents' }, { status: 500 }); + } +} diff --git a/components/a2a-agents-button.tsx b/components/a2a-agents-button.tsx new file mode 100644 index 0000000..38bda98 --- /dev/null +++ b/components/a2a-agents-button.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Bot, Loader2 } from 'lucide-react'; +import { fetchA2AAgents } from '@/lib/api'; +import { A2AAgentsDialog } from './a2a-agents-dialog'; +import type { A2AAgent } from '@/types/a2a'; + +export function A2AAgentsButton() { + const [agents, setAgents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + + const availableAgents = agents.filter(agent => agent.status === 'available'); + + useEffect(() => { + const loadAgents = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchA2AAgents(); + setAgents(response.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load A2A agents'); + } finally { + setIsLoading(false); + } + }; + + loadAgents(); + }, []); + + const handleClick = () => { + setDialogOpen(true); + }; + + return ( + <> + + + { + const loadAgents = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetchA2AAgents(); + setAgents(response.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load A2A agents'); + } finally { + setIsLoading(false); + } + }; + loadAgents(); + }} + /> + + ); +} diff --git a/components/a2a-agents-dialog.tsx b/components/a2a-agents-dialog.tsx new file mode 100644 index 0000000..3343cf4 --- /dev/null +++ b/components/a2a-agents-dialog.tsx @@ -0,0 +1,531 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + RefreshCw, + Bot, + CheckCircle, + XCircle, + AlertCircle, + ExternalLink, + Info, + Clock, + Zap, + Bell, + History, + Package, + ArrowRight, + ArrowLeft, + Sparkles, +} from 'lucide-react'; +import type { A2AAgent, A2ASkill } from '@/types/a2a'; + +interface A2AAgentsDialogProps { + open: boolean; + onOpenChangeAction: (open: boolean) => void; + agents: A2AAgent[]; + isLoading: boolean; + error: string | null; + onRefreshAction: () => void; +} + +export function A2AAgentsDialog({ + open, + onOpenChangeAction, + agents, + isLoading, + error, + onRefreshAction, +}: A2AAgentsDialogProps) { + const [selectedAgent, setSelectedAgent] = useState(null); + + const availableAgents = agents.filter(agent => agent.status === 'available'); + const unavailableAgents = agents.filter(agent => agent.status !== 'available'); + + const getStatusIcon = (status: A2AAgent['status']) => { + switch (status) { + case 'available': + return ; + case 'unavailable': + return ; + case 'error': + return ; + default: + return ; + } + }; + + const getStatusBadgeVariant = (status: A2AAgent['status']) => { + switch (status) { + case 'available': + return 'default' as const; + case 'unavailable': + return 'destructive' as const; + case 'error': + return 'secondary' as const; + default: + return 'outline' as const; + } + }; + + return ( + + + +
+
+ + A2A Agents +
+ +
+ + Agent-to-Agent communication endpoints for distributed AI workflows. + {agents.length > 0 && ( + + {availableAgents.length} available, {unavailableAgents.length} unavailable + + )} + +
+ +
+ {/* Agent List */} +
+ + {error && ( + + + {error} + + )} + + {agents.length === 0 && !isLoading && !error && ( +
+ +

No A2A agents configured

+

Configure agents to enable Agent-to-Agent communication

+
+ )} + + {isLoading && ( +
+ +

Loading A2A agents...

+
+ )} + +
+ {availableAgents.map(agent => ( + setSelectedAgent(agent)} + > + +
+ {agent.name} +
+ {getStatusIcon(agent.status)} + + {agent.status} + +
+
+ + {agent.description} + +
+ +
+ {agent.skills?.length || 0} skills + v{agent.version} + {agent.lastUpdated && ( +
+ + {new Date(agent.lastUpdated).toLocaleDateString()} +
+ )} +
+
+
+ ))} + + {unavailableAgents.length > 0 && availableAgents.length > 0 && ( + + )} + + {unavailableAgents.map(agent => ( + setSelectedAgent(agent)} + > + +
+ {agent.name} +
+ {getStatusIcon(agent.status)} + + {agent.status} + +
+
+ + {agent.description} + +
+ +
+ {agent.skills?.length || 0} skills + v{agent.version} +
+
+
+ ))} +
+
+
+ + {/* Agent Details */} + {selectedAgent && ( + <> + +
+ +
+
+
+

{selectedAgent.name}

+
+ {getStatusIcon(selectedAgent.status)} + + {selectedAgent.status} + +
+
+

+ {selectedAgent.description} +

+ + {selectedAgent.provider && ( + + )} +
+ +
+
+ Version: {selectedAgent.version} +
+ {selectedAgent.author && ( +
+ Author: {selectedAgent.author} +
+ )} + {selectedAgent.license && ( +
+ License: {selectedAgent.license} +
+ )} + {selectedAgent.homepage && ( +
+ Homepage: + + + Link + +
+ )} +
+ + + + {selectedAgent.capabilities && ( + <> +
+

+ + Capabilities +

+
+
+
+ + Streaming +
+ + {selectedAgent.capabilities.streaming ? 'Enabled' : 'Disabled'} + +
+ +
+
+ + Push Notifications +
+ + {selectedAgent.capabilities.pushNotifications + ? 'Enabled' + : 'Disabled'} + +
+ +
+
+ + + State Transition History + +
+ + {selectedAgent.capabilities.stateTransitionHistory + ? 'Enabled' + : 'Disabled'} + +
+ + {selectedAgent.capabilities.extensions && + selectedAgent.capabilities.extensions.length > 0 && ( +
+
+ + Extensions +
+ + {selectedAgent.capabilities.extensions.length} available + +
+ )} +
+ + {(selectedAgent.defaultInputModes && + selectedAgent.defaultInputModes.length > 0) || + (selectedAgent.defaultOutputModes && + selectedAgent.defaultOutputModes.length > 0) ? ( +
+ {selectedAgent.defaultInputModes && + selectedAgent.defaultInputModes.length > 0 && ( +
+
+ + + Default Input Modes + +
+
+ {selectedAgent.defaultInputModes.map((mode, index) => ( + + {mode} + + ))} +
+
+ )} + {selectedAgent.defaultOutputModes && + selectedAgent.defaultOutputModes.length > 0 && ( +
+
+ + + Default Output Modes + +
+
+ {selectedAgent.defaultOutputModes.map((mode, index) => ( + + {mode} + + ))} +
+
+ )} +
+ ) : null} +
+ + + )} + +
+

+ + Skills ({selectedAgent.skills?.length || 0}) +

+
+ {(selectedAgent.skills || []).map((skill: A2ASkill, index: number) => ( + + +
+
+ + {skill.name} + + + {skill.description} + +
+ + {skill.id} + +
+ + {skill.tags && skill.tags.length > 0 && ( +
+ {skill.tags.map((tag, tagIndex) => ( + + {tag} + + ))} +
+ )} + + {skill.examples && skill.examples.length > 0 && ( +
+
Examples:
+
+ {skill.examples.slice(0, 3).map((example, exampleIndex) => ( +
+ “{example}” +
+ ))} + {skill.examples.length > 3 && ( +
+ ... and {skill.examples.length - 3} more examples +
+ )} +
+
+ )} + +
+ {skill.inputModes && skill.inputModes.length > 0 && ( +
+ Input: +
+ {skill.inputModes.map((mode, modeIndex) => ( + + {mode} + + ))} +
+
+ )} + {skill.outputModes && skill.outputModes.length > 0 && ( +
+ Output: +
+ {skill.outputModes.map((mode, modeIndex) => ( + + {mode} + + ))} +
+
+ )} +
+
+
+ ))} + + {(!selectedAgent.skills || selectedAgent.skills.length === 0) && ( +
+ +

No skills configured for this agent

+
+ )} +
+
+ + +
+

Agent URL

+
+ {selectedAgent.url} +
+
+
+
+
+ + )} +
+
+
+ ); +} diff --git a/components/input-area.tsx b/components/input-area.tsx index 736386f..cd23683 100644 --- a/components/input-area.tsx +++ b/components/input-area.tsx @@ -17,6 +17,7 @@ import { useIsMobile } from '@/hooks/use-mobile'; import { useRef, useState, useEffect, useMemo } from 'react'; import { TokenUsage } from './token-usage'; import { MCPToolsButton } from './mcp-tools-button'; +import { A2AAgentsButton } from './a2a-agents-button'; interface CommandOption { name: string; @@ -353,6 +354,7 @@ export function InputArea({ + )} diff --git a/components/tool-call-bubble.tsx b/components/tool-call-bubble.tsx index b9ec513..4f49ecc 100644 --- a/components/tool-call-bubble.tsx +++ b/components/tool-call-bubble.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import { ChevronDown, ChevronUp, Terminal, Wrench } from 'lucide-react'; +import { ChevronDown, ChevronUp, Terminal, Wrench, Bot } from 'lucide-react'; import { SchemaChatCompletionMessageToolCall } from '@inference-gateway/sdk'; import { CodeBlock } from './code-block'; -import { isMCPTool } from '@/lib/tools'; +import { isMCPTool, isA2ATool } from '@/lib/tools'; interface ToolCallBubbleProps { toolCalls: SchemaChatCompletionMessageToolCall[] | undefined; @@ -31,6 +31,21 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { } const hasMCPTools = toolCalls.some(call => isMCPTool(call.function.name)); + const hasA2ATools = toolCalls.some(call => isA2ATool(call.function.name)); + + const getToolTypeLabel = () => { + if (hasA2ATools && hasMCPTools) return 'Tool Calls'; + if (hasA2ATools) return 'A2A Tool Calls'; + if (hasMCPTools) return 'MCP Tool Calls'; + return 'Tool Calls'; + }; + + const getToolIcon = () => { + if (hasA2ATools && hasMCPTools) return ; + if (hasA2ATools) return ; + if (hasMCPTools) return ; + return ; + }; return (
@@ -39,9 +54,9 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { onClick={toggleExpanded} className="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs bg-[hsl(var(--thinking-bubble-bg))] border border-[hsl(var(--thinking-bubble-border))] hover:bg-[hsl(var(--thinking-bubble-hover-bg))] transition-colors duration-200 text-[hsl(var(--thinking-bubble-text))]" > - {hasMCPTools ? : } + {getToolIcon()} - {hasMCPTools ? 'MCP Tool Calls' : 'Tool Calls'} ({toolCalls.length}) + {getToolTypeLabel()} ({toolCalls.length}) {isExpanded ? : } @@ -52,6 +67,7 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { {toolCalls.map((toolCall, index) => { const isToolExpanded = expandedToolIds.has(toolCall.id || `tool-${index}`); const isMCP = isMCPTool(toolCall.function.name); + const isA2A = isA2ATool(toolCall.function.name); let parsedArguments: Record = {}; try { @@ -70,16 +86,25 @@ export default function ToolCallBubble({ toolCalls }: ToolCallBubbleProps) { className="bg-[hsl(var(--thinking-bubble-content-bg))] border border-[hsl(var(--thinking-bubble-content-border))] rounded-lg shadow-sm" >