Skip to content
Closed
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
5 changes: 2 additions & 3 deletions go/api/v1alpha1/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,8 @@ type AgentSpec struct {
// Whether to stream the response from the model.
// If not specified, the default value is true.
// +optional
Stream *bool `json:"stream,omitempty"`
// +kubebuilder:validation:MaxItems=20
Tools []*Tool `json:"tools,omitempty"`
Stream *bool `json:"stream,omitempty"`
Tools []*Tool `json:"tools,omitempty"`
// Can either be a reference to the name of a Memory in the same namespace as the referencing Agent, or a reference to the name of a Memory in a different namespace in the form <namespace>/<name>
// +optional
Memory []string `json:"memory,omitempty"`
Expand Down
7 changes: 2 additions & 5 deletions go/api/v1alpha2/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ type SkillForAgent struct {
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`

// The list of skill images to fetch.
// +kubebuilder:validation:MaxItems=20
// +kubebuilder:validation:MinItems=1
// +optional
Refs []string `json:"refs,omitempty"`
Expand All @@ -90,7 +89,6 @@ type SkillForAgent struct {
GitAuthSecretRef *corev1.LocalObjectReference `json:"gitAuthSecretRef,omitempty"`

// Git repositories to fetch skills from.
// +kubebuilder:validation:MaxItems=20
// +kubebuilder:validation:MinItems=1
// +optional
GitRefs []GitRepo `json:"gitRefs,omitempty"`
Expand Down Expand Up @@ -132,9 +130,8 @@ type DeclarativeAgentSpec struct {
// Whether to stream the response from the model.
// If not specified, the default value is false.
// +optional
Stream bool `json:"stream,omitempty"`
// +kubebuilder:validation:MaxItems=20
Tools []*Tool `json:"tools,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []*Tool `json:"tools,omitempty"`
// A2AConfig instantiates an A2A server for this agent,
// served on the HTTP port of the kagent kubernetes
// controller (default 8083).
Expand Down
4 changes: 0 additions & 4 deletions go/config/crd/bases/kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2277,7 +2277,6 @@ spec:
rule: '!(has(self.agent) && self.type != ''Agent'')'
- message: type.agent must be specified for Agent filter.type
rule: '!(!has(self.agent) && self.type == ''Agent'')'
maxItems: 20
type: array
type: object
status:
Expand Down Expand Up @@ -10086,7 +10085,6 @@ spec:
rule: '!(has(self.agent) && self.type != ''Agent'')'
- message: type.agent must be specified for Agent filter.type
rule: '!(!has(self.agent) && self.type == ''Agent'')'
maxItems: 20
type: array
type: object
x-kubernetes-validations:
Expand Down Expand Up @@ -10142,7 +10140,6 @@ spec:
required:
- url
type: object
maxItems: 20
minItems: 1
type: array
insecureSkipVerify:
Expand All @@ -10154,7 +10151,6 @@ spec:
description: The list of skill images to fetch.
items:
type: string
maxItems: 20
minItems: 1
type: array
type: object
Expand Down
4 changes: 0 additions & 4 deletions helm/kagent-crds/templates/kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2277,7 +2277,6 @@ spec:
rule: '!(has(self.agent) && self.type != ''Agent'')'
- message: type.agent must be specified for Agent filter.type
rule: '!(!has(self.agent) && self.type == ''Agent'')'
maxItems: 20
type: array
type: object
status:
Expand Down Expand Up @@ -10086,7 +10085,6 @@ spec:
rule: '!(has(self.agent) && self.type != ''Agent'')'
- message: type.agent must be specified for Agent filter.type
rule: '!(!has(self.agent) && self.type == ''Agent'')'
maxItems: 20
type: array
type: object
x-kubernetes-validations:
Expand Down Expand Up @@ -10142,7 +10140,6 @@ spec:
required:
- url
type: object
maxItems: 20
minItems: 1
type: array
insecureSkipVerify:
Expand All @@ -10154,7 +10151,6 @@ spec:
description: The list of skill images to fetch.
items:
type: string
maxItems: 20
minItems: 1
type: array
type: object
Expand Down
7 changes: 6 additions & 1 deletion ui/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,9 @@ env/
htmlcov/
workspace/
node_modules/
.next/
.next/
**/__tests__/
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
4 changes: 1 addition & 3 deletions ui/src/app/agents/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -774,9 +774,7 @@ function AgentPageContent({ isEditMode, agentName, agentNamespace }: AgentPageCo
variant="outline"
size="icon"
onClick={() => {
if ((state.skillRefs || []).length < 20) {
setState(prev => ({ ...prev, skillRefs: [...prev.skillRefs, ""] }));
}
setState(prev => ({ ...prev, skillRefs: [...prev.skillRefs, ""] }));
}}
title="Add skill"
>
Expand Down
25 changes: 10 additions & 15 deletions ui/src/components/create/SelectToolsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { toast } from "sonner";
import KagentLogo from "../kagent-logo";
import { k8sRefUtils } from "@/lib/k8sUtils";

// Maximum number of tools that can be selected
const MAX_TOOLS_LIMIT = 20;
// Threshold at which a warning is shown about potential increased token usage
const TOOLS_WARNING_THRESHOLD = 20;

interface SelectToolsDialogProps {
open: boolean;
Expand Down Expand Up @@ -117,7 +117,7 @@ export const SelectToolsDialog: React.FC<SelectToolsDialogProps> = ({ open, onOp
}, 0);
}, [localSelectedTools]);

const isLimitReached = actualSelectedCount >= MAX_TOOLS_LIMIT;
const isWarningThresholdReached = actualSelectedCount >= TOOLS_WARNING_THRESHOLD;

const filteredAvailableItems = useMemo(() => {
const searchLower = searchTerm.toLowerCase();
Expand Down Expand Up @@ -221,10 +221,6 @@ export const SelectToolsDialog: React.FC<SelectToolsDialogProps> = ({ open, onOp
return;
}

if (actualSelectedCount >= MAX_TOOLS_LIMIT) {
return;
}

let toolToAdd: Tool;

if (isAgentResponse(item)) {
Expand Down Expand Up @@ -399,20 +395,19 @@ export const SelectToolsDialog: React.FC<SelectToolsDialogProps> = ({ open, onOp
{items.map((item) => {
const { displayName, description, identifier, providerText } = getItemDisplayInfo(item);
const isSelected = isItemSelected(item);
const isDisabled = !isSelected && isLimitReached;

return (
<div
key={identifier}
className={`flex items-center justify-between p-3 pr-2 group min-w-0 ${isDisabled ? 'opacity-50 cursor-not-allowed' : isSelected ? 'cursor-default' : 'cursor-pointer hover:bg-muted/50'}`}
onClick={() => !isDisabled && !isSelected && handleAddItem(item)}
className={`flex items-center justify-between p-3 pr-2 group min-w-0 ${isSelected ? 'cursor-default' : 'cursor-pointer hover:bg-muted/50'}`}
onClick={() => !isSelected && handleAddItem(item)}
>
<div className="flex-1 overflow-hidden pr-2">
<p className="font-medium text-sm truncate overflow-hidden">{highlightMatch(displayName, searchTerm)}</p>
{description && <p className="text-xs text-muted-foreground">{highlightMatch(description, searchTerm)}</p>}
{providerText && <p className="text-xs text-muted-foreground/80 font-mono mt-1">{highlightMatch(providerText, searchTerm)}</p>}
</div>
{!isSelected && !isDisabled && (
{!isSelected && (
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100 text-green-600 hover:text-green-700" >
<PlusCircle className="h-4 w-4"/>
</Button>
Expand Down Expand Up @@ -478,17 +473,17 @@ export const SelectToolsDialog: React.FC<SelectToolsDialogProps> = ({ open, onOp
{/* Right Panel: Selected Tools */}
<div className="w-1/2 flex flex-col p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Selected ({actualSelectedCount}/{MAX_TOOLS_LIMIT})</h3>
<h3 className="text-lg font-semibold">Selected ({actualSelectedCount})</h3>
<Button variant="ghost" size="sm" onClick={clearAllSelectedTools} disabled={actualSelectedCount === 0}>
Clear All
</Button>
</div>

{isLimitReached && actualSelectedCount >= MAX_TOOLS_LIMIT && (
{isWarningThresholdReached && (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3 flex items-start gap-2 text-amber-800 text-sm">
<AlertCircle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" />
<div>
Tool limit reached. Deselect a tool to add another.
You have selected {actualSelectedCount} tools. A large number of tools may increase token usage and affect performance.
</div>
</div>
)}
Expand Down Expand Up @@ -607,7 +602,7 @@ export const SelectToolsDialog: React.FC<SelectToolsDialogProps> = ({ open, onOp
<DialogFooter className="p-4 border-t mt-auto">
<div className="flex justify-between w-full items-center">
<div className="text-sm text-muted-foreground">
Select up to {MAX_TOOLS_LIMIT} tools for your agent.
Select tools for your agent.
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>Cancel</Button>
Expand Down
137 changes: 137 additions & 0 deletions ui/src/components/create/__tests__/SelectToolsDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Test: Tool Limit Warning (Issue #694)
*
* Verifies that the hard 20-tool limit has been replaced with a soft warning.
* - Tools can be added beyond 20 (no hard block)
* - A warning is shown when 20+ tools are selected
* - The warning informs about potential token usage impact
*/

import { describe, it, expect, jest, beforeEach } from "@jest/globals";
import { render, screen, act } from "@testing-library/react";
import { SelectToolsDialog } from "../SelectToolsDialog";
import type { Tool, ToolsResponse, AgentResponse } from "@/types";

// Mock next/link
jest.mock("next/link", () => {
return {
__esModule: true,
default: ({ children, ...props }: { children: React.ReactNode; href: string }) => <a {...props}>{children}</a>,
};
});

// Mock sonner toast
jest.mock("sonner", () => ({
toast: {
warning: jest.fn(),
error: jest.fn(),
success: jest.fn(),
},
}));

// Mock ScrollArea to render children directly
jest.mock("@/components/ui/scroll-area", () => ({
ScrollArea: ({ children }: { children: React.ReactNode }) => <div data-testid="scroll-area">{children}</div>,
}));

// Helper to create mock tools
function createMockTools(count: number): ToolsResponse[] {
return Array.from({ length: count }, (_, i) => ({
id: `tool-${i}`,
name: `Tool ${i}`,
description: `Description for tool ${i}`,
server_name: `server-${i}`,
server_description: `Server ${i}`,
})) as unknown as ToolsResponse[];
}

// Helper to create selected tools
function createSelectedTools(count: number): Tool[] {
return Array.from({ length: count }, (_, i) => ({
type: "McpServer" as const,
mcpServer: {
name: `server-${i}`,
kind: "ToolServer",
apiGroup: "kagent.dev",
toolNames: [`tool-${i}`],
},
}));
}

const baseProps = {
onOpenChange: jest.fn(),
availableTools: createMockTools(30),
onToolsSelected: jest.fn(),
availableAgents: [] as AgentResponse[],
loadingAgents: false,
currentAgentNamespace: "default",
};

/**
* Helper: renders the dialog first closed, then opens it to trigger
* the useLayoutEffect that copies selectedTools → localSelectedTools.
*/
function renderDialogWithTools(selectedTools: Tool[]) {
const props = { ...baseProps, selectedTools, open: false };
const { rerender } = render(<SelectToolsDialog {...props} />);
// Transition from closed to open to trigger useLayoutEffect
act(() => {
rerender(<SelectToolsDialog {...props} open={true} />);
});
return { rerender };
}

describe("SelectToolsDialog - Tool Limit Warning (Issue #694)", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should NOT show warning when fewer than 20 tools are selected", () => {
renderDialogWithTools(createSelectedTools(5));

expect(screen.queryByText(/may increase token usage/i)).not.toBeInTheDocument();
});

it("should show warning when 20 or more tools are selected", () => {
renderDialogWithTools(createSelectedTools(20));

expect(screen.getByText(/may increase token usage/i)).toBeInTheDocument();
});

it("should show warning with correct count when more than 20 tools are selected", () => {
renderDialogWithTools(createSelectedTools(25));

expect(screen.getByText(/may increase token usage/i)).toBeInTheDocument();
expect(screen.getByText(/You have selected 25 tools/)).toBeInTheDocument();
});

it("should display the selected count without a maximum limit", () => {
renderDialogWithTools(createSelectedTools(25));

// Should show "Selected (25)" not "Selected (25/20)"
expect(screen.getByText("Selected (25)")).toBeInTheDocument();
expect(screen.queryByText(/\/20/)).not.toBeInTheDocument();
});

it("should not display 'Tool limit reached' blocking message", () => {
renderDialogWithTools(createSelectedTools(20));

expect(screen.queryByText(/Tool limit reached/i)).not.toBeInTheDocument();
expect(screen.queryByText(/Deselect a tool to add another/i)).not.toBeInTheDocument();
});

it("should show footer text without mentioning a maximum", () => {
renderDialogWithTools([]);

expect(screen.getByText(/Select tools for your agent/i)).toBeInTheDocument();
expect(screen.queryByText(/Select up to/i)).not.toBeInTheDocument();
});

it("should not disable tool items when at or above the threshold", () => {
renderDialogWithTools(createSelectedTools(20));

// No elements should have the opacity-50 cursor-not-allowed class
const disabledElements = document.querySelectorAll(".opacity-50.cursor-not-allowed");
expect(disabledElements.length).toBe(0);
});
});
Loading