Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ui/src/app/actions/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 45 additions & 2 deletions ui/src/app/agents/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,7 @@ interface ValidationErrors {
knowledgeSources?: string;
tools?: string;
skills?: string;
a2aSkills?: string;
}

interface AgentPageContentProps {
Expand Down Expand Up @@ -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;
Expand All @@ -91,6 +94,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
selectedModel: null,
selectedTools: [],
skillRefs: [""],
a2aSkills: [],
byoImage: "",
byoCmd: "",
byoArgs: "",
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -706,6 +732,23 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
</div>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
A2A Configuration
</CardTitle>
</CardHeader>
<CardContent>
<A2ASkillsSection
skills={state.a2aSkills}
onChange={(skills) => setState(prev => ({ ...prev, a2aSkills: skills, errors: { ...prev.errors, a2aSkills: undefined } }))}
disabled={state.isSubmitting || state.isLoading}
error={state.errors.a2aSkills}
/>
</CardContent>
</Card>
</>
)}
<div className="flex justify-end">
Expand Down
7 changes: 6 additions & 1 deletion ui/src/components/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,17 @@ export function AgentCard({ agentResponse: { agent, model, modelProvider, deploy
<p className="text-sm text-muted-foreground line-clamp-3 overflow-hidden">
{agent.spec.description}
</p>
<div className="mt-4 flex items-center text-xs text-muted-foreground">
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
{isBYO ? (
<span title={byoImage} className="truncate">Image: {byoImage}</span>
) : (
<span className="truncate">{modelProvider} ({model})</span>
)}
{!isBYO && agent.spec?.declarative?.a2aConfig?.skills && agent.spec.declarative.a2aConfig.skills.length > 0 && (
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400 shrink-0">
A2A
</span>
)}
</div>
</CardContent>
{statusInfo && (
Expand Down
5 changes: 4 additions & 1 deletion ui/src/components/AgentsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -17,6 +17,7 @@ interface ValidationErrors {
knowledgeSources?: string;
tools?: string;
skills?: string;
a2aSkills?: string;
}

export interface AgentFormData {
Expand All @@ -31,6 +32,8 @@ export interface AgentFormData {
stream?: boolean;
// Skills
skillRefs?: string[];
// A2A Skills
a2aSkills?: AgentSkill[];
// BYO fields
byoImage?: string;
byoCmd?: string;
Expand Down
216 changes: 216 additions & 0 deletions ui/src/components/create/A2ASkillsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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<number | null>(skills.length > 0 ? 0 : null);

const updateSkill = (index: number, updates: Partial<AgentSkill>) => {
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 (
<div className="space-y-4">
<div>
<Label className="text-sm mb-2 block font-semibold">A2A Skills</Label>
<p className="text-xs mb-3 block text-muted-foreground">
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.
</p>
</div>

{skills.length === 0 && (
<p className="text-xs text-muted-foreground italic">No A2A skills configured. Click &quot;Add A2A Skill&quot; to get started.</p>
)}

<div className="space-y-3">
{skills.map((skill, index) => {
const isExpanded = expandedIndex === index;
const skillLabel = skill.name || skill.id || `Skill ${index + 1}`;

return (
<div key={index} className="border rounded-md">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-muted/50"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={() => setExpandedIndex(isExpanded ? null : index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpandedIndex(isExpanded ? null : index);
}
}}
>
Comment on lines 75 to 87
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collapsible skill header is clickable but lacks proper accessibility attributes. The div should have role="button", tabIndex={0}, and onKeyDown handler to support keyboard navigation (Enter/Space keys). Additionally, consider adding aria-expanded={isExpanded} to communicate the expand/collapse state to screen readers.

Copilot uses AI. Check for mistakes.
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<span className="text-sm font-medium">{skillLabel}</span>
{skill.id && skill.name && (
<span className="text-xs text-muted-foreground">({skill.id})</span>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
removeSkill(index);
}}
disabled={disabled}
title="Remove skill"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>

{isExpanded && (
<div className="px-4 pb-4 space-y-3 border-t">
<div className="grid grid-cols-2 gap-3 pt-3">
<div>
<Label className="text-xs mb-1 block">ID <span className="text-red-500">*</span></Label>
<Input
placeholder="e.g. kubernetes-deploy"
value={skill.id}
onChange={(e) => updateSkill(index, { id: e.target.value })}
disabled={disabled}
className="text-sm"
/>
</div>
<div>
<Label className="text-xs mb-1 block">Name <span className="text-red-500">*</span></Label>
<Input
placeholder="e.g. Kubernetes Deployment Manager"
value={skill.name}
onChange={(e) => updateSkill(index, { name: e.target.value })}
disabled={disabled}
className="text-sm"
/>
</div>
</div>

<div>
<Label className="text-xs mb-1 block">Description</Label>
<Textarea
placeholder="Describe what this skill does..."
value={skill.description || ""}
onChange={(e) => updateSkill(index, { description: e.target.value })}
disabled={disabled}
className="text-sm min-h-[60px]"
/>
</div>

<div>
<Label className="text-xs mb-1 block">Tags (comma-separated)</Label>
<Input
placeholder="e.g. kubernetes, deploy, infrastructure"
value={(skill.tags || []).join(", ")}
onChange={(e) => updateSkill(index, { tags: parseCommaSeparated(e.target.value) })}
disabled={disabled}
className="text-sm"
/>
</div>

<div>
<Label className="text-xs mb-1 block">Examples (comma-separated)</Label>
<Input
placeholder='e.g. Deploy my app to staging, Scale the web service to 3 replicas'
value={(skill.examples || []).join(", ")}
onChange={(e) => updateSkill(index, { examples: parseCommaSeparated(e.target.value) })}
disabled={disabled}
className="text-sm"
/>
</div>

<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1 block">Input Modes (comma-separated)</Label>
<Input
placeholder="e.g. text, file"
value={(skill.inputModes || []).join(", ")}
onChange={(e) => updateSkill(index, { inputModes: parseCommaSeparated(e.target.value) })}
disabled={disabled}
className="text-sm"
/>
</div>
<div>
<Label className="text-xs mb-1 block">Output Modes (comma-separated)</Label>
<Input
placeholder="e.g. text, file"
value={(skill.outputModes || []).join(", ")}
onChange={(e) => updateSkill(index, { outputModes: parseCommaSeparated(e.target.value) })}
disabled={disabled}
className="text-sm"
/>
</div>
</div>
</div>
)}
</div>
);
})}
</div>

<Button
variant="outline"
size="sm"
onClick={addSkill}
disabled={disabled}
className="w-full"
>
<PlusCircle className="h-4 w-4 mr-2" />
Add A2A Skill
</Button>

{error && (
<p className="text-red-500 text-sm mt-1">{error}</p>
)}
</div>
);
}
Loading