mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🎯 feat: Per-Tool Intent Label Toggles in the Agent Builder (#14550)
* 🎯 feat: Per-Tool Intent Label Toggles in the Agent Builder Saved agents have had per-tool intent control on the backend since the capability landed (tool_options[name].describe_intent, consumed by applyIntentLabels), but the builder offered no way to set it - the capability was invisible to saved agents on MCP tools, which default off. This is the deferred UI slice. The MCP tools panel gains a fourth per-tool option toggle (Captions icon, teal) next to defer / programmatic / background, plus the matching section-header bulk toggle, gated on the tool_intents capability. The toggle writes describe_intent: true through the same withBooleanOption path the sibling flags use, so an opt-in composes with existing entries and clearing the last flag drops the tool's entry entirely. No backend changes: the agent CRUD schema already validates describe_intent and initialization already consumes it. * 🧯 fix: Keep the Intent Toggle Truthful for Programmatic-Only Tools A tool marked Programmatic in the builder gets allowed_callers: ['code_execution'], and the backend's canInjectIntentParam deliberately skips non-direct tools (no card renders for calls made from code), so an intent opt-in on such a tool is guaranteed inert. The UI could nevertheless show both settings active. The intent toggle now mirrors the runtime gate: isToolProgrammaticOnly (allowed_callers set and missing 'direct', the exact backend predicate) renders the per-row toggle inert with a tooltip explaining why, shows it unpressed regardless of any stored flag, and the bulk toggle and its all-state consider only tools the label can actually reach. The stored describe_intent value is preserved, so unmarking Programmatic restores the user's earlier choice instead of destroying it. OptionToggle gains a disabled state (dimmed, non-interactive, tooltip kept) shared by the row and bulk variants.
This commit is contained in:
parent
52b2ebf948
commit
a67b0c1da8
10 changed files with 278 additions and 15 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Check, Clock, Code2, Info, Zap } from 'lucide-react';
|
||||
import { Check, Clock, Code2, Captions, Info, Zap } from 'lucide-react';
|
||||
import type { AgentToolType } from 'librechat-data-provider';
|
||||
import OptionToggle from './OptionToggle';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -11,13 +11,19 @@ interface MCPToolItemProps {
|
|||
isDeferred: boolean;
|
||||
isProgrammatic: boolean;
|
||||
isBackground: boolean;
|
||||
isIntent: boolean;
|
||||
/** Intent labels never reach a programmatic-only tool (no card renders for
|
||||
* calls made from code), so the toggle is shown inert with an explanation. */
|
||||
intentDisabled: boolean;
|
||||
deferredToolsEnabled: boolean;
|
||||
programmaticToolsEnabled: boolean;
|
||||
backgroundToolsEnabled: boolean;
|
||||
toolIntentsEnabled: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onToggleDefer: () => void;
|
||||
onToggleProgrammatic: () => void;
|
||||
onToggleBackground: () => void;
|
||||
onToggleIntent: () => void;
|
||||
}
|
||||
|
||||
const iconButton =
|
||||
|
|
@ -33,9 +39,13 @@ export default function MCPToolItem({
|
|||
onToggleProgrammatic,
|
||||
isBackground,
|
||||
onToggleBackground,
|
||||
isIntent,
|
||||
intentDisabled,
|
||||
onToggleIntent,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
backgroundToolsEnabled,
|
||||
toolIntentsEnabled,
|
||||
}: MCPToolItemProps) {
|
||||
const localize = useLocalize();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
|
@ -100,6 +110,19 @@ export default function MCPToolItem({
|
|||
onToggle={onToggleBackground}
|
||||
/>
|
||||
)}
|
||||
{toolIntentsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Captions}
|
||||
pressed={isIntent}
|
||||
disabled={intentDisabled}
|
||||
label={localize('com_ui_mcp_intent')}
|
||||
tooltip={localize(
|
||||
intentDisabled ? 'com_ui_mcp_intent_programmatic' : 'com_ui_mcp_click_to_intent',
|
||||
)}
|
||||
activeClass="text-teal-500"
|
||||
onToggle={onToggleIntent}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,18 @@ interface OptionToggleProps {
|
|||
activeClass: string;
|
||||
onToggle: () => void;
|
||||
size?: 'sm' | 'md';
|
||||
/**
|
||||
* Renders the toggle inert (dimmed, non-interactive) while keeping it
|
||||
* visible with its tooltip, so the user can learn WHY the option is
|
||||
* unavailable instead of it silently disappearing.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon toggle for a per-tool option (defer / programmatic / background), shared
|
||||
* between the per-tool row (`sm`) and the section-header bulk action (`md`).
|
||||
* Icon toggle for a per-tool option (defer / programmatic / background /
|
||||
* intent), shared between the per-tool row (`sm`) and the section-header bulk
|
||||
* action (`md`).
|
||||
*/
|
||||
export default function OptionToggle({
|
||||
icon: Icon,
|
||||
|
|
@ -26,6 +33,7 @@ export default function OptionToggle({
|
|||
activeClass,
|
||||
onToggle,
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
}: OptionToggleProps) {
|
||||
return (
|
||||
<TooltipAnchor
|
||||
|
|
@ -34,13 +42,19 @@ export default function OptionToggle({
|
|||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
onClick={disabled ? undefined : onToggle}
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md transition-colors hover:bg-surface-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
'flex items-center justify-center rounded-md transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
|
||||
size === 'sm' ? 'size-6' : 'size-7',
|
||||
pressed ? activeClass : 'text-text-secondary hover:text-text-primary',
|
||||
disabled
|
||||
? 'cursor-not-allowed text-text-tertiary opacity-60'
|
||||
: cn(
|
||||
'hover:bg-surface-hover',
|
||||
pressed ? activeClass : 'text-text-secondary hover:text-text-primary',
|
||||
),
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,14 @@ const mockSetValue = jest.fn();
|
|||
const mockGetValues = jest.fn((): string[] => []);
|
||||
const mockInitializeServer = jest.fn();
|
||||
const mockIsConnectionDeferred = jest.fn((): boolean => false);
|
||||
const mockToggleIntentAll = jest.fn();
|
||||
const mockIsToolProgrammaticOnly = jest.fn((_toolId: string): boolean => false);
|
||||
const mockCapabilities = {
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
backgroundToolsEnabled: false,
|
||||
toolIntentsEnabled: false,
|
||||
};
|
||||
|
||||
jest.mock('react-hook-form', () => ({
|
||||
useFormContext: () => ({ control: {}, setValue: mockSetValue, getValues: mockGetValues }),
|
||||
|
|
@ -26,10 +34,7 @@ jest.mock('~/components/ui', () => ({
|
|||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useCopyToClipboard: () => jest.fn(),
|
||||
useAgentCapabilities: () => ({
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
}),
|
||||
useAgentCapabilities: () => mockCapabilities,
|
||||
useGetAgentsConfig: () => ({ agentsConfig: { capabilities: [] } }),
|
||||
useMCPServerManager: () => ({
|
||||
getServerStatusIconProps: () => null,
|
||||
|
|
@ -45,15 +50,20 @@ jest.mock('~/hooks', () => ({
|
|||
isToolDeferred: () => false,
|
||||
isToolProgrammatic: () => false,
|
||||
isToolBackground: () => false,
|
||||
isToolIntent: () => false,
|
||||
isToolProgrammaticOnly: mockIsToolProgrammaticOnly,
|
||||
toggleToolDefer: jest.fn(),
|
||||
toggleToolProgrammatic: jest.fn(),
|
||||
toggleToolBackground: jest.fn(),
|
||||
toggleToolIntent: jest.fn(),
|
||||
areAllToolsDeferred: () => false,
|
||||
areAllToolsProgrammatic: () => false,
|
||||
areAllToolsBackground: () => false,
|
||||
areAllToolsIntent: () => false,
|
||||
toggleDeferAll: jest.fn(),
|
||||
toggleProgrammaticAll: jest.fn(),
|
||||
toggleBackgroundAll: jest.fn(),
|
||||
toggleIntentAll: mockToggleIntentAll,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -96,6 +106,7 @@ jest.mock('~/components/MCP/McpOAuthDialog', () => ({
|
|||
jest.mock('@librechat/client', () => {
|
||||
const React = jest.requireActual('react');
|
||||
return {
|
||||
TooltipAnchor: ({ render }: { render: React.ReactElement }) => render,
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||
React.createElement('button', { type: 'button', onClick }, children),
|
||||
Checkbox: ({
|
||||
|
|
@ -141,6 +152,10 @@ describe('McpSection', () => {
|
|||
mockInitializeServer.mockReset();
|
||||
mockIsConnectionDeferred.mockReset();
|
||||
mockIsConnectionDeferred.mockReturnValue(false);
|
||||
mockToggleIntentAll.mockClear();
|
||||
mockIsToolProgrammaticOnly.mockReset();
|
||||
mockIsToolProgrammaticOnly.mockReturnValue(false);
|
||||
mockCapabilities.toolIntentsEnabled = false;
|
||||
});
|
||||
|
||||
test('renders one row per tool', () => {
|
||||
|
|
@ -283,6 +298,27 @@ describe('McpSection', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('bulk intent toggle renders only when the tool_intents capability is enabled', () => {
|
||||
const { unmount } = render(<McpSection item={item} />);
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_mcp_intent_all' })).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
mockCapabilities.toolIntentsEnabled = true;
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_mcp_intent_all' }));
|
||||
expect(mockToggleIntentAll).toHaveBeenCalledWith(item.server.tools);
|
||||
});
|
||||
|
||||
test('bulk intent skips programmatic-only tools (label can never reach them)', () => {
|
||||
mockCapabilities.toolIntentsEnabled = true;
|
||||
mockIsToolProgrammaticOnly.mockImplementation((toolId: string) => toolId === 'mcp:srv:a');
|
||||
render(<McpSection item={item} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'com_ui_mcp_intent_all' }));
|
||||
expect(mockToggleIntentAll).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ tool_id: 'mcp:srv:b' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('shows the runtime-tools hint when attached via the wildcard', () => {
|
||||
mockGetValues.mockReturnValue(['sys__all__sys_mcp_srv']);
|
||||
const empty: McpItem = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Clock, Code2, Zap } from 'lucide-react';
|
||||
import { Clock, Code2, Captions, Zap } from 'lucide-react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { Button, Spinner, Checkbox, Skeleton } from '@librechat/client';
|
||||
import type { MouseEvent } from 'react';
|
||||
|
|
@ -76,21 +76,30 @@ export default function McpSection({ item }: Props) {
|
|||
const [autoSelectPending, setAutoSelectPending] = useState(false);
|
||||
const { mcpServersMap, mcpToolsLoading } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { deferredToolsEnabled, programmaticToolsEnabled, backgroundToolsEnabled } =
|
||||
useAgentCapabilities(agentsConfig?.capabilities);
|
||||
const {
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
backgroundToolsEnabled,
|
||||
toolIntentsEnabled,
|
||||
} = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
const {
|
||||
isToolDeferred,
|
||||
isToolProgrammatic,
|
||||
isToolBackground,
|
||||
isToolIntent,
|
||||
isToolProgrammaticOnly,
|
||||
toggleToolDefer,
|
||||
toggleToolProgrammatic,
|
||||
toggleToolBackground,
|
||||
toggleToolIntent,
|
||||
areAllToolsDeferred,
|
||||
areAllToolsProgrammatic,
|
||||
areAllToolsBackground,
|
||||
areAllToolsIntent,
|
||||
toggleDeferAll,
|
||||
toggleProgrammaticAll,
|
||||
toggleBackgroundAll,
|
||||
toggleIntentAll,
|
||||
} = useMCPToolOptions();
|
||||
|
||||
const serverName = item.server.serverName;
|
||||
|
|
@ -154,6 +163,11 @@ export default function McpSection({ item }: Props) {
|
|||
const allDeferred = areAllToolsDeferred(tools);
|
||||
const allProgrammatic = areAllToolsProgrammatic(tools);
|
||||
const allBackground = areAllToolsBackground(tools);
|
||||
/** Programmatic-only tools can never carry an intent label (the backend's
|
||||
* `canInjectIntentParam` skips non-direct tools), so both the bulk toggle
|
||||
* and its all-state only consider tools the label can actually reach. */
|
||||
const intentEligibleTools = tools.filter((tool) => !isToolProgrammaticOnly(tool.tool_id));
|
||||
const allIntent = areAllToolsIntent(intentEligibleTools);
|
||||
const statusIconProps = getServerStatusIconProps(serverName);
|
||||
const configDialogProps = getConfigDialogProps();
|
||||
const connectionState = statusIconProps?.serverStatus?.connectionState;
|
||||
|
|
@ -339,7 +353,21 @@ export default function McpSection({ item }: Props) {
|
|||
onToggle={() => toggleBackgroundAll(tools)}
|
||||
/>
|
||||
)}
|
||||
{(deferredToolsEnabled || programmaticToolsEnabled || backgroundToolsEnabled) && (
|
||||
{toolIntentsEnabled && (
|
||||
<OptionToggle
|
||||
icon={Captions}
|
||||
size="md"
|
||||
pressed={allIntent}
|
||||
disabled={intentEligibleTools.length === 0}
|
||||
label={localize(allIntent ? 'com_ui_mcp_unintent_all' : 'com_ui_mcp_intent_all')}
|
||||
activeClass="text-teal-600 dark:text-teal-500"
|
||||
onToggle={() => toggleIntentAll(intentEligibleTools)}
|
||||
/>
|
||||
)}
|
||||
{(deferredToolsEnabled ||
|
||||
programmaticToolsEnabled ||
|
||||
backgroundToolsEnabled ||
|
||||
toolIntentsEnabled) && (
|
||||
<span className="mx-1 h-4 w-px bg-border-light" aria-hidden="true" />
|
||||
)}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs text-text-secondary">
|
||||
|
|
@ -376,13 +404,21 @@ export default function McpSection({ item }: Props) {
|
|||
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
|
||||
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
|
||||
isBackground={backgroundToolsEnabled && isToolBackground(tool.tool_id)}
|
||||
isIntent={
|
||||
toolIntentsEnabled &&
|
||||
isToolIntent(tool.tool_id) &&
|
||||
!isToolProgrammaticOnly(tool.tool_id)
|
||||
}
|
||||
intentDisabled={isToolProgrammaticOnly(tool.tool_id)}
|
||||
deferredToolsEnabled={deferredToolsEnabled}
|
||||
programmaticToolsEnabled={programmaticToolsEnabled}
|
||||
backgroundToolsEnabled={backgroundToolsEnabled}
|
||||
toolIntentsEnabled={toolIntentsEnabled}
|
||||
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
|
||||
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
|
||||
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
|
||||
onToggleBackground={() => toggleToolBackground(tool.tool_id)}
|
||||
onToggleIntent={() => toggleToolIntent(tool.tool_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,13 +23,17 @@ function setup(overrides: Partial<React.ComponentProps<typeof MCPToolItem>> = {}
|
|||
isDeferred: false,
|
||||
isProgrammatic: false,
|
||||
isBackground: false,
|
||||
isIntent: false,
|
||||
intentDisabled: false,
|
||||
deferredToolsEnabled: false,
|
||||
programmaticToolsEnabled: false,
|
||||
backgroundToolsEnabled: false,
|
||||
toolIntentsEnabled: false,
|
||||
onToggleSelect: jest.fn(),
|
||||
onToggleDefer: jest.fn(),
|
||||
onToggleProgrammatic: jest.fn(),
|
||||
onToggleBackground: jest.fn(),
|
||||
onToggleIntent: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<MCPToolItem {...props} />);
|
||||
|
|
@ -106,4 +110,32 @@ describe('MCPToolItem', () => {
|
|||
setup();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_mcp_background' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('intent label is an inline button rendered only when enabled', () => {
|
||||
const props = setup({ toolIntentsEnabled: true });
|
||||
const intentButton = screen.getByRole('button', { name: 'com_ui_mcp_intent' });
|
||||
fireEvent.click(intentButton);
|
||||
expect(props.onToggleIntent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('intent button is absent when tool intents are disabled', () => {
|
||||
setup();
|
||||
expect(screen.queryByRole('button', { name: 'com_ui_mcp_intent' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('intent button reflects the opted-in state via aria-pressed', () => {
|
||||
setup({ toolIntentsEnabled: true, isIntent: true });
|
||||
expect(screen.getByRole('button', { name: 'com_ui_mcp_intent' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
test('intent button is inert for programmatic-only tools (label can never render)', () => {
|
||||
const props = setup({ toolIntentsEnabled: true, intentDisabled: true });
|
||||
const intentButton = screen.getByRole('button', { name: 'com_ui_mcp_intent' });
|
||||
expect(intentButton).toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(intentButton);
|
||||
expect(props.onToggleIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -109,4 +109,17 @@ describe('useAgentCapabilities', () => {
|
|||
expect(result.current.deferredToolsEnabled).toBe(true);
|
||||
expect(result.current.programmaticToolsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return toolIntentsEnabled as true when tool_intents is in capabilities', () => {
|
||||
const { result } = renderHook(() => useAgentCapabilities([AgentCapabilities.tool_intents]));
|
||||
|
||||
expect(result.current.toolIntentsEnabled).toBe(true);
|
||||
expect(result.current.backgroundToolsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return toolIntentsEnabled as false when absent', () => {
|
||||
const { result } = renderHook(() => useAgentCapabilities([]));
|
||||
|
||||
expect(result.current.toolIntentsEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -632,6 +632,78 @@ describe('useMCPToolOptions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('intent (describe_intent)', () => {
|
||||
it('reads the flag only when explicitly true', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
tool1: { describe_intent: true },
|
||||
tool2: { run_in_background: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolIntent('tool1')).toBe(true);
|
||||
expect(result.current.isToolIntent('tool2')).toBe(false);
|
||||
});
|
||||
|
||||
it('toggling on writes describe_intent without clobbering sibling flags', () => {
|
||||
mockGetValues.mockReturnValue({ tool1: { run_in_background: true } });
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
act(() => result.current.toggleToolIntent('tool1'));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { run_in_background: true, describe_intent: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('toggling off removes the flag and drops an empty entry', () => {
|
||||
mockGetValues.mockReturnValue({ tool1: { describe_intent: true } });
|
||||
(useWatch as jest.Mock).mockReturnValue({ tool1: { describe_intent: true } });
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
act(() => result.current.toggleToolIntent('tool1'));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith('tool_options', {}, { shouldDirty: true });
|
||||
});
|
||||
|
||||
it('bulk toggle marks every tool and unmarks when all are set', () => {
|
||||
const tools = [createMockTool('tool1'), createMockTool('tool2')];
|
||||
mockGetValues.mockReturnValue({});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
act(() => result.current.toggleIntentAll(tools));
|
||||
|
||||
expect(mockSetValue).toHaveBeenCalledWith(
|
||||
'tool_options',
|
||||
{ tool1: { describe_intent: true }, tool2: { describe_intent: true } },
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolProgrammaticOnly', () => {
|
||||
it('mirrors the backend canInjectIntentParam gate exactly', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue({
|
||||
codeOnly: { allowed_callers: ['code_execution'] },
|
||||
both: { allowed_callers: ['direct', 'code_execution'] },
|
||||
directOnly: { allowed_callers: ['direct'] },
|
||||
empty: { allowed_callers: [] },
|
||||
unset: { defer_loading: true },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMCPToolOptions());
|
||||
|
||||
expect(result.current.isToolProgrammaticOnly('codeOnly')).toBe(true);
|
||||
expect(result.current.isToolProgrammaticOnly('both')).toBe(false);
|
||||
expect(result.current.isToolProgrammaticOnly('directOnly')).toBe(false);
|
||||
expect(result.current.isToolProgrammaticOnly('empty')).toBe(false);
|
||||
expect(result.current.isToolProgrammaticOnly('unset')).toBe(false);
|
||||
expect(result.current.isToolProgrammaticOnly('missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formToolOptions', () => {
|
||||
it('should return undefined when useWatch returns undefined', () => {
|
||||
(useWatch as jest.Mock).mockReturnValue(undefined);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface AgentCapabilitiesResult {
|
|||
deferredToolsEnabled: boolean;
|
||||
programmaticToolsEnabled: boolean;
|
||||
backgroundToolsEnabled: boolean;
|
||||
toolIntentsEnabled: boolean;
|
||||
}
|
||||
|
||||
export default function useAgentCapabilities(
|
||||
|
|
@ -85,6 +86,11 @@ export default function useAgentCapabilities(
|
|||
[capabilities],
|
||||
);
|
||||
|
||||
const toolIntentsEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.tool_intents) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
return {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
|
|
@ -99,5 +105,6 @@ export default function useAgentCapabilities(
|
|||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
backgroundToolsEnabled,
|
||||
toolIntentsEnabled,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { AgentToolOptions, AllowedCaller, AgentToolType } from 'librechat-d
|
|||
import type { UseFormGetValues, UseFormSetValue } from 'react-hook-form';
|
||||
import type { AgentForm } from '~/common';
|
||||
|
||||
type BooleanToolOptionKey = 'defer_loading' | 'run_in_background';
|
||||
type BooleanToolOptionKey = 'defer_loading' | 'run_in_background' | 'describe_intent';
|
||||
|
||||
interface BooleanOptionHandlers {
|
||||
isSet: (toolId: string) => boolean;
|
||||
|
|
@ -24,15 +24,20 @@ interface UseMCPToolOptionsReturn {
|
|||
isToolDeferred: (toolId: string) => boolean;
|
||||
isToolProgrammatic: (toolId: string) => boolean;
|
||||
isToolBackground: (toolId: string) => boolean;
|
||||
isToolIntent: (toolId: string) => boolean;
|
||||
isToolProgrammaticOnly: (toolId: string) => boolean;
|
||||
toggleToolDefer: (toolId: string) => void;
|
||||
toggleToolProgrammatic: (toolId: string) => void;
|
||||
toggleToolBackground: (toolId: string) => void;
|
||||
toggleToolIntent: (toolId: string) => void;
|
||||
areAllToolsDeferred: (tools: AgentToolType[]) => boolean;
|
||||
areAllToolsProgrammatic: (tools: AgentToolType[]) => boolean;
|
||||
areAllToolsBackground: (tools: AgentToolType[]) => boolean;
|
||||
areAllToolsIntent: (tools: AgentToolType[]) => boolean;
|
||||
toggleDeferAll: (tools: AgentToolType[]) => void;
|
||||
toggleProgrammaticAll: (tools: AgentToolType[]) => void;
|
||||
toggleBackgroundAll: (tools: AgentToolType[]) => void;
|
||||
toggleIntentAll: (tools: AgentToolType[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -146,6 +151,7 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
|
|||
|
||||
const defer = useBooleanToolOption('defer_loading', formContext);
|
||||
const background = useBooleanToolOption('run_in_background', formContext);
|
||||
const intent = useBooleanToolOption('describe_intent', formContext);
|
||||
|
||||
/** `allowed_callers` is array-valued, so the programmatic family stays bespoke. */
|
||||
const isToolProgrammatic = useCallback(
|
||||
|
|
@ -154,6 +160,20 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
|
|||
[formToolOptions],
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the tool can NEVER be called directly (`allowed_callers` set and
|
||||
* missing `direct`) — mirrors the backend's `canInjectIntentParam` gate: no
|
||||
* card renders for such calls, so intent labels are guaranteed inert and
|
||||
* the intent toggle must not present a setting runtime will ignore.
|
||||
*/
|
||||
const isToolProgrammaticOnly = useCallback(
|
||||
(toolId: string): boolean => {
|
||||
const callers = formToolOptions?.[toolId]?.allowed_callers;
|
||||
return callers != null && callers.length > 0 && !callers.includes('direct');
|
||||
},
|
||||
[formToolOptions],
|
||||
);
|
||||
|
||||
const toggleToolProgrammatic = useCallback(
|
||||
(toolId: string) => {
|
||||
const currentOptions = getValues('tool_options') || {};
|
||||
|
|
@ -240,14 +260,19 @@ export default function useMCPToolOptions(): UseMCPToolOptionsReturn {
|
|||
isToolDeferred: defer.isSet,
|
||||
isToolProgrammatic,
|
||||
isToolBackground: background.isSet,
|
||||
isToolIntent: intent.isSet,
|
||||
isToolProgrammaticOnly,
|
||||
toggleToolDefer: defer.toggle,
|
||||
toggleToolProgrammatic,
|
||||
toggleToolBackground: background.toggle,
|
||||
toggleToolIntent: intent.toggle,
|
||||
areAllToolsDeferred: defer.areAllSet,
|
||||
areAllToolsProgrammatic,
|
||||
areAllToolsBackground: background.areAllSet,
|
||||
areAllToolsIntent: intent.areAllSet,
|
||||
toggleDeferAll: defer.toggleAll,
|
||||
toggleProgrammaticAll,
|
||||
toggleBackgroundAll: background.toggleAll,
|
||||
toggleIntentAll: intent.toggleAll,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1343,6 +1343,7 @@
|
|||
"com_ui_mcp_background_all": "Mark all as background",
|
||||
"com_ui_mcp_click_to_background": "Runs this tool in the background: it returns immediately and the agent polls for the result, so it can keep working while the tool runs.",
|
||||
"com_ui_mcp_click_to_defer": "Loads this tool only when the agent needs it instead of keeping it active the whole time. Useful when a server has many tools.",
|
||||
"com_ui_mcp_click_to_intent": "The agent states in one sentence what each call is about to do, shown live as the call's status. Adds a small token cost per request.",
|
||||
"com_ui_mcp_click_to_programmatic": "The agent calls this tool by writing code, not as a direct tool call. Best for tools meant to run in code.",
|
||||
"com_ui_mcp_defer": "Defer",
|
||||
"com_ui_mcp_defer_all": "Defer all tools",
|
||||
|
|
@ -1353,6 +1354,9 @@
|
|||
"com_ui_mcp_init_failed": "Failed to initialize MCP server",
|
||||
"com_ui_mcp_initialize": "Initialize",
|
||||
"com_ui_mcp_initialized_success": "MCP server '{{0}}' initialized successfully",
|
||||
"com_ui_mcp_intent": "Intent label",
|
||||
"com_ui_mcp_intent_all": "Enable intent labels for all",
|
||||
"com_ui_mcp_intent_programmatic": "Intent labels don't apply to programmatic-only tools: calls made from code render no tool card, so there is nowhere to show the label.",
|
||||
"com_ui_mcp_invalid_url": "Please enter a valid URL",
|
||||
"com_ui_mcp_missing_custom_user_vars": "MCP server '{{0}}' requires variables [{{1}}] which are not set",
|
||||
"com_ui_mcp_no_description": "No description available",
|
||||
|
|
@ -1391,6 +1395,7 @@
|
|||
"com_ui_mcp_unbackground_all": "Unmark all as background",
|
||||
"com_ui_mcp_undefer": "Undefer",
|
||||
"com_ui_mcp_undefer_all": "Undefer all tools",
|
||||
"com_ui_mcp_unintent_all": "Disable intent labels for all",
|
||||
"com_ui_mcp_unprogrammatic_all": "Unmark all as programmatic",
|
||||
"com_ui_mcp_update_var": "Update {{0}}",
|
||||
"com_ui_mcp_url": "MCP Server URL",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue