From 3e97238f408c2ee4d38d89bc43749e7cf0dcceb8 Mon Sep 17 00:00:00 2001 From: OpSpawn Date: Fri, 13 Feb 2026 02:58:03 +0000 Subject: [PATCH 1/2] feat(ui): add A2A skills configuration to agent creation/editing Add UI support for configuring A2A (Agent-to-Agent) skills when creating or editing a Declarative agent. This enables users to define A2A skills directly from the UI, matching the existing Agent CRD's a2aConfig field. Changes: - New A2ASkillsSection component with collapsible skill forms - Support for all AgentSkill fields: id, name, description, tags, examples, inputModes, outputModes - Form validation (required id/name, duplicate detection) - Edit mode loads existing A2A config from agent spec - A2A badge shown on agent cards when configured - Wired through AgentFormData -> fromAgentFormDataToAgent -> API Closes #360 Signed-off-by: OpSpawn --- ui/src/app/actions/agents.ts | 6 + ui/src/app/agents/new/page.tsx | 47 +++- ui/src/components/AgentCard.tsx | 7 +- ui/src/components/AgentsProvider.tsx | 5 +- ui/src/components/create/A2ASkillsSection.tsx | 207 ++++++++++++++++++ 5 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/create/A2ASkillsSection.tsx diff --git a/ui/src/app/actions/agents.ts b/ui/src/app/actions/agents.ts index 96c4936b0..f011092af 100644 --- a/ui/src/app/actions/agents.ts +++ b/ui/src/app/actions/agents.ts @@ -116,6 +116,12 @@ function fromAgentFormDataToAgent(agentFormData: AgentFormData): Agent { tools: convertTools(agentFormData.tools || []), }; + if (agentFormData.a2aSkills && agentFormData.a2aSkills.length > 0) { + base.spec!.declarative!.a2aConfig = { + skills: agentFormData.a2aSkills, + }; + } + if (agentFormData.skillRefs && agentFormData.skillRefs.length > 0) { base.spec!.skills = { refs: agentFormData.skillRefs, diff --git a/ui/src/app/agents/new/page.tsx b/ui/src/app/agents/new/page.tsx index 4d15e6195..6bcfb97b0 100644 --- a/ui/src/app/agents/new/page.tsx +++ b/ui/src/app/agents/new/page.tsx @@ -4,11 +4,12 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2, Settings2, PlusCircle, Trash2 } from "lucide-react"; -import { ModelConfig, AgentType } from "@/types"; +import { Loader2, Settings2, PlusCircle, Trash2, Globe } from "lucide-react"; +import { ModelConfig, AgentType, AgentSkill } from "@/types"; import { SystemPromptSection } from "@/components/create/SystemPromptSection"; import { ModelSelectionSection } from "@/components/create/ModelSelectionSection"; import { ToolsSection } from "@/components/create/ToolsSection"; +import { A2ASkillsSection } from "@/components/create/A2ASkillsSection"; import { useRouter, useSearchParams } from "next/navigation"; import { useAgents } from "@/components/AgentsProvider"; import { LoadingState } from "@/components/LoadingState"; @@ -32,6 +33,7 @@ interface ValidationErrors { knowledgeSources?: string; tools?: string; skills?: string; + a2aSkills?: string; } interface AgentPageContentProps { @@ -69,6 +71,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedModel: SelectedModelType | null; selectedTools: Tool[]; skillRefs: string[]; + a2aSkills: AgentSkill[]; byoImage: string; byoCmd: string; byoArgs: string; @@ -91,6 +94,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedModel: null, selectedTools: [], skillRefs: [""], + a2aSkills: [], byoImage: "", byoCmd: "", byoArgs: "", @@ -136,6 +140,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo selectedTools: (agent.spec?.declarative?.tools && agentResponse.tools) ? agentResponse.tools : [], selectedModel: agentResponse.modelConfigRef ? { model: agentResponse.model || "default-model-config", ref: agentResponse.modelConfigRef } : null, skillRefs: (agent.spec?.skills?.refs && agent.spec.skills.refs.length > 0) ? agent.spec.skills.refs : [""], + a2aSkills: agent.spec?.declarative?.a2aConfig?.skills || [], stream: agent.spec?.declarative?.stream ?? false, byoImage: "", byoCmd: "", @@ -226,6 +231,26 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo // If all refs are empty/whitespace, that's fine - no skills will be included } + if (state.agentType === "Declarative" && state.a2aSkills && state.a2aSkills.length > 0) { + for (const skill of state.a2aSkills) { + if (!skill.id.trim()) { + newErrors.a2aSkills = "All A2A skills must have an ID"; + break; + } + if (!skill.name.trim()) { + newErrors.a2aSkills = "All A2A skills must have a name"; + break; + } + } + if (!newErrors.a2aSkills) { + const ids = state.a2aSkills.map(s => s.id.trim().toLowerCase()); + const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); + if (duplicateIds.length > 0) { + newErrors.a2aSkills = `Duplicate skill ID: ${duplicateIds[0]}`; + } + } + } + setState(prev => ({ ...prev, errors: newErrors })); return Object.keys(newErrors).length === 0; }; @@ -280,6 +305,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo stream: state.stream, tools: state.selectedTools, skillRefs: state.agentType === "Declarative" ? (state.skillRefs || []).filter(ref => ref.trim()) : undefined, + a2aSkills: state.agentType === "Declarative" ? (state.a2aSkills || []).filter(s => s.id.trim() && s.name.trim()) : undefined, // BYO byoImage: state.byoImage, byoCmd: state.byoCmd || undefined, @@ -706,6 +732,23 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo + + + + + + A2A Configuration + + + + setState(prev => ({ ...prev, a2aSkills: skills, errors: { ...prev.errors, a2aSkills: undefined } }))} + disabled={state.isSubmitting || state.isLoading} + error={state.errors.a2aSkills} + /> + + )}
diff --git a/ui/src/components/AgentCard.tsx b/ui/src/components/AgentCard.tsx index a6b5fa6eb..7bbd7c569 100644 --- a/ui/src/components/AgentCard.tsx +++ b/ui/src/components/AgentCard.tsx @@ -80,12 +80,17 @@ export function AgentCard({ agentResponse: { agent, model, modelProvider, deploy

{agent.spec.description}

-
+
{isBYO ? ( Image: {byoImage} ) : ( {modelProvider} ({model}) )} + {!isBYO && agent.spec?.declarative?.a2aConfig?.skills && agent.spec.declarative.a2aConfig.skills.length > 0 && ( + + A2A + + )}
{statusInfo && ( diff --git a/ui/src/components/AgentsProvider.tsx b/ui/src/components/AgentsProvider.tsx index c6ae25b65..91b458bb6 100644 --- a/ui/src/components/AgentsProvider.tsx +++ b/ui/src/components/AgentsProvider.tsx @@ -3,7 +3,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"; import { getAgent as getAgentAction, createAgent, getAgents } from "@/app/actions/agents"; import { getTools } from "@/app/actions/tools"; -import type { Agent, Tool, AgentResponse, BaseResponse, ModelConfig, ToolsResponse, AgentType, EnvVar } from "@/types"; +import type { Agent, Tool, AgentResponse, BaseResponse, ModelConfig, ToolsResponse, AgentType, EnvVar, AgentSkill } from "@/types"; import { getModelConfigs } from "@/app/actions/modelConfigs"; import { isResourceNameValid } from "@/lib/utils"; @@ -17,6 +17,7 @@ interface ValidationErrors { knowledgeSources?: string; tools?: string; skills?: string; + a2aSkills?: string; } export interface AgentFormData { @@ -31,6 +32,8 @@ export interface AgentFormData { stream?: boolean; // Skills skillRefs?: string[]; + // A2A Skills + a2aSkills?: AgentSkill[]; // BYO fields byoImage?: string; byoCmd?: string; diff --git a/ui/src/components/create/A2ASkillsSection.tsx b/ui/src/components/create/A2ASkillsSection.tsx new file mode 100644 index 000000000..2c1fef2ba --- /dev/null +++ b/ui/src/components/create/A2ASkillsSection.tsx @@ -0,0 +1,207 @@ +import React from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { PlusCircle, Trash2, ChevronDown, ChevronUp } from "lucide-react"; +import type { AgentSkill } from "@/types"; + +export const EMPTY_A2A_SKILL: AgentSkill = { + id: "", + name: "", + description: "", + tags: [], + examples: [], + inputModes: ["text"], + outputModes: ["text"], +}; + +interface A2ASkillsSectionProps { + skills: AgentSkill[]; + onChange: (skills: AgentSkill[]) => void; + disabled?: boolean; + error?: string; +} + +export function A2ASkillsSection({ skills, onChange, disabled, error }: A2ASkillsSectionProps) { + const [expandedIndex, setExpandedIndex] = React.useState(skills.length > 0 ? 0 : null); + + const updateSkill = (index: number, updates: Partial) => { + const updated = [...skills]; + updated[index] = { ...updated[index], ...updates }; + onChange(updated); + }; + + const addSkill = () => { + onChange([...skills, { ...EMPTY_A2A_SKILL }]); + setExpandedIndex(skills.length); + }; + + const removeSkill = (index: number) => { + const updated = skills.filter((_, i) => i !== index); + onChange(updated); + if (expandedIndex === index) { + setExpandedIndex(null); + } else if (expandedIndex !== null && expandedIndex > index) { + setExpandedIndex(expandedIndex - 1); + } + }; + + const parseCommaSeparated = (value: string): string[] => { + return value.split(",").map((s) => s.trim()).filter(Boolean); + }; + + return ( +
+
+ +

+ Define A2A (Agent-to-Agent) skills that this agent exposes. Each skill describes a capability + that other agents can discover and invoke via the A2A protocol. +

+
+ + {skills.length === 0 && ( +

No A2A skills configured. Click "Add A2A Skill" to get started.

+ )} + +
+ {skills.map((skill, index) => { + const isExpanded = expandedIndex === index; + const skillLabel = skill.name || skill.id || `Skill ${index + 1}`; + + return ( +
+
setExpandedIndex(isExpanded ? null : index)} + > +
+ {isExpanded ? ( + + ) : ( + + )} + {skillLabel} + {skill.id && skill.name && ( + ({skill.id}) + )} +
+ +
+ + {isExpanded && ( +
+
+
+ + updateSkill(index, { id: e.target.value })} + disabled={disabled} + className="text-sm" + /> +
+
+ + updateSkill(index, { name: e.target.value })} + disabled={disabled} + className="text-sm" + /> +
+
+ +
+ +