mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-26 12:16:13 +01:00
🦥 feat: Add Deferred Tools as Agents Capability (#11295)
This commit is contained in:
parent
8a4fad7fda
commit
ebf5fb22bd
8 changed files with 819 additions and 79 deletions
|
|
@ -409,8 +409,14 @@ async function loadAgentTools({
|
||||||
const checkCapability = (capability) => {
|
const checkCapability = (capability) => {
|
||||||
const enabled = enabledCapabilities.has(capability);
|
const enabled = enabledCapabilities.has(capability);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
const isToolCapability = [
|
||||||
|
AgentCapabilities.file_search,
|
||||||
|
AgentCapabilities.execute_code,
|
||||||
|
AgentCapabilities.web_search,
|
||||||
|
].includes(capability);
|
||||||
|
const suffix = isToolCapability ? ' despite configured tool.' : '.';
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Capability "${capability}" disabled${capability === AgentCapabilities.tools ? '.' : ' despite configured tool.'} User: ${req.user.id} | Agent: ${agent.id}`,
|
`Capability "${capability}" disabled${suffix} User: ${req.user.id} | Agent: ${agent.id}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return enabled;
|
return enabled;
|
||||||
|
|
@ -519,11 +525,13 @@ async function loadAgentTools({
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
|
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
|
||||||
|
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
|
||||||
const { toolRegistry, additionalTools, hasDeferredTools } = await buildToolClassification({
|
const { toolRegistry, additionalTools, hasDeferredTools } = await buildToolClassification({
|
||||||
loadedTools,
|
loadedTools,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
agentToolOptions: agent.tool_options,
|
agentToolOptions: agent.tool_options,
|
||||||
|
deferredToolsEnabled,
|
||||||
loadAuthValues,
|
loadAuthValues,
|
||||||
});
|
});
|
||||||
agentTools.push(...additionalTools);
|
agentTools.push(...additionalTools);
|
||||||
|
|
|
||||||
149
api/server/services/__tests__/ToolService.spec.js
Normal file
149
api/server/services/__tests__/ToolService.spec.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
const { AgentCapabilities, defaultAgentCapabilities } = require('librechat-data-provider');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for ToolService capability checking logic.
|
||||||
|
* The actual loadAgentTools function has many dependencies, so we test
|
||||||
|
* the capability checking logic in isolation.
|
||||||
|
*/
|
||||||
|
describe('ToolService - Capability Checking', () => {
|
||||||
|
describe('checkCapability logic', () => {
|
||||||
|
/**
|
||||||
|
* Simulates the checkCapability function from loadAgentTools
|
||||||
|
*/
|
||||||
|
const createCheckCapability = (enabledCapabilities, logger = { warn: jest.fn() }) => {
|
||||||
|
return (capability) => {
|
||||||
|
const enabled = enabledCapabilities.has(capability);
|
||||||
|
if (!enabled) {
|
||||||
|
const isToolCapability = [
|
||||||
|
AgentCapabilities.file_search,
|
||||||
|
AgentCapabilities.execute_code,
|
||||||
|
AgentCapabilities.web_search,
|
||||||
|
].includes(capability);
|
||||||
|
const suffix = isToolCapability ? ' despite configured tool.' : '.';
|
||||||
|
logger.warn(`Capability "${capability}" disabled${suffix}`);
|
||||||
|
}
|
||||||
|
return enabled;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return true when capability is enabled', () => {
|
||||||
|
const enabledCapabilities = new Set([AgentCapabilities.deferred_tools]);
|
||||||
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
||||||
|
|
||||||
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when capability is not enabled', () => {
|
||||||
|
const enabledCapabilities = new Set([]);
|
||||||
|
const checkCapability = createCheckCapability(enabledCapabilities);
|
||||||
|
|
||||||
|
expect(checkCapability(AgentCapabilities.deferred_tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning with "despite configured tool" for tool capabilities', () => {
|
||||||
|
const logger = { warn: jest.fn() };
|
||||||
|
const enabledCapabilities = new Set([]);
|
||||||
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
||||||
|
|
||||||
|
checkCapability(AgentCapabilities.file_search);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
||||||
|
|
||||||
|
logger.warn.mockClear();
|
||||||
|
checkCapability(AgentCapabilities.execute_code);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
||||||
|
|
||||||
|
logger.warn.mockClear();
|
||||||
|
checkCapability(AgentCapabilities.web_search);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('despite configured tool'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log warning without "despite configured tool" for non-tool capabilities', () => {
|
||||||
|
const logger = { warn: jest.fn() };
|
||||||
|
const enabledCapabilities = new Set([]);
|
||||||
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
||||||
|
|
||||||
|
checkCapability(AgentCapabilities.deferred_tools);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Capability "deferred_tools" disabled.'),
|
||||||
|
);
|
||||||
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('despite configured tool'),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn.mockClear();
|
||||||
|
checkCapability(AgentCapabilities.tools);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Capability "tools" disabled.'),
|
||||||
|
);
|
||||||
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('despite configured tool'),
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.warn.mockClear();
|
||||||
|
checkCapability(AgentCapabilities.actions);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Capability "actions" disabled.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log warning when capability is enabled', () => {
|
||||||
|
const logger = { warn: jest.fn() };
|
||||||
|
const enabledCapabilities = new Set([
|
||||||
|
AgentCapabilities.deferred_tools,
|
||||||
|
AgentCapabilities.file_search,
|
||||||
|
]);
|
||||||
|
const checkCapability = createCheckCapability(enabledCapabilities, logger);
|
||||||
|
|
||||||
|
checkCapability(AgentCapabilities.deferred_tools);
|
||||||
|
checkCapability(AgentCapabilities.file_search);
|
||||||
|
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultAgentCapabilities', () => {
|
||||||
|
it('should include deferred_tools capability by default', () => {
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.deferred_tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all expected default capabilities', () => {
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.execute_code);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.file_search);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.web_search);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.artifacts);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.actions);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.context);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.tools);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.chain);
|
||||||
|
expect(defaultAgentCapabilities).toContain(AgentCapabilities.ocr);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deferredToolsEnabled integration', () => {
|
||||||
|
it('should correctly determine deferredToolsEnabled from capabilities set', () => {
|
||||||
|
const createCheckCapability = (enabledCapabilities) => {
|
||||||
|
return (capability) => enabledCapabilities.has(capability);
|
||||||
|
};
|
||||||
|
|
||||||
|
// When deferred_tools is in capabilities
|
||||||
|
const withDeferred = new Set([AgentCapabilities.deferred_tools, AgentCapabilities.tools]);
|
||||||
|
const checkWithDeferred = createCheckCapability(withDeferred);
|
||||||
|
expect(checkWithDeferred(AgentCapabilities.deferred_tools)).toBe(true);
|
||||||
|
|
||||||
|
// When deferred_tools is NOT in capabilities
|
||||||
|
const withoutDeferred = new Set([AgentCapabilities.tools, AgentCapabilities.actions]);
|
||||||
|
const checkWithoutDeferred = createCheckCapability(withoutDeferred);
|
||||||
|
expect(checkWithoutDeferred(AgentCapabilities.deferred_tools)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use defaultAgentCapabilities when no capabilities configured', () => {
|
||||||
|
// Simulates the fallback behavior in loadAgentTools
|
||||||
|
const endpointsConfig = {}; // No capabilities configured
|
||||||
|
const enabledCapabilities = new Set(
|
||||||
|
endpointsConfig?.capabilities ?? defaultAgentCapabilities,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(enabledCapabilities.has(AgentCapabilities.deferred_tools)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { ChevronDown, Clock } from 'lucide-react';
|
import { ChevronDown, Clock } from 'lucide-react';
|
||||||
import { useFormContext, useWatch } from 'react-hook-form';
|
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
import type { AgentToolOptions } from 'librechat-data-provider';
|
|
||||||
import {
|
import {
|
||||||
Label,
|
Label,
|
||||||
ESide,
|
ESide,
|
||||||
|
|
@ -18,8 +17,15 @@ import {
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
OGDialogTemplate,
|
OGDialogTemplate,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
|
import type { AgentToolOptions } from 'librechat-data-provider';
|
||||||
import type { AgentForm, MCPServerInfo } from '~/common';
|
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 MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
@ -29,6 +35,8 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
const { removeTool } = useRemoveMCPTool();
|
const { removeTool } = useRemoveMCPTool();
|
||||||
const { getValues, setValue, control } = useFormContext<AgentForm>();
|
const { getValues, setValue, control } = useFormContext<AgentForm>();
|
||||||
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
||||||
|
const { agentsConfig } = useGetAgentsConfig();
|
||||||
|
const { deferredToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
|
||||||
|
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
|
@ -242,45 +250,47 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Defer All toggle - icon only with tooltip */}
|
{/* Defer All toggle - icon only with tooltip */}
|
||||||
<TooltipAnchor
|
{deferredToolsEnabled && (
|
||||||
description={
|
<TooltipAnchor
|
||||||
areAllToolsDeferred
|
description={
|
||||||
? localize('com_ui_mcp_undefer_all')
|
areAllToolsDeferred
|
||||||
: localize('com_ui_mcp_defer_all')
|
? localize('com_ui_mcp_undefer_all')
|
||||||
}
|
: localize('com_ui_mcp_defer_all')
|
||||||
side="top"
|
}
|
||||||
role="button"
|
side="top"
|
||||||
tabIndex={isExpanded ? 0 : -1}
|
role="button"
|
||||||
aria-label={
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
areAllToolsDeferred
|
aria-label={
|
||||||
? localize('com_ui_mcp_undefer_all')
|
areAllToolsDeferred
|
||||||
: localize('com_ui_mcp_defer_all')
|
? localize('com_ui_mcp_undefer_all')
|
||||||
}
|
: localize('com_ui_mcp_defer_all')
|
||||||
aria-pressed={areAllToolsDeferred}
|
}
|
||||||
className={cn(
|
aria-pressed={areAllToolsDeferred}
|
||||||
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
className={cn(
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
||||||
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
areAllToolsDeferred
|
isExpanded ? 'visible' : 'pointer-events-none invisible',
|
||||||
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
|
areAllToolsDeferred
|
||||||
: 'text-text-tertiary hover:bg-surface-hover hover:text-text-primary',
|
? '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();
|
onClick={(e) => {
|
||||||
toggleDeferAll();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleDeferAll();
|
toggleDeferAll();
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<Clock
|
e.preventDefault();
|
||||||
className={cn('h-4 w-4', areAllToolsDeferred && 'fill-amber-500/30')}
|
e.stopPropagation();
|
||||||
/>
|
toggleDeferAll();
|
||||||
</TooltipAnchor>
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
className={cn('h-4 w-4', areAllToolsDeferred && 'fill-amber-500/30')}
|
||||||
|
/>
|
||||||
|
</TooltipAnchor>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Caret button for accordion */}
|
{/* 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">
|
<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">
|
<div className="space-y-1">
|
||||||
{serverInfo.tools?.map((subTool) => {
|
{serverInfo.tools?.map((subTool) => {
|
||||||
const isDeferred = isToolDeferred(subTool.tool_id);
|
const isDeferred = deferredToolsEnabled && isToolDeferred(subTool.tool_id);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={subTool.tool_id}
|
key={subTool.tool_id}
|
||||||
|
|
@ -388,42 +398,44 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
{/* Per-tool defer toggle - icon only */}
|
{/* Per-tool defer toggle - icon only */}
|
||||||
<TooltipAnchor
|
{deferredToolsEnabled && (
|
||||||
description={
|
<TooltipAnchor
|
||||||
isDeferred
|
description={
|
||||||
? localize('com_ui_mcp_click_to_undefer')
|
isDeferred
|
||||||
: localize('com_ui_mcp_click_to_defer')
|
? localize('com_ui_mcp_click_to_undefer')
|
||||||
}
|
: localize('com_ui_mcp_click_to_defer')
|
||||||
side="top"
|
}
|
||||||
role="button"
|
side="top"
|
||||||
aria-label={
|
role="button"
|
||||||
isDeferred
|
aria-label={
|
||||||
? localize('com_ui_mcp_undefer')
|
isDeferred
|
||||||
: localize('com_ui_mcp_defer_loading')
|
? localize('com_ui_mcp_undefer')
|
||||||
}
|
: localize('com_ui_mcp_defer_loading')
|
||||||
aria-pressed={isDeferred}
|
}
|
||||||
className={cn(
|
aria-pressed={isDeferred}
|
||||||
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
|
className={cn(
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
'flex h-6 w-6 items-center justify-center rounded transition-all duration-200',
|
||||||
isDeferred
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
? 'bg-amber-500/20 text-amber-500 hover:bg-amber-500/30'
|
isDeferred
|
||||||
: 'text-text-tertiary opacity-0 hover:bg-surface-hover hover:text-text-primary group-focus-within/item:opacity-100 group-hover/item:opacity-100',
|
? '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();
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
toggleToolDefer(subTool.tool_id);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleToolDefer(subTool.tool_id);
|
toggleToolDefer(subTool.tool_id);
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
<Clock className={cn('h-3.5 w-3.5', isDeferred && 'fill-amber-500/30')} />
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
</TooltipAnchor>
|
e.preventDefault();
|
||||||
|
toggleToolDefer(subTool.tool_id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clock className={cn('h-3.5 w-3.5', isDeferred && 'fill-amber-500/30')} />
|
||||||
|
</TooltipAnchor>
|
||||||
|
)}
|
||||||
{subTool.metadata.description && (
|
{subTool.metadata.description && (
|
||||||
<InfoHoverCard side={ESide.Left} text={subTool.metadata.description} />
|
<InfoHoverCard side={ESide.Left} text={subTool.metadata.description} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,6 +10,7 @@ interface AgentCapabilitiesResult {
|
||||||
fileSearchEnabled: boolean;
|
fileSearchEnabled: boolean;
|
||||||
webSearchEnabled: boolean;
|
webSearchEnabled: boolean;
|
||||||
codeEnabled: boolean;
|
codeEnabled: boolean;
|
||||||
|
deferredToolsEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useAgentCapabilities(
|
export default function useAgentCapabilities(
|
||||||
|
|
@ -55,6 +56,11 @@ export default function useAgentCapabilities(
|
||||||
[capabilities],
|
[capabilities],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deferredToolsEnabled = useMemo(
|
||||||
|
() => capabilities?.includes(AgentCapabilities.deferred_tools) ?? false,
|
||||||
|
[capabilities],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ocrEnabled,
|
ocrEnabled,
|
||||||
codeEnabled,
|
codeEnabled,
|
||||||
|
|
@ -64,5 +70,6 @@ export default function useAgentCapabilities(
|
||||||
artifactsEnabled,
|
artifactsEnabled,
|
||||||
webSearchEnabled,
|
webSearchEnabled,
|
||||||
fileSearchEnabled,
|
fileSearchEnabled,
|
||||||
|
deferredToolsEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
453
packages/api/src/tools/classification.spec.ts
Normal file
453
packages/api/src/tools/classification.spec.ts
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
import {
|
||||||
|
parseToolList,
|
||||||
|
toolMatchesPatterns,
|
||||||
|
getServerNameFromTool,
|
||||||
|
buildToolRegistryFromEnv,
|
||||||
|
buildToolRegistryFromAgentOptions,
|
||||||
|
buildToolClassification,
|
||||||
|
agentHasDeferredTools,
|
||||||
|
agentHasProgrammaticTools,
|
||||||
|
isAgentAllowedForClassification,
|
||||||
|
} from './classification';
|
||||||
|
import type { ToolDefinition, LCToolRegistry } from './classification';
|
||||||
|
import type { GenericTool } from '@librechat/agents';
|
||||||
|
import type { AgentToolOptions } from 'librechat-data-provider';
|
||||||
|
|
||||||
|
describe('classification.ts', () => {
|
||||||
|
const originalEnv = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
// Clear classification-related env vars
|
||||||
|
delete process.env.TOOL_PROGRAMMATIC_ONLY;
|
||||||
|
delete process.env.TOOL_PROGRAMMATIC_ONLY_EXCLUDE;
|
||||||
|
delete process.env.TOOL_DUAL_CONTEXT;
|
||||||
|
delete process.env.TOOL_DUAL_CONTEXT_EXCLUDE;
|
||||||
|
delete process.env.TOOL_DEFERRED;
|
||||||
|
delete process.env.TOOL_DEFERRED_EXCLUDE;
|
||||||
|
delete process.env.TOOL_CLASSIFICATION_AGENT_IDS;
|
||||||
|
delete process.env.TOOL_CLASSIFICATION_FROM_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseToolList', () => {
|
||||||
|
it('should return empty set for undefined input', () => {
|
||||||
|
const result = parseToolList(undefined);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty set for empty string', () => {
|
||||||
|
const result = parseToolList('');
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty set for whitespace-only string', () => {
|
||||||
|
const result = parseToolList(' ');
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse comma-separated tool names', () => {
|
||||||
|
const result = parseToolList('tool1,tool2,tool3');
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
expect(result.has('tool1')).toBe(true);
|
||||||
|
expect(result.has('tool2')).toBe(true);
|
||||||
|
expect(result.has('tool3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from tool names', () => {
|
||||||
|
const result = parseToolList(' tool1 , tool2 ');
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect(result.has('tool1')).toBe(true);
|
||||||
|
expect(result.has('tool2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out empty entries', () => {
|
||||||
|
const result = parseToolList('tool1,,tool2,,,tool3');
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getServerNameFromTool', () => {
|
||||||
|
it('should extract server name from MCP tool name', () => {
|
||||||
|
const result = getServerNameFromTool('list_files_mcp_Google-Workspace');
|
||||||
|
expect(result).toBe('Google-Workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-MCP tool', () => {
|
||||||
|
const result = getServerNameFromTool('simple_tool');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple delimiters', () => {
|
||||||
|
const result = getServerNameFromTool('some_tool_mcp_Server_Name');
|
||||||
|
expect(result).toBe('Server_Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toolMatchesPatterns', () => {
|
||||||
|
it('should return true for exact match', () => {
|
||||||
|
const patterns = new Set(['tool1', 'tool2']);
|
||||||
|
const excludes = new Set<string>();
|
||||||
|
expect(toolMatchesPatterns('tool1', patterns, excludes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-matching tool', () => {
|
||||||
|
const patterns = new Set(['tool1', 'tool2']);
|
||||||
|
const excludes = new Set<string>();
|
||||||
|
expect(toolMatchesPatterns('tool3', patterns, excludes)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when tool is in excludes', () => {
|
||||||
|
const patterns = new Set(['tool1', 'tool2']);
|
||||||
|
const excludes = new Set(['tool1']);
|
||||||
|
expect(toolMatchesPatterns('tool1', patterns, excludes)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match server-wide pattern', () => {
|
||||||
|
const patterns = new Set(['sys__all__sys_mcp_Google-Workspace']);
|
||||||
|
const excludes = new Set<string>();
|
||||||
|
expect(toolMatchesPatterns('list_files_mcp_Google-Workspace', patterns, excludes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect excludes for server-wide patterns', () => {
|
||||||
|
const patterns = new Set(['sys__all__sys_mcp_Google-Workspace']);
|
||||||
|
const excludes = new Set(['list_files_mcp_Google-Workspace']);
|
||||||
|
expect(toolMatchesPatterns('list_files_mcp_Google-Workspace', patterns, excludes)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildToolRegistryFromEnv', () => {
|
||||||
|
it('should set defer_loading based on TOOL_DEFERRED env var', () => {
|
||||||
|
process.env.TOOL_DEFERRED = 'tool1,tool2';
|
||||||
|
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
{ name: 'tool1', description: 'Tool 1' },
|
||||||
|
{ name: 'tool2', description: 'Tool 2' },
|
||||||
|
{ name: 'tool3', description: 'Tool 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromEnv(tools);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.defer_loading).toBe(true);
|
||||||
|
expect(registry.get('tool2')?.defer_loading).toBe(true);
|
||||||
|
expect(registry.get('tool3')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect TOOL_DEFERRED_EXCLUDE', () => {
|
||||||
|
process.env.TOOL_DEFERRED = 'sys__all__sys_mcp_TestServer';
|
||||||
|
process.env.TOOL_DEFERRED_EXCLUDE = 'tool2_mcp_TestServer';
|
||||||
|
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
{ name: 'tool1_mcp_TestServer', description: 'Tool 1' },
|
||||||
|
{ name: 'tool2_mcp_TestServer', description: 'Tool 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromEnv(tools);
|
||||||
|
|
||||||
|
expect(registry.get('tool1_mcp_TestServer')?.defer_loading).toBe(true);
|
||||||
|
expect(registry.get('tool2_mcp_TestServer')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set allowed_callers based on TOOL_PROGRAMMATIC_ONLY', () => {
|
||||||
|
process.env.TOOL_PROGRAMMATIC_ONLY = 'tool1';
|
||||||
|
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
{ name: 'tool1', description: 'Tool 1' },
|
||||||
|
{ name: 'tool2', description: 'Tool 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromEnv(tools);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.allowed_callers).toEqual(['code_execution']);
|
||||||
|
expect(registry.get('tool2')?.allowed_callers).toEqual(['direct']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set dual context callers based on TOOL_DUAL_CONTEXT', () => {
|
||||||
|
process.env.TOOL_DUAL_CONTEXT = 'tool1';
|
||||||
|
|
||||||
|
const tools: ToolDefinition[] = [{ name: 'tool1', description: 'Tool 1' }];
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromEnv(tools);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.allowed_callers).toEqual(['direct', 'code_execution']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildToolRegistryFromAgentOptions', () => {
|
||||||
|
it('should use agent tool options for defer_loading', () => {
|
||||||
|
const tools: ToolDefinition[] = [
|
||||||
|
{ name: 'tool1', description: 'Tool 1' },
|
||||||
|
{ name: 'tool2', description: 'Tool 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
tool2: { defer_loading: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.defer_loading).toBe(true);
|
||||||
|
expect(registry.get('tool2')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default defer_loading to false when not specified', () => {
|
||||||
|
const tools: ToolDefinition[] = [{ name: 'tool1', description: 'Tool 1' }];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {};
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use agent allowed_callers when specified', () => {
|
||||||
|
const tools: ToolDefinition[] = [{ name: 'tool1', description: 'Tool 1' }];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { allowed_callers: ['code_execution'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions);
|
||||||
|
|
||||||
|
expect(registry.get('tool1')?.allowed_callers).toEqual(['code_execution']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agentHasDeferredTools', () => {
|
||||||
|
it('should return true when registry has deferred tools', () => {
|
||||||
|
const registry: LCToolRegistry = new Map([
|
||||||
|
['tool1', { name: 'tool1', allowed_callers: ['direct'], defer_loading: true }],
|
||||||
|
['tool2', { name: 'tool2', allowed_callers: ['direct'], defer_loading: false }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(agentHasDeferredTools(registry)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no tools are deferred', () => {
|
||||||
|
const registry: LCToolRegistry = new Map([
|
||||||
|
['tool1', { name: 'tool1', allowed_callers: ['direct'], defer_loading: false }],
|
||||||
|
['tool2', { name: 'tool2', allowed_callers: ['direct'], defer_loading: false }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(agentHasDeferredTools(registry)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty registry', () => {
|
||||||
|
const registry: LCToolRegistry = new Map();
|
||||||
|
expect(agentHasDeferredTools(registry)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agentHasProgrammaticTools', () => {
|
||||||
|
it('should return true when registry has programmatic tools', () => {
|
||||||
|
const registry: LCToolRegistry = new Map([
|
||||||
|
['tool1', { name: 'tool1', allowed_callers: ['code_execution'], defer_loading: false }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(agentHasProgrammaticTools(registry)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for dual context tools', () => {
|
||||||
|
const registry: LCToolRegistry = new Map([
|
||||||
|
[
|
||||||
|
'tool1',
|
||||||
|
{ name: 'tool1', allowed_callers: ['direct', 'code_execution'], defer_loading: false },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(agentHasProgrammaticTools(registry)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no programmatic tools', () => {
|
||||||
|
const registry: LCToolRegistry = new Map([
|
||||||
|
['tool1', { name: 'tool1', allowed_callers: ['direct'], defer_loading: false }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(agentHasProgrammaticTools(registry)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAgentAllowedForClassification', () => {
|
||||||
|
it('should return true when TOOL_CLASSIFICATION_AGENT_IDS is not set', () => {
|
||||||
|
expect(isAgentAllowedForClassification('any-agent-id')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when agent is in allowed list', () => {
|
||||||
|
process.env.TOOL_CLASSIFICATION_AGENT_IDS = 'agent1,agent2,agent3';
|
||||||
|
expect(isAgentAllowedForClassification('agent2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when agent is not in allowed list', () => {
|
||||||
|
process.env.TOOL_CLASSIFICATION_AGENT_IDS = 'agent1,agent2';
|
||||||
|
expect(isAgentAllowedForClassification('agent3')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when agentId is undefined and list is set', () => {
|
||||||
|
process.env.TOOL_CLASSIFICATION_AGENT_IDS = 'agent1';
|
||||||
|
expect(isAgentAllowedForClassification(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildToolClassification with deferredToolsEnabled', () => {
|
||||||
|
const mockLoadAuthValues = jest.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
const createMCPTool = (name: string, description?: string) =>
|
||||||
|
({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
mcp: true,
|
||||||
|
mcpJsonSchema: { type: 'object', properties: {} },
|
||||||
|
}) as unknown as GenericTool;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return hasDeferredTools: false when deferredToolsEnabled is false', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1'), createMCPTool('tool2')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
tool2: { defer_loading: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasDeferredTools).toBe(false);
|
||||||
|
expect(result.additionalTools.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear defer_loading from all tools when deferredToolsEnabled is false', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1'), createMCPTool('tool2')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
tool2: { defer_loading: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.toolRegistry).toBeDefined();
|
||||||
|
expect(result.toolRegistry?.get('tool1')?.defer_loading).toBe(false);
|
||||||
|
expect(result.toolRegistry?.get('tool2')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve defer_loading when deferredToolsEnabled is true', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1'), createMCPTool('tool2')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
tool2: { defer_loading: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasDeferredTools).toBe(true);
|
||||||
|
expect(result.toolRegistry?.get('tool1')?.defer_loading).toBe(true);
|
||||||
|
expect(result.toolRegistry?.get('tool2')?.defer_loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create tool search when deferredToolsEnabled is true and has deferred tools', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasDeferredTools).toBe(true);
|
||||||
|
expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT create tool search when deferredToolsEnabled is false', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled: false,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasDeferredTools).toBe(false);
|
||||||
|
expect(result.additionalTools.some((t) => t.name === 'tool_search')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default deferredToolsEnabled to true when not specified', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [createMCPTool('tool1')];
|
||||||
|
|
||||||
|
const agentToolOptions: AgentToolOptions = {
|
||||||
|
tool1: { defer_loading: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
agentToolOptions,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.hasDeferredTools).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early when no MCP tools are present', async () => {
|
||||||
|
const loadedTools: GenericTool[] = [
|
||||||
|
{ name: 'regular_tool', mcp: false } as unknown as GenericTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await buildToolClassification({
|
||||||
|
loadedTools,
|
||||||
|
userId: 'user1',
|
||||||
|
agentId: 'agent1',
|
||||||
|
deferredToolsEnabled: true,
|
||||||
|
loadAuthValues: mockLoadAuthValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.toolRegistry).toBeUndefined();
|
||||||
|
expect(result.hasDeferredTools).toBe(false);
|
||||||
|
expect(result.additionalTools.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -322,6 +322,8 @@ export interface BuildToolClassificationParams {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
/** Per-tool configuration from the agent (takes precedence over env vars) */
|
/** Per-tool configuration from the agent (takes precedence over env vars) */
|
||||||
agentToolOptions?: AgentToolOptions;
|
agentToolOptions?: AgentToolOptions;
|
||||||
|
/** Whether the deferred_tools capability is enabled (from agent config) */
|
||||||
|
deferredToolsEnabled?: boolean;
|
||||||
/** Function to load auth values (dependency injection) */
|
/** Function to load auth values (dependency injection) */
|
||||||
loadAuthValues: (params: {
|
loadAuthValues: (params: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -404,7 +406,14 @@ export function agentHasDeferredTools(toolRegistry: LCToolRegistry): boolean {
|
||||||
export async function buildToolClassification(
|
export async function buildToolClassification(
|
||||||
params: BuildToolClassificationParams,
|
params: BuildToolClassificationParams,
|
||||||
): Promise<BuildToolClassificationResult> {
|
): Promise<BuildToolClassificationResult> {
|
||||||
const { loadedTools, userId, agentId, agentToolOptions, loadAuthValues } = params;
|
const {
|
||||||
|
loadedTools,
|
||||||
|
userId,
|
||||||
|
agentId,
|
||||||
|
agentToolOptions,
|
||||||
|
deferredToolsEnabled = true,
|
||||||
|
loadAuthValues,
|
||||||
|
} = params;
|
||||||
const additionalTools: GenericTool[] = [];
|
const additionalTools: GenericTool[] = [];
|
||||||
|
|
||||||
/** Check if this agent is allowed to have classification features (requires agentId) */
|
/** Check if this agent is allowed to have classification features (requires agentId) */
|
||||||
|
|
@ -444,10 +453,22 @@ export async function buildToolClassification(
|
||||||
/**
|
/**
|
||||||
* Check if this agent actually has tools that match the patterns.
|
* Check if this agent actually has tools that match the patterns.
|
||||||
* Only enable PTC if the agent has programmatic tools.
|
* Only enable PTC if the agent has programmatic tools.
|
||||||
* Only enable tool search if the agent has deferred tools.
|
* Only enable tool search if the agent has deferred tools AND the capability is enabled.
|
||||||
*/
|
*/
|
||||||
const hasProgrammaticTools = agentHasProgrammaticTools(toolRegistry);
|
const hasProgrammaticTools = agentHasProgrammaticTools(toolRegistry);
|
||||||
const hasDeferredTools = agentHasDeferredTools(toolRegistry);
|
const hasDeferredTools = deferredToolsEnabled && agentHasDeferredTools(toolRegistry);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If deferred tools capability is disabled, clear defer_loading from all tools
|
||||||
|
* to ensure no tools are treated as deferred at runtime.
|
||||||
|
*/
|
||||||
|
if (!deferredToolsEnabled) {
|
||||||
|
for (const toolDef of toolRegistry.values()) {
|
||||||
|
if (toolDef.defer_loading === true) {
|
||||||
|
toolDef.defer_loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasProgrammaticTools && !hasDeferredTools) {
|
if (!hasProgrammaticTools && !hasDeferredTools) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export enum Capabilities {
|
||||||
export enum AgentCapabilities {
|
export enum AgentCapabilities {
|
||||||
hide_sequential_outputs = 'hide_sequential_outputs',
|
hide_sequential_outputs = 'hide_sequential_outputs',
|
||||||
end_after_tools = 'end_after_tools',
|
end_after_tools = 'end_after_tools',
|
||||||
|
deferred_tools = 'deferred_tools',
|
||||||
execute_code = 'execute_code',
|
execute_code = 'execute_code',
|
||||||
file_search = 'file_search',
|
file_search = 'file_search',
|
||||||
web_search = 'web_search',
|
web_search = 'web_search',
|
||||||
|
|
@ -259,6 +260,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge(
|
||||||
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
|
export type TAssistantEndpoint = z.infer<typeof assistantEndpointSchema>;
|
||||||
|
|
||||||
export const defaultAgentCapabilities = [
|
export const defaultAgentCapabilities = [
|
||||||
|
AgentCapabilities.deferred_tools,
|
||||||
AgentCapabilities.execute_code,
|
AgentCapabilities.execute_code,
|
||||||
AgentCapabilities.file_search,
|
AgentCapabilities.file_search,
|
||||||
AgentCapabilities.web_search,
|
AgentCapabilities.web_search,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue