🦥 feat: Add Deferred Tools as Agents Capability (#11295)

This commit is contained in:
Danny Avila 2026-01-10 20:25:34 -05:00
parent 70a218ff82
commit 9cb9f42f52
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
8 changed files with 819 additions and 79 deletions

View file

@ -1,9 +1,8 @@
import React, { useState, useCallback } from 'react';
import { ChevronDown, Clock } from 'lucide-react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useFormContext, useWatch } from 'react-hook-form';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import type { AgentToolOptions } from 'librechat-data-provider';
import {
Label,
ESide,
@ -18,8 +17,15 @@ import {
AccordionContent,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentToolOptions } from 'librechat-data-provider';
import type { AgentForm, MCPServerInfo } from '~/common';
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
import {
useAgentCapabilities,
useMCPServerManager,
useGetAgentsConfig,
useRemoveMCPTool,
useLocalize,
} from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { cn } from '~/utils';
@ -29,6 +35,8 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
const { removeTool } = useRemoveMCPTool();
const { getValues, setValue, control } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
const { agentsConfig } = useGetAgentsConfig();
const { deferredToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
@ -242,45 +250,47 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
</div>
{/* Defer All toggle - icon only with tooltip */}
<TooltipAnchor
description={
areAllToolsDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
side="top"
role="button"
tabIndex={isExpanded ? 0 : -1}
aria-label={
areAllToolsDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
aria-pressed={areAllToolsDeferred}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isExpanded ? 'visible' : 'pointer-events-none invisible',
areAllToolsDeferred
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
)}
onClick={(e) => {
e.stopPropagation();
toggleDeferAll();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
{deferredToolsEnabled && (
<TooltipAnchor
description={
areAllToolsDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
side="top"
role="button"
tabIndex={isExpanded ? 0 : -1}
aria-label={
areAllToolsDeferred
? localize('com_ui_mcp_undefer_all')
: localize('com_ui_mcp_defer_all')
}
aria-pressed={areAllToolsDeferred}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isExpanded ? 'visible' : 'pointer-events-none invisible',
areAllToolsDeferred
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
)}
onClick={(e) => {
e.stopPropagation();
toggleDeferAll();
}
}}
>
<Clock
className={cn('h-4 w-4', areAllToolsDeferred && 'fill-amber-500/30')}
/>
</TooltipAnchor>
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
toggleDeferAll();
}
}}
>
<Clock
className={cn('h-4 w-4', areAllToolsDeferred && 'fill-amber-500/30')}
/>
</TooltipAnchor>
)}
<div className="flex items-center gap-1">
{/* Caret button for accordion */}
@ -343,7 +353,7 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{serverInfo.tools?.map((subTool) => {
const isDeferred = isToolDeferred(subTool.tool_id);
const isDeferred = deferredToolsEnabled && isToolDeferred(subTool.tool_id);
return (
<label
key={subTool.tool_id}
@ -388,42 +398,44 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
</span>
<div className="ml-auto flex items-center gap-1">
{/* Per-tool defer toggle - icon only */}
<TooltipAnchor
description={
isDeferred
? localize('com_ui_mcp_click_to_undefer')
: localize('com_ui_mcp_click_to_defer')
}
side="top"
role="button"
aria-label={
isDeferred
? localize('com_ui_mcp_undefer')
: localize('com_ui_mcp_defer_loading')
}
aria-pressed={isDeferred}
className={cn(
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isDeferred
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
toggleToolDefer(subTool.tool_id);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
{deferredToolsEnabled && (
<TooltipAnchor
description={
isDeferred
? localize('com_ui_mcp_click_to_undefer')
: localize('com_ui_mcp_click_to_defer')
}
side="top"
role="button"
aria-label={
isDeferred
? localize('com_ui_mcp_undefer')
: localize('com_ui_mcp_defer_loading')
}
aria-pressed={isDeferred}
className={cn(
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isDeferred
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
toggleToolDefer(subTool.tool_id);
}
}}
>
<Clock className={cn('h-3.5 w-3.5', isDeferred && 'fill-amber-500/30')} />
</TooltipAnchor>
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleToolDefer(subTool.tool_id);
}
}}
>
<Clock className={cn('h-3.5 w-3.5', isDeferred && 'fill-amber-500/30')} />
</TooltipAnchor>
)}
{subTool.metadata.description && (
<InfoHoverCard side={ESide.Left} text={subTool.metadata.description} />
)}

View file

@ -0,0 +1,88 @@
import { renderHook } from '@testing-library/react';
import { AgentCapabilities } from 'librechat-data-provider';
import useAgentCapabilities from '../useAgentCapabilities';
describe('useAgentCapabilities', () => {
it('should return all capabilities as false when capabilities is undefined', () => {
const { result } = renderHook(() => useAgentCapabilities(undefined));
expect(result.current.toolsEnabled).toBe(false);
expect(result.current.actionsEnabled).toBe(false);
expect(result.current.artifactsEnabled).toBe(false);
expect(result.current.ocrEnabled).toBe(false);
expect(result.current.contextEnabled).toBe(false);
expect(result.current.fileSearchEnabled).toBe(false);
expect(result.current.webSearchEnabled).toBe(false);
expect(result.current.codeEnabled).toBe(false);
expect(result.current.deferredToolsEnabled).toBe(false);
});
it('should return all capabilities as false when capabilities is empty array', () => {
const { result } = renderHook(() => useAgentCapabilities([]));
expect(result.current.toolsEnabled).toBe(false);
expect(result.current.deferredToolsEnabled).toBe(false);
});
it('should return true for enabled capabilities', () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.deferred_tools,
AgentCapabilities.file_search,
];
const { result } = renderHook(() => useAgentCapabilities(capabilities));
expect(result.current.toolsEnabled).toBe(true);
expect(result.current.deferredToolsEnabled).toBe(true);
expect(result.current.fileSearchEnabled).toBe(true);
expect(result.current.actionsEnabled).toBe(false);
expect(result.current.webSearchEnabled).toBe(false);
});
it('should return deferredToolsEnabled as true when deferred_tools is in capabilities', () => {
const capabilities = [AgentCapabilities.deferred_tools];
const { result } = renderHook(() => useAgentCapabilities(capabilities));
expect(result.current.deferredToolsEnabled).toBe(true);
});
it('should return deferredToolsEnabled as false when deferred_tools is not in capabilities', () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.actions,
AgentCapabilities.artifacts,
];
const { result } = renderHook(() => useAgentCapabilities(capabilities));
expect(result.current.deferredToolsEnabled).toBe(false);
});
it('should handle all capabilities being enabled', () => {
const capabilities = [
AgentCapabilities.tools,
AgentCapabilities.actions,
AgentCapabilities.artifacts,
AgentCapabilities.ocr,
AgentCapabilities.context,
AgentCapabilities.file_search,
AgentCapabilities.web_search,
AgentCapabilities.execute_code,
AgentCapabilities.deferred_tools,
];
const { result } = renderHook(() => useAgentCapabilities(capabilities));
expect(result.current.toolsEnabled).toBe(true);
expect(result.current.actionsEnabled).toBe(true);
expect(result.current.artifactsEnabled).toBe(true);
expect(result.current.ocrEnabled).toBe(true);
expect(result.current.contextEnabled).toBe(true);
expect(result.current.fileSearchEnabled).toBe(true);
expect(result.current.webSearchEnabled).toBe(true);
expect(result.current.codeEnabled).toBe(true);
expect(result.current.deferredToolsEnabled).toBe(true);
});
});

View file

@ -10,6 +10,7 @@ interface AgentCapabilitiesResult {
fileSearchEnabled: boolean;
webSearchEnabled: boolean;
codeEnabled: boolean;
deferredToolsEnabled: boolean;
}
export default function useAgentCapabilities(
@ -55,6 +56,11 @@ export default function useAgentCapabilities(
[capabilities],
);
const deferredToolsEnabled = useMemo(
() => capabilities?.includes(AgentCapabilities.deferred_tools) ?? false,
[capabilities],
);
return {
ocrEnabled,
codeEnabled,
@ -64,5 +70,6 @@ export default function useAgentCapabilities(
artifactsEnabled,
webSearchEnabled,
fileSearchEnabled,
deferredToolsEnabled,
};
}