mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 12:13:30 +00:00
🔌 feat: Background Execution Toggles for Actions & Plugin Tools (#14407)
* 🧵 feat: Background Execution Toggles for Actions & Plugin Tools * 🩹 fix: Resolve action background opt-in across encoded-domain forms and scope it per action * 🧹 refactor: Resolve action domain in a single pass * 🧩 fix: Merge Normalized Action Background Options * 🪢 fix: Reconcile Action Background Aliases * 🧭 fix: Harden Action Background Compatibility * 🕰️ test: Allow Settled Task TTL Expiry * 🧬 fix: Merge Refreshed Action Tool Registrations
This commit is contained in:
parent
4d246469dd
commit
4b113697b5
24 changed files with 960 additions and 66 deletions
|
|
@ -35,6 +35,7 @@ const {
|
|||
getSafeErrorMetadata,
|
||||
isContentFilterError,
|
||||
isFileAuthoringToolDefinition,
|
||||
normalizeActionToolName,
|
||||
ASK_USER_QUESTION_TOOL_NAME,
|
||||
splitMCPToolKey,
|
||||
buildServerNameAliases,
|
||||
|
|
@ -53,6 +54,7 @@ const {
|
|||
ErrorTypes,
|
||||
ContentTypes,
|
||||
imageGenTools,
|
||||
AuthTypeEnum,
|
||||
EModelEndpoint,
|
||||
EToolResources,
|
||||
isActionTool,
|
||||
|
|
@ -202,32 +204,6 @@ const prepareActionSnapshotForTools = async ({ agentId, toolNames, filters, decr
|
|||
return { storedActions, actionSets };
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse every `actionDomainSeparator` sequence in the encoded-domain
|
||||
* suffix of a fully-qualified action tool name to an underscore. Agents
|
||||
* can store tool names in the raw `domainParser(..., true)` output,
|
||||
* which for short hostnames is a `---`-separated string (e.g.
|
||||
* `medium---com`). The lookup maps below are always keyed with the
|
||||
* `_`-collapsed domain, so every read must normalize that suffix or
|
||||
* short-hostname tools silently fail to resolve.
|
||||
*
|
||||
* The operationId portion (everything before the last `actionDelimiter`)
|
||||
* is deliberately left untouched: `openapiToFunction` preserves hyphens
|
||||
* in generated operationIds, so two specs can legitimately produce
|
||||
* operationIds that differ only in hyphens-vs-underscores (e.g.
|
||||
* `get_foo---bar` vs `get_foo_bar`). Collapsing the operationId would
|
||||
* merge those into a single map slot and silently drop one tool.
|
||||
*/
|
||||
const normalizeActionToolName = (toolName) => {
|
||||
const delimiterIndex = toolName.lastIndexOf(actionDelimiter);
|
||||
if (delimiterIndex === -1) {
|
||||
return toolName;
|
||||
}
|
||||
const prefixEnd = delimiterIndex + actionDelimiter.length;
|
||||
const encodedDomain = toolName.slice(prefixEnd);
|
||||
return toolName.slice(0, prefixEnd) + encodedDomain.replace(domainSeparatorRegex, '_');
|
||||
};
|
||||
|
||||
/**
|
||||
* Populate a `toolToAction` map with one slot per fully-qualified tool
|
||||
* name (`<operationId><actionDelimiter><encoded-domain>`). Both the new
|
||||
|
|
@ -1208,14 +1184,19 @@ async function loadToolDefinitionsWrapper({
|
|||
for (const sig of functionSignatures) {
|
||||
const toolName = `${sig.name}${actionDelimiter}${normalizedDomain}`;
|
||||
const legacyToolName = `${sig.name}${actionDelimiter}${legacyNormalized}`;
|
||||
if (!normalizedToolNames.has(toolName) && !normalizedToolNames.has(legacyToolName)) {
|
||||
const matchesCurrentName = normalizedToolNames.has(toolName);
|
||||
const matchesLegacyName = normalizedToolNames.has(legacyToolName);
|
||||
if (!matchesCurrentName && !matchesLegacyName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.push({
|
||||
name: toolName,
|
||||
/** Keep the selected legacy spelling when that is the only match so
|
||||
* persisted tool_options resolve against the emitted definition. */
|
||||
name: matchesCurrentName ? toolName : legacyToolName,
|
||||
description: sig.description,
|
||||
parameters: sig.parameters,
|
||||
oauth: action.metadata.auth?.type === AuthTypeEnum.OAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1223,28 +1204,34 @@ async function loadToolDefinitionsWrapper({
|
|||
return definitions;
|
||||
};
|
||||
|
||||
let { toolDefinitions, toolRegistry, hasDeferredTools, mcpToolAliases, mcpResolution } =
|
||||
await loadToolDefinitions(
|
||||
{
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
tools: defsFilteredTools,
|
||||
toolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
codeExecutionEnabled,
|
||||
provider: agent.provider,
|
||||
mcpServerNames,
|
||||
rawServerNames: mcpRawServerNames,
|
||||
accessibleServerNames: defsAccessibleServerNames,
|
||||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
getOrFetchMCPServerTools,
|
||||
refreshMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
);
|
||||
let {
|
||||
toolDefinitions,
|
||||
toolRegistry,
|
||||
hasDeferredTools,
|
||||
mcpToolAliases,
|
||||
mcpResolution,
|
||||
oauthActionToolNames,
|
||||
} = await loadToolDefinitions(
|
||||
{
|
||||
userId: req.user.id,
|
||||
agentId: agent.id,
|
||||
tools: defsFilteredTools,
|
||||
toolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
programmaticToolsEnabled,
|
||||
codeExecutionEnabled,
|
||||
provider: agent.provider,
|
||||
mcpServerNames,
|
||||
rawServerNames: mcpRawServerNames,
|
||||
accessibleServerNames: defsAccessibleServerNames,
|
||||
},
|
||||
{
|
||||
isBuiltInTool,
|
||||
getOrFetchMCPServerTools,
|
||||
refreshMCPServerTools,
|
||||
getActionToolDefinitions,
|
||||
},
|
||||
);
|
||||
|
||||
/** OAuth discovery must not reconnect (or prompt for) a server whose
|
||||
* definitions the collision filter deliberately rejected. */
|
||||
|
|
@ -1338,6 +1325,7 @@ async function loadToolDefinitionsWrapper({
|
|||
hasDeferredTools = reloadResult.hasDeferredTools;
|
||||
mcpToolAliases = reloadResult.mcpToolAliases;
|
||||
mcpResolution = reloadResult.mcpResolution;
|
||||
oauthActionToolNames = reloadResult.oauthActionToolNames;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1454,6 +1442,7 @@ async function loadToolDefinitionsWrapper({
|
|||
mcpToolAliases,
|
||||
actionsEnabled,
|
||||
primedCodeFiles,
|
||||
oauthActionToolNames,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3348,6 +3348,41 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
expect(callArgs.requestBuilder.path).toBe('/echo');
|
||||
});
|
||||
|
||||
it('definitions-only loading emits the selected legacy action name', async () => {
|
||||
mockLoadActionSets.mockResolvedValue([actionA]);
|
||||
const legacyToolName = `echoMessage${actionDelimiter}${LEGACY_ENCODED_DOMAIN}`;
|
||||
const capabilities = [AgentCapabilities.tools, AgentCapabilities.actions];
|
||||
const req = createMockReq(capabilities);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
mockLoadToolDefinitions.mockImplementationOnce(async (_options, dependencies) => {
|
||||
const definitions = await dependencies.getActionToolDefinitions('agent_legacy', [
|
||||
legacyToolName,
|
||||
]);
|
||||
expect(definitions).toEqual([
|
||||
expect.objectContaining({ name: legacyToolName, description: 'Mock echoMessage' }),
|
||||
]);
|
||||
return {
|
||||
toolDefinitions: definitions,
|
||||
toolRegistry: new Map(),
|
||||
hasDeferredTools: false,
|
||||
};
|
||||
});
|
||||
|
||||
await loadAgentTools({
|
||||
req,
|
||||
res: {},
|
||||
agent: {
|
||||
id: 'agent_legacy',
|
||||
tools: [legacyToolName],
|
||||
tool_options: { [legacyToolName]: { run_in_background: true } },
|
||||
},
|
||||
definitionsOnly: true,
|
||||
});
|
||||
|
||||
expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loadAgentTools distinguishes operationIds that differ only by `---` vs `_`', async () => {
|
||||
// `openapiToFunction` uses the user-supplied operationId verbatim
|
||||
// and only sanitizes the synthetic `<method>_<path>` fallback, and
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
AuthTypeEnum,
|
||||
actionDelimiter,
|
||||
openapiToFunction,
|
||||
validateAndParseOpenAPISpec,
|
||||
} from 'librechat-data-provider';
|
||||
import { useAgentCapabilities, useGetAgentsConfig } from '~/hooks';
|
||||
import { useGetExpandedAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { isEphemeralAgent } from '~/common';
|
||||
import Background from '../Background';
|
||||
|
||||
/** "Background execution" switch for a saved action — opts every operation of
|
||||
* the action into background dispatch via `tool_options`. Hidden for OAuth
|
||||
* actions: their calls can block on an interactive login prompt that a
|
||||
* detached run could never surface (the server excludes them regardless). */
|
||||
export default function ActionBackground({ agentId }: { agentId: string }) {
|
||||
const { action } = useAgentPanelContext();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { backgroundToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
const { data: agent } = useGetExpandedAgentByIdQuery(agentId, {
|
||||
enabled: backgroundToolsEnabled && action != null && !isEphemeralAgent(agentId),
|
||||
});
|
||||
|
||||
/** The agent's `actions` entries are `${encodedDomain}_action_${action_id}`,
|
||||
* so the saved encoded domain is recoverable without re-implementing the
|
||||
* server's domain encoding. The domain alone is NOT enough to identify this
|
||||
* action's tools: two actions may share a hostname, and their operations
|
||||
* then share the suffix. Narrow by this spec's own operation ids, falling
|
||||
* back to the suffix only when no other action shares the domain. */
|
||||
const actionToolIds = useMemo(() => {
|
||||
const actionId = action?.action_id;
|
||||
if (!actionId || !agent) {
|
||||
return [];
|
||||
}
|
||||
let domain = '';
|
||||
const domainCounts = new Map<string, number>();
|
||||
for (const entry of agent.actions ?? []) {
|
||||
const idx = entry.indexOf(actionDelimiter);
|
||||
if (idx < 1) {
|
||||
continue;
|
||||
}
|
||||
const entryDomain = entry.slice(0, idx);
|
||||
domainCounts.set(entryDomain, (domainCounts.get(entryDomain) ?? 0) + 1);
|
||||
if (entry.slice(idx + actionDelimiter.length) === actionId) {
|
||||
domain = entryDomain;
|
||||
}
|
||||
}
|
||||
if (!domain) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sharesDomain = (domainCounts.get(domain) ?? 0) > 1;
|
||||
const suffix = `${actionDelimiter}${domain}`;
|
||||
const domainTools = (agent.tools ?? []).filter((tool) => tool.endsWith(suffix));
|
||||
const spec = action?.metadata.raw_spec;
|
||||
const parsed = spec ? validateAndParseOpenAPISpec(spec) : undefined;
|
||||
if (!parsed?.spec) {
|
||||
return sharesDomain ? [] : domainTools;
|
||||
}
|
||||
const functionSignatures = openapiToFunction(parsed.spec).functionSignatures;
|
||||
const operationIds = new Set(functionSignatures.map((sig) => sig.name));
|
||||
const backgroundOperationIds = new Set(
|
||||
functionSignatures
|
||||
.filter((sig) => sig.parameters.properties.run_in_background == null)
|
||||
.map((sig) => sig.name),
|
||||
);
|
||||
const ownTools = domainTools.filter((tool) =>
|
||||
operationIds.has(tool.slice(0, tool.length - suffix.length)),
|
||||
);
|
||||
if (ownTools.length === 0) {
|
||||
return sharesDomain ? [] : domainTools;
|
||||
}
|
||||
return ownTools.filter((tool) =>
|
||||
backgroundOperationIds.has(tool.slice(0, tool.length - suffix.length)),
|
||||
);
|
||||
}, [action?.action_id, action?.metadata.raw_spec, agent]);
|
||||
|
||||
if (action?.metadata.auth?.type === AuthTypeEnum.OAuth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Background
|
||||
toolIds={actionToolIds}
|
||||
switchId="action-background-tools"
|
||||
labelKey="com_ui_tool_background"
|
||||
infoKey="com_nav_info_tool_background"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { AuthTypeEnum } from 'librechat-data-provider';
|
||||
import { useForm, FormProvider, useWatch } from 'react-hook-form';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { Action, Agent } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentForm } from '~/common';
|
||||
import ActionBackground from '../Background';
|
||||
|
||||
let mockBackgroundEnabled = true;
|
||||
let mockAction: Partial<Action> | undefined;
|
||||
let mockAgent: Partial<Agent> | undefined;
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useGetAgentsConfig: () => ({ agentsConfig: undefined }),
|
||||
useAgentCapabilities: () => ({ backgroundToolsEnabled: mockBackgroundEnabled }),
|
||||
}));
|
||||
jest.mock('~/Providers', () => ({
|
||||
useAgentPanelContext: () => ({ action: mockAction }),
|
||||
}));
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetExpandedAgentByIdQuery: () => ({ data: mockAgent }),
|
||||
}));
|
||||
|
||||
function OptionsProbe() {
|
||||
const value = useWatch<AgentForm>({ name: 'tool_options' });
|
||||
return <span data-testid="options">{JSON.stringify(value ?? null)}</span>;
|
||||
}
|
||||
|
||||
function renderActionBackground(defaultValues: Partial<AgentForm> = {}) {
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const methods = useForm<AgentForm>({ defaultValues: defaultValues as AgentForm });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
{children}
|
||||
<OptionsProbe />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return render(<ActionBackground agentId="agent_abc" />, { wrapper: Wrapper });
|
||||
}
|
||||
|
||||
describe('ActionBackground', () => {
|
||||
beforeEach(() => {
|
||||
mockBackgroundEnabled = true;
|
||||
mockAction = { action_id: 'act123', metadata: {} };
|
||||
mockAgent = {
|
||||
id: 'agent_abc',
|
||||
tools: [
|
||||
'getWeather_action_weather---com',
|
||||
'getForecast_action_weather---com',
|
||||
'sendMail_action_mail---com',
|
||||
'web_search',
|
||||
],
|
||||
actions: ['weather---com_action_act123', 'mail---com_action_act456'],
|
||||
};
|
||||
});
|
||||
|
||||
test('toggling opts in every operation of this action and no other tools', () => {
|
||||
renderActionBackground();
|
||||
const switchEl = screen.getByTestId('action-background-tools');
|
||||
expect(switchEl).not.toBeChecked();
|
||||
|
||||
fireEvent.click(switchEl);
|
||||
const options = JSON.parse(screen.getByTestId('options').textContent ?? 'null');
|
||||
expect(options).toEqual({
|
||||
'getWeather_action_weather---com': { run_in_background: true },
|
||||
getWeather_action_weather_com: { run_in_background: true },
|
||||
'getForecast_action_weather---com': { run_in_background: true },
|
||||
getForecast_action_weather_com: { run_in_background: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('reflects enabled when one operation is already opted in', () => {
|
||||
renderActionBackground({
|
||||
tool_options: { 'getForecast_action_weather---com': { run_in_background: true } },
|
||||
});
|
||||
expect(screen.getByTestId('action-background-tools')).toBeChecked();
|
||||
});
|
||||
|
||||
test('opts in only the selected action when two actions share a hostname', () => {
|
||||
mockAgent = {
|
||||
id: 'agent_abc',
|
||||
tools: [
|
||||
'getWeather_action_api---example---com',
|
||||
'sendMail_action_api---example---com',
|
||||
'web_search',
|
||||
],
|
||||
actions: ['api---example---com_action_act123', 'api---example---com_action_act456'],
|
||||
};
|
||||
mockAction = {
|
||||
action_id: 'act123',
|
||||
metadata: {
|
||||
raw_spec: JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Weather', version: '1.0.0' },
|
||||
servers: [{ url: 'https://api.example.com' }],
|
||||
paths: {
|
||||
'/weather': {
|
||||
get: { operationId: 'getWeather', responses: { '200': { description: 'ok' } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderActionBackground();
|
||||
fireEvent.click(screen.getByTestId('action-background-tools'));
|
||||
const options = JSON.parse(screen.getByTestId('options').textContent ?? 'null');
|
||||
expect(options).toEqual({
|
||||
'getWeather_action_api---example---com': { run_in_background: true },
|
||||
getWeather_action_api_example_com: { run_in_background: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('hides rather than guesses when a shared-hostname spec cannot be parsed', () => {
|
||||
mockAgent = {
|
||||
id: 'agent_abc',
|
||||
tools: ['getWeather_action_api---example---com', 'sendMail_action_api---example---com'],
|
||||
actions: ['api---example---com_action_act123', 'api---example---com_action_act456'],
|
||||
};
|
||||
mockAction = { action_id: 'act123', metadata: {} };
|
||||
|
||||
renderActionBackground();
|
||||
expect(screen.queryByTestId('action-background-tools')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden for OAuth actions', () => {
|
||||
mockAction = { action_id: 'act123', metadata: { auth: { type: AuthTypeEnum.OAuth } } };
|
||||
renderActionBackground();
|
||||
expect(screen.queryByTestId('action-background-tools')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden when every operation owns the run_in_background parameter', () => {
|
||||
mockAgent = {
|
||||
id: 'agent_abc',
|
||||
tools: ['getWeather_action_weather---com'],
|
||||
actions: ['weather---com_action_act123'],
|
||||
};
|
||||
mockAction = {
|
||||
action_id: 'act123',
|
||||
metadata: {
|
||||
raw_spec: JSON.stringify({
|
||||
openapi: '3.0.0',
|
||||
info: { title: 'Weather', version: '1.0.0' },
|
||||
servers: [{ url: 'https://weather.com' }],
|
||||
paths: {
|
||||
'/weather': {
|
||||
get: {
|
||||
operationId: 'getWeather',
|
||||
parameters: [
|
||||
{
|
||||
name: 'run_in_background',
|
||||
in: 'query',
|
||||
schema: { type: 'boolean' },
|
||||
},
|
||||
],
|
||||
responses: { '200': { description: 'ok' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderActionBackground();
|
||||
expect(screen.queryByTestId('action-background-tools')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden when the action is not registered on the agent', () => {
|
||||
mockAction = { action_id: 'act999', metadata: {} };
|
||||
renderActionBackground();
|
||||
expect(screen.queryByTestId('action-background-tools')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden when the background capability is off', () => {
|
||||
mockBackgroundEnabled = false;
|
||||
renderActionBackground();
|
||||
expect(screen.queryByTestId('action-background-tools')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-que
|
|||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import { useLocalize, useAgentDefaultPermissionLevel } from '~/hooks';
|
||||
import { mergeDirtyToolsWithServerActions } from './agentTools';
|
||||
import { useListAgentsQuery } from '~/data-provider';
|
||||
|
||||
const keys = new Set(Object.keys(defaultAgentFormValues));
|
||||
|
|
@ -27,7 +28,17 @@ function AgentSelect({
|
|||
}) {
|
||||
const localize = useLocalize();
|
||||
const lastSelectedAgent = useRef<string | null>(null);
|
||||
const { control, reset } = useFormContext();
|
||||
const {
|
||||
control,
|
||||
getValues,
|
||||
reset,
|
||||
setValue,
|
||||
/** Subscribing dirtyFields is required for reset({ keepDirtyValues: true })
|
||||
* to preserve edits when an action mutation refreshes the agent query. */
|
||||
formState: { dirtyFields },
|
||||
} = useFormContext();
|
||||
const dirtyFieldsRef = useRef(dirtyFields);
|
||||
dirtyFieldsRef.current = dirtyFields;
|
||||
const permissionLevel = useAgentDefaultPermissionLevel();
|
||||
|
||||
const { data: agents = null } = useListAgentsQuery(
|
||||
|
|
@ -46,7 +57,7 @@ function AgentSelect({
|
|||
);
|
||||
|
||||
const resetAgentForm = useCallback(
|
||||
(fullAgent: Agent) => {
|
||||
(fullAgent: Agent, preserveDirtyValues = false) => {
|
||||
const isGlobal = fullAgent.isPublic ?? false;
|
||||
const update = {
|
||||
...fullAgent,
|
||||
|
|
@ -168,9 +179,16 @@ function AgentSelect({
|
|||
formValues.skills_enabled = true;
|
||||
}
|
||||
|
||||
reset(formValues);
|
||||
const mergedDirtyTools =
|
||||
preserveDirtyValues && dirtyFieldsRef.current.tools != null
|
||||
? mergeDirtyToolsWithServerActions(getValues('tools') ?? [], agentTools)
|
||||
: undefined;
|
||||
reset(formValues, { keepDirtyValues: preserveDirtyValues });
|
||||
if (mergedDirtyTools != null) {
|
||||
setValue('tools', mergedDirtyTools, { shouldDirty: true });
|
||||
}
|
||||
},
|
||||
[reset],
|
||||
[getValues, reset, setValue],
|
||||
);
|
||||
|
||||
const onSelect = useCallback(
|
||||
|
|
@ -207,7 +225,7 @@ function AgentSelect({
|
|||
|
||||
useEffect(() => {
|
||||
if (agentQuery.data && agentQuery.isSuccess) {
|
||||
resetAgentForm(agentQuery.data);
|
||||
resetAgentForm(agentQuery.data, true);
|
||||
}
|
||||
}, [agentQuery.data, agentQuery.isSuccess, resetAgentForm]);
|
||||
|
||||
|
|
|
|||
90
client/src/components/SidePanel/Agents/Background.tsx
Normal file
90
client/src/components/SidePanel/Agents/Background.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { normalizeActionToolName } from 'librechat-data-provider';
|
||||
import {
|
||||
Switch,
|
||||
HoverCard,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
CircleHelpIcon,
|
||||
} from '@librechat/client';
|
||||
import type { TranslationKeys } from '~/hooks/useLocalize';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useAgentCapabilities, useGetAgentsConfig, useLocalize } from '~/hooks';
|
||||
import { withBooleanOption } from '~/hooks/Agents/useMCPToolOptions';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
interface Props {
|
||||
toolIds: string[];
|
||||
switchId: string;
|
||||
labelKey: TranslationKeys;
|
||||
infoKey: TranslationKeys;
|
||||
}
|
||||
|
||||
/** Shared "Background execution" switch — opts the given tool ids into
|
||||
* background dispatch via `tool_options`. Reflects enabled when ANY id is
|
||||
* opted in, mirroring the server, which honors each id independently and
|
||||
* expands grouped ids (e.g. the code pair) across the group. */
|
||||
export default function Background({ toolIds, switchId, labelKey, infoKey }: Props) {
|
||||
const localize = useLocalize();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const { backgroundToolsEnabled } = useAgentCapabilities(agentsConfig?.capabilities);
|
||||
const { control, getValues, setValue } = useFormContext<AgentForm>();
|
||||
const toolOptions = useWatch({ control, name: 'tool_options' });
|
||||
const enabled = toolIds.some((toolId) => {
|
||||
const normalized = normalizeActionToolName(toolId);
|
||||
const normalizedValue = toolOptions?.[normalized]?.run_in_background;
|
||||
if (normalized !== toolId && normalizedValue != null) {
|
||||
return normalizedValue;
|
||||
}
|
||||
return toolOptions?.[toolId]?.run_in_background === true;
|
||||
});
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: boolean) => {
|
||||
let updated = getValues('tool_options') || {};
|
||||
for (const toolId of toolIds) {
|
||||
const normalized = normalizeActionToolName(toolId);
|
||||
const aliases = normalized === toolId ? [toolId] : [toolId, normalized];
|
||||
for (const alias of aliases) {
|
||||
updated = withBooleanOption(updated, alias, 'run_in_background', value);
|
||||
}
|
||||
}
|
||||
setValue('tool_options', updated, { shouldDirty: true });
|
||||
},
|
||||
[toolIds, getValues, setValue],
|
||||
);
|
||||
|
||||
if (!backgroundToolsEnabled || toolIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm">{localize(labelKey)}</div>
|
||||
<HoverCardTrigger>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</HoverCardTrigger>
|
||||
</div>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize(infoKey)}</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enabled}
|
||||
onCheckedChange={handleChange}
|
||||
className="ml-4"
|
||||
data-testid={switchId}
|
||||
aria-label={localize(labelKey)}
|
||||
/>
|
||||
</div>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ToolItem } from '../../items/types';
|
||||
import type { AgentForm } from '~/common';
|
||||
import ToolSection from '../sections/ToolSection';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useGetAgentsConfig: () => ({ agentsConfig: undefined }),
|
||||
useAgentCapabilities: () => ({ backgroundToolsEnabled: true }),
|
||||
}));
|
||||
jest.mock('librechat-data-provider/react-query', () => ({
|
||||
useUpdateUserPluginsMutation: () => ({ mutate: jest.fn(), isLoading: false }),
|
||||
}));
|
||||
jest.mock('@librechat/client', () => ({
|
||||
...jest.requireActual('@librechat/client'),
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
jest.mock('~/components/Plugins/Store/PluginAuthForm', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div />,
|
||||
}));
|
||||
|
||||
function toolItem(id: string): ToolItem {
|
||||
return {
|
||||
kind: 'tool',
|
||||
id,
|
||||
name: id,
|
||||
description: 'A tool',
|
||||
iconKey: 'tool',
|
||||
plugin: { pluginKey: id, name: id, authConfig: [] } as unknown as TPlugin,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(item: ToolItem) {
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const methods = useForm<AgentForm>({ defaultValues: {} as AgentForm });
|
||||
return <FormProvider {...methods}>{children}</FormProvider>;
|
||||
}
|
||||
|
||||
return render(<ToolSection item={item} />, { wrapper: Wrapper });
|
||||
}
|
||||
|
||||
describe('ToolSection background switch', () => {
|
||||
test('renders the switch for a background-eligible plugin tool', () => {
|
||||
renderSection(toolItem('wolfram'));
|
||||
expect(screen.getByTestId('tool-background')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render the switch for image generation tools', () => {
|
||||
renderSection(toolItem('dalle'));
|
||||
expect(screen.queryByTestId('tool-background')).toBeNull();
|
||||
|
||||
renderSection(toolItem('image_gen_oai'));
|
||||
expect(screen.queryByTestId('tool-background')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect } from 'react';
|
||||
import type { ActionItem } from '../../items/types';
|
||||
import ActionBackground from '../../../Actions/Background';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { NEW_ACTION_ID } from '../../items/types';
|
||||
import ActionEditor from '../../ActionEditor';
|
||||
|
|
@ -20,7 +21,8 @@ export default function ActionSection({ item, agentId, onClose }: Props) {
|
|||
}, [isCreate, item.action, setAction]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
{!isCreate && <ActionBackground agentId={agentId} />}
|
||||
<ActionEditor
|
||||
agentId={agentId}
|
||||
onClose={onClose}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { imageGenTools } from 'librechat-data-provider';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TError, TPluginAction } from 'librechat-data-provider';
|
||||
|
|
@ -8,12 +9,19 @@ import type { ToolItem } from '../../items/types';
|
|||
import type { AgentForm } from '~/common';
|
||||
import PluginAuthForm from '~/components/Plugins/Store/PluginAuthForm';
|
||||
import { pluginNeedsAuth } from '../../items/auth';
|
||||
import Background from '../../../Background';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface Props {
|
||||
item: ToolItem;
|
||||
}
|
||||
|
||||
/** Client mirror of the server's image-gen background exclusion
|
||||
* (`EXCLUDED_BACKGROUND_TOOL_NAMES`): artifact-first tools whose files can't
|
||||
* attach to an already-saved turn never get the switch. */
|
||||
const isBackgroundEligibleTool = (toolId: string): boolean =>
|
||||
!imageGenTools.has(toolId) && toolId !== 'image_gen_oai' && toolId !== 'image_edit_oai';
|
||||
|
||||
export default function ToolSection({ item }: Props) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
@ -94,6 +102,14 @@ export default function ToolSection({ item }: Props) {
|
|||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
{isBackgroundEligibleTool(item.id) && (
|
||||
<Background
|
||||
toolIds={[item.id]}
|
||||
switchId="tool-background"
|
||||
labelKey="com_ui_tool_background"
|
||||
infoKey="com_nav_info_tool_background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import '@testing-library/jest-dom/extend-expect';
|
||||
import { useForm, FormProvider, useWatch } from 'react-hook-form';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { AgentForm } from '~/common';
|
||||
import Background from '../Background';
|
||||
|
||||
let mockBackgroundEnabled = true;
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useGetAgentsConfig: () => ({ agentsConfig: undefined }),
|
||||
useAgentCapabilities: () => ({ backgroundToolsEnabled: mockBackgroundEnabled }),
|
||||
}));
|
||||
|
||||
function OptionsProbe() {
|
||||
const value = useWatch<AgentForm>({ name: 'tool_options' });
|
||||
return <span data-testid="options">{JSON.stringify(value ?? null)}</span>;
|
||||
}
|
||||
|
||||
function renderBackground(toolIds: string[], defaultValues: Partial<AgentForm> = {}) {
|
||||
function Wrapper({ children }: { children: ReactNode }) {
|
||||
const methods = useForm<AgentForm>({ defaultValues: defaultValues as AgentForm });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
{children}
|
||||
<OptionsProbe />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return render(
|
||||
<Background
|
||||
toolIds={toolIds}
|
||||
switchId="bg-switch"
|
||||
labelKey="com_ui_tool_background"
|
||||
infoKey="com_nav_info_tool_background"
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
}
|
||||
|
||||
describe('Background switch', () => {
|
||||
beforeEach(() => {
|
||||
mockBackgroundEnabled = true;
|
||||
});
|
||||
|
||||
test('hidden when the background capability is off', () => {
|
||||
mockBackgroundEnabled = false;
|
||||
renderBackground(['wolfram']);
|
||||
expect(screen.queryByTestId('bg-switch')).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden when there are no tool ids to opt in', () => {
|
||||
renderBackground([]);
|
||||
expect(screen.queryByTestId('bg-switch')).toBeNull();
|
||||
});
|
||||
|
||||
test('reflects enabled when ANY grouped id is opted in', () => {
|
||||
renderBackground(['execute_code', 'bash_tool'], {
|
||||
tool_options: { bash_tool: { run_in_background: true } },
|
||||
});
|
||||
expect(screen.getByTestId('bg-switch')).toBeChecked();
|
||||
});
|
||||
|
||||
test('toggling writes every grouped id and clears entries on disable', () => {
|
||||
renderBackground(['execute_code', 'bash_tool']);
|
||||
const switchEl = screen.getByTestId('bg-switch');
|
||||
expect(switchEl).not.toBeChecked();
|
||||
|
||||
fireEvent.click(switchEl);
|
||||
expect(switchEl).toBeChecked();
|
||||
const enabled = JSON.parse(screen.getByTestId('options').textContent ?? 'null');
|
||||
expect(enabled).toEqual({
|
||||
execute_code: { run_in_background: true },
|
||||
bash_tool: { run_in_background: true },
|
||||
});
|
||||
|
||||
fireEvent.click(switchEl);
|
||||
expect(switchEl).not.toBeChecked();
|
||||
const disabled = JSON.parse(screen.getByTestId('options').textContent ?? 'null');
|
||||
expect(disabled).toEqual({});
|
||||
});
|
||||
|
||||
test('preserves unrelated per-tool options when toggling', () => {
|
||||
renderBackground(['wolfram'], {
|
||||
tool_options: { search_mcp_docs: { defer_loading: true } },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('bg-switch'));
|
||||
const options = JSON.parse(screen.getByTestId('options').textContent ?? 'null');
|
||||
expect(options).toEqual({
|
||||
search_mcp_docs: { defer_loading: true },
|
||||
wolfram: { run_in_background: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('reconciles raw and normalized action aliases when enabling', () => {
|
||||
const rawToolId = 'getPerson_action_swapi---tech';
|
||||
const normalizedToolId = 'getPerson_action_swapi_tech';
|
||||
renderBackground([rawToolId], {
|
||||
tool_options: {
|
||||
[rawToolId]: { run_in_background: true },
|
||||
[normalizedToolId]: { defer_loading: true, run_in_background: false },
|
||||
},
|
||||
});
|
||||
|
||||
const switchEl = screen.getByTestId('bg-switch');
|
||||
expect(switchEl).not.toBeChecked();
|
||||
fireEvent.click(switchEl);
|
||||
|
||||
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual({
|
||||
[rawToolId]: { run_in_background: true },
|
||||
[normalizedToolId]: { defer_loading: true, run_in_background: true },
|
||||
});
|
||||
});
|
||||
|
||||
test('clears both action aliases when disabling', () => {
|
||||
const rawToolId = 'getPerson_action_swapi---tech';
|
||||
const normalizedToolId = 'getPerson_action_swapi_tech';
|
||||
renderBackground([rawToolId], {
|
||||
tool_options: {
|
||||
[normalizedToolId]: { defer_loading: true, run_in_background: true },
|
||||
},
|
||||
});
|
||||
|
||||
const switchEl = screen.getByTestId('bg-switch');
|
||||
expect(switchEl).toBeChecked();
|
||||
fireEvent.click(switchEl);
|
||||
|
||||
expect(JSON.parse(screen.getByTestId('options').textContent ?? 'null')).toEqual({
|
||||
[normalizedToolId]: { defer_loading: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { mergeDirtyToolsWithServerActions } from '../agentTools';
|
||||
|
||||
describe('mergeDirtyToolsWithServerActions', () => {
|
||||
it('preserves dirty non-action choices and replaces action registrations', () => {
|
||||
expect(
|
||||
mergeDirtyToolsWithServerActions(
|
||||
['local_plugin', 'removed_action_old---example---com'],
|
||||
['server_plugin', 'added_action_new---example---com'],
|
||||
),
|
||||
).toEqual(['local_plugin', 'added_action_new---example---com']);
|
||||
});
|
||||
|
||||
it('does not duplicate a server action registration', () => {
|
||||
expect(
|
||||
mergeDirtyToolsWithServerActions(
|
||||
[],
|
||||
['get_action_api---example---com', 'get_action_api---example---com'],
|
||||
),
|
||||
).toEqual(['get_action_api---example---com']);
|
||||
});
|
||||
});
|
||||
20
client/src/components/SidePanel/Agents/agentTools.ts
Normal file
20
client/src/components/SidePanel/Agents/agentTools.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { isActionTool } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Keeps unsaved non-action tool choices while accepting the server's canonical
|
||||
* action registrations after an adjacent action create/update mutation.
|
||||
*/
|
||||
export function mergeDirtyToolsWithServerActions(
|
||||
dirtyTools: readonly string[],
|
||||
serverTools: readonly string[],
|
||||
): string[] {
|
||||
const merged = dirtyTools.filter((tool) => !isActionTool(tool));
|
||||
const seen = new Set(merged);
|
||||
for (const tool of serverTools) {
|
||||
if (isActionTool(tool) && !seen.has(tool)) {
|
||||
merged.push(tool);
|
||||
seen.add(tool);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
|
@ -554,6 +554,7 @@
|
|||
"com_nav_info_smooth_streaming": "When enabled, newly streamed words fade in smoothly for the latest response. This is purely visual — it does not delay token delivery — and is disabled automatically when your device prefers reduced motion.",
|
||||
"com_nav_info_stateful_sessions": "When enabled, this agent uses the dedicated stateful Code API instead of the default stateless service. Files, installed packages, and working state usually carry over between runs. The workspace may occasionally reset, so save anything important under /mnt/data. Requires Code Interpreter and the app-level stateful sessions capability.",
|
||||
"com_nav_info_stateful_code_environment": "Choose who shares this agent's stateful workspace. This does not share live files with stateless code sessions.",
|
||||
"com_nav_info_tool_background": "When enabled, the model can run this tool in the background: the conversation continues immediately while the tool runs, and the model retrieves the result later with the background task tool. Requires the app-level background tools capability.",
|
||||
"com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.",
|
||||
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
|
||||
"com_nav_lang_arabic": "العربية",
|
||||
|
|
@ -2279,6 +2280,7 @@
|
|||
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||
"com_ui_token_url": "Token URL",
|
||||
"com_ui_tokens": "tokens",
|
||||
"com_ui_tool_background": "Background execution",
|
||||
"com_ui_tool_collection_prefix": "A collection of tools from",
|
||||
"com_ui_tool_credentials_saved": "Credentials saved",
|
||||
"com_ui_tool_failed": "failed",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ jest.mock(
|
|||
'librechat-data-provider',
|
||||
() => ({
|
||||
actionDelimiter: '_action_',
|
||||
normalizeActionToolName: (toolName: string) => toolName,
|
||||
validateAndParseOpenAPISpec: (specString: string) => {
|
||||
const spec = JSON.parse(specString) as { paths?: Record<string, unknown> };
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { actionDelimiter, validateAndParseOpenAPISpec } from 'librechat-data-provider';
|
||||
|
||||
export { normalizeActionToolName } from 'librechat-data-provider';
|
||||
|
||||
export type ActionToolLike = {
|
||||
function?: {
|
||||
name?: string;
|
||||
|
|
|
|||
|
|
@ -237,6 +237,88 @@ describe('applyBackgroundToolCalls', () => {
|
|||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves an action opt-in stored with the raw `---` domain against the collapsed def name', () => {
|
||||
/** Agents persist `swapi---tech`; the runtime def is named `swapi_tech`. */
|
||||
const defs = [mcpDef('getPerson_action_swapi_tech')];
|
||||
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
|
||||
const result = applyBackgroundToolCalls({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: registry,
|
||||
toolOptions: { 'getPerson_action_swapi---tech': { run_in_background: true } },
|
||||
});
|
||||
expect(result.backgroundToolNames).toEqual(['getPerson_action_swapi_tech']);
|
||||
expect(registry.has(CHECK_BACKGROUND_TASK_NAME)).toBe(true);
|
||||
});
|
||||
|
||||
it('merges a raw action opt-in into an existing normalized option entry', () => {
|
||||
const defs = [mcpDef('getPerson_action_swapi_tech')];
|
||||
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
|
||||
const result = applyBackgroundToolCalls({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: registry,
|
||||
toolOptions: {
|
||||
'getPerson_action_swapi---tech': { run_in_background: true },
|
||||
getPerson_action_swapi_tech: { defer_loading: true },
|
||||
},
|
||||
});
|
||||
expect(result.backgroundToolNames).toEqual(['getPerson_action_swapi_tech']);
|
||||
});
|
||||
|
||||
it('keeps an explicit normalized action background option authoritative', () => {
|
||||
const defs = [mcpDef('getPerson_action_swapi_tech')];
|
||||
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
|
||||
const result = applyBackgroundToolCalls({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: registry,
|
||||
toolOptions: {
|
||||
'getPerson_action_swapi---tech': { run_in_background: true },
|
||||
getPerson_action_swapi_tech: { run_in_background: false },
|
||||
},
|
||||
});
|
||||
expect(result.backgroundToolNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not collapse hyphens in the operationId when normalizing an action key', () => {
|
||||
const defs = [
|
||||
mcpDef('get_foo---bar_action_swapi_tech'),
|
||||
mcpDef('get_foo_bar_action_swapi_tech'),
|
||||
];
|
||||
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
|
||||
const result = applyBackgroundToolCalls({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: registry,
|
||||
toolOptions: { 'get_foo---bar_action_swapi---tech': { run_in_background: true } },
|
||||
});
|
||||
expect(result.backgroundToolNames).toEqual(['get_foo---bar_action_swapi_tech']);
|
||||
});
|
||||
|
||||
it('injects an opted-in action tool but not one the OAuth excludeTool rejects', () => {
|
||||
const oauthActionNames = new Set(['sendMail_action_mail---example---com']);
|
||||
const defs = [
|
||||
mcpDef('getWeather_action_weather---com'),
|
||||
mcpDef('sendMail_action_mail---example---com'),
|
||||
];
|
||||
const registry: LCToolRegistry = new Map(defs.map((d) => [d.name, { ...d }]));
|
||||
const result = applyBackgroundToolCalls({
|
||||
toolDefinitions: defs,
|
||||
toolRegistry: registry,
|
||||
toolOptions: {
|
||||
'getWeather_action_weather---com': { run_in_background: true },
|
||||
'sendMail_action_mail---example---com': { run_in_background: true },
|
||||
},
|
||||
excludeTool: (name) => oauthActionNames.has(name),
|
||||
});
|
||||
expect(result.backgroundToolNames).toEqual(['getWeather_action_weather---com']);
|
||||
const oauthDef = result.toolDefinitions.find(
|
||||
(d) => d.name === 'sendMail_action_mail---example---com',
|
||||
);
|
||||
expect(
|
||||
(oauthDef?.parameters as { properties?: Record<string, unknown> }).properties?.[
|
||||
RUN_IN_BACKGROUND_ARG
|
||||
],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips a non-object (string-input) schema without rewriting it', () => {
|
||||
const defs = [{ name: 'legacy_tool', parameters: { type: 'string' } } as unknown as LCTool];
|
||||
const result = applyBackgroundToolCalls({
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { SubagentTaskOwnerUnavailableError } from './subagentTaskRouting';
|
|||
import { SET_MEMORY_TOOL_NAME, DELETE_MEMORY_TOOL_NAME } from './memory';
|
||||
import { ASK_USER_QUESTION_TOOL_NAME } from './hitl/askUserQuestionTool';
|
||||
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from './tools';
|
||||
import { normalizeActionToolName } from '~/actions/tools';
|
||||
import { truncateMiddle } from '~/utils';
|
||||
|
||||
/** Argument the model sets on a tool call to dispatch it in the background. */
|
||||
|
|
@ -125,6 +126,36 @@ const EXCLUDED_BACKGROUND_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
|
|||
'image_edit_oai',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Agents persist action tool names with the raw encoded domain (`---` for short
|
||||
* hostnames), while the runtime definitions those names must match against are
|
||||
* always `_`-collapsed. The builder writes `tool_options` keyed by the persisted
|
||||
* name, so alias every action-shaped key to its normalized form; without this
|
||||
* the opt-in silently never resolves for short-hostname actions. Merge the raw
|
||||
* background option into any normalized entry while keeping an explicit
|
||||
* normalized background value authoritative.
|
||||
*/
|
||||
function expandActionToolOptions(toolOptions: AgentToolOptions): AgentToolOptions {
|
||||
let expanded: AgentToolOptions | undefined;
|
||||
for (const [name, options] of Object.entries(toolOptions)) {
|
||||
const normalized = normalizeActionToolName(name);
|
||||
const runInBackground = options?.run_in_background;
|
||||
if (
|
||||
normalized === name ||
|
||||
runInBackground == null ||
|
||||
toolOptions[normalized]?.run_in_background != null
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
expanded = expanded ?? { ...toolOptions };
|
||||
expanded[normalized] = {
|
||||
...toolOptions[normalized],
|
||||
run_in_background: runInBackground,
|
||||
};
|
||||
}
|
||||
return expanded ?? toolOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool may be dispatched in the background. Handoff tools
|
||||
* (`lc_transfer_to_*`) run through the direct path and are excluded by prefix.
|
||||
|
|
@ -466,7 +497,8 @@ export function applyBackgroundToolCalls(params: {
|
|||
*/
|
||||
excludeTool?: (toolName: string) => boolean;
|
||||
}): { toolDefinitions: LCTool[]; backgroundToolNames: string[] } {
|
||||
const { toolRegistry, toolOptions, capabilityToolNames, excludeTool } = params;
|
||||
const { toolRegistry, capabilityToolNames, excludeTool } = params;
|
||||
const toolOptions = params.toolOptions && expandActionToolOptions(params.toolOptions);
|
||||
const defs = params.toolDefinitions ?? [];
|
||||
const selectionNames = getSelectionNames(toolOptions, 'run_in_background');
|
||||
const effectiveSources = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -555,6 +555,8 @@ export interface InitializeAgentParams {
|
|||
hasDeferredTools?: boolean;
|
||||
mcpToolAliases?: MCPToolAlias[];
|
||||
actionsEnabled?: boolean;
|
||||
/** Action tool names backed by OAuth — excluded from background dispatch. */
|
||||
oauthActionToolNames?: string[];
|
||||
/**
|
||||
* Pre-uploaded code-env file refs for the agent's
|
||||
* `tool_resources.execute_code`. Bubbled up so the run host can seed
|
||||
|
|
@ -1308,6 +1310,7 @@ export async function initializeAgent(
|
|||
hasDeferredTools,
|
||||
mcpToolAliases,
|
||||
actionsEnabled,
|
||||
oauthActionToolNames,
|
||||
tools: structuredTools,
|
||||
primedCodeFiles,
|
||||
} = loadToolsResult ?? {
|
||||
|
|
@ -1322,6 +1325,7 @@ export async function initializeAgent(
|
|||
hasDeferredTools: false,
|
||||
mcpToolAliases: [],
|
||||
actionsEnabled: undefined,
|
||||
oauthActionToolNames: undefined,
|
||||
primedCodeFiles: undefined,
|
||||
};
|
||||
|
||||
|
|
@ -1540,6 +1544,7 @@ export async function initializeAgent(
|
|||
* ephemeral subset: a non-ephemeral name ending in an ephemeral one would
|
||||
* otherwise be misread as ephemeral. */
|
||||
const allServerNames = Object.keys(req.config?.mcpConfig ?? {}).map(normalizeServerName);
|
||||
const oauthActionNames = new Set(oauthActionToolNames ?? []);
|
||||
const backgroundResult = applyBackgroundToolCalls({
|
||||
toolDefinitions,
|
||||
toolRegistry,
|
||||
|
|
@ -1549,8 +1554,13 @@ export async function initializeAgent(
|
|||
* placeholders) never get the param: their connection dies at request
|
||||
* end, so the executor would only downgrade the call to foreground.
|
||||
* Unknown servers stay eligible — the executor's per-instance tag is
|
||||
* the fail-safe for those. */
|
||||
* the fail-safe for those. OAuth-backed action tools are excluded too:
|
||||
* a detached call can block on an interactive login prompt the user
|
||||
* never sees. */
|
||||
excludeTool: (toolName) => {
|
||||
if (oauthActionNames.has(toolName)) {
|
||||
return true;
|
||||
}
|
||||
const [, serverName] = splitMCPToolKey(toolName, allServerNames);
|
||||
return serverName != null && ephemeralServerNames.has(serverName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3778,7 +3778,13 @@ describe('SubagentThreadTaskStore', () => {
|
|||
expect(store.get(config.scopeId, liveTaskId)?.pendingControls).toBe(2);
|
||||
|
||||
finish({ content: 'done' });
|
||||
await waitForSettled(store, config.scopeId, live);
|
||||
/** This store deliberately uses a 20 ms completed TTL. Under coverage the
|
||||
* task can settle and expire between polling ticks, which is also a valid
|
||||
* terminal outcome for the cleanup asserted by this test. */
|
||||
await waitUntil(
|
||||
() => store.get(config.scopeId, liveTaskId)?.status !== 'running',
|
||||
'the live replay-window task to settle or expire',
|
||||
);
|
||||
await store.destroyTaskControlTransport();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ describe('definitions.ts', () => {
|
|||
expect(result.toolDefinitions).toHaveLength(0);
|
||||
expect(result.toolRegistry.size).toBe(0);
|
||||
expect(result.hasDeferredTools).toBe(false);
|
||||
expect(result.oauthActionToolNames).toEqual([]);
|
||||
});
|
||||
|
||||
describe('action tool definitions', () => {
|
||||
|
|
@ -130,6 +131,53 @@ describe('definitions.ts', () => {
|
|||
expect(actionDef?.parameters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('collects OAuth action tool names and strips the marker from emitted defs', async () => {
|
||||
const mockActionDefs: ActionToolDefinition[] = [
|
||||
{
|
||||
name: 'getWeather_action_weather_com',
|
||||
description: 'Get weather for a location',
|
||||
oauth: false,
|
||||
},
|
||||
{
|
||||
name: 'sendMail_action_mail_example_com',
|
||||
description: 'Send an email',
|
||||
oauth: true,
|
||||
},
|
||||
{
|
||||
name: 'listItems_action_api_example_com',
|
||||
description: 'List all items',
|
||||
},
|
||||
];
|
||||
|
||||
const mockGetActionToolDefinitions = jest.fn().mockResolvedValue(mockActionDefs);
|
||||
|
||||
const params: LoadToolDefinitionsParams = {
|
||||
userId: 'user-123',
|
||||
agentId: 'agent-123',
|
||||
tools: [
|
||||
'getWeather_action_weather---com',
|
||||
'sendMail_action_mail---example---com',
|
||||
'listItems_action_api---example---com',
|
||||
],
|
||||
};
|
||||
|
||||
const deps: LoadToolDefinitionsDeps = {
|
||||
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
|
||||
isBuiltInTool: mockIsBuiltInTool,
|
||||
getActionToolDefinitions: mockGetActionToolDefinitions,
|
||||
};
|
||||
|
||||
const result = await loadToolDefinitions(params, deps);
|
||||
|
||||
expect(result.oauthActionToolNames).toEqual(['sendMail_action_mail_example_com']);
|
||||
for (const def of result.toolDefinitions) {
|
||||
expect(def).not.toHaveProperty('oauth');
|
||||
}
|
||||
const registryEntry = result.toolRegistry.get('sendMail_action_mail_example_com');
|
||||
expect(registryEntry).toBeDefined();
|
||||
expect(registryEntry).not.toHaveProperty('oauth');
|
||||
});
|
||||
|
||||
it('should not classify MCP tools with _action in name as action tools', async () => {
|
||||
const mockGetActionToolDefinitions = jest.fn();
|
||||
const mcpTool = 'get_action_mcp_myserver';
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ export interface ActionToolDefinition {
|
|||
name: string;
|
||||
description?: string;
|
||||
parameters?: JsonSchemaType;
|
||||
/** True when the action authenticates via OAuth — its calls may block on an
|
||||
* interactive login prompt, so it must never be dispatched in the background. */
|
||||
oauth?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadToolDefinitionsDeps {
|
||||
|
|
@ -101,6 +104,8 @@ export interface LoadToolDefinitionsResult {
|
|||
expectedToolCount: number;
|
||||
resolvedToolCount: number;
|
||||
};
|
||||
/** Action tool names backed by OAuth — excluded from background dispatch. */
|
||||
oauthActionToolNames: string[];
|
||||
}
|
||||
|
||||
const mcpToolPattern = /_mcp_/;
|
||||
|
|
@ -152,6 +157,7 @@ export async function loadToolDefinitions(
|
|||
hasDeferredTools: false,
|
||||
mcpToolAliases: [],
|
||||
mcpResolution: { expectedToolCount: 0, resolvedToolCount: 0 },
|
||||
oauthActionToolNames: [],
|
||||
};
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
|
|
@ -324,13 +330,19 @@ export async function loadToolDefinitions(
|
|||
}
|
||||
}
|
||||
|
||||
const oauthActionToolNames: string[] = [];
|
||||
if (actionToolNames.length > 0 && getActionToolDefinitions) {
|
||||
const fetchedActionDefs = await getActionToolDefinitions(agentId, actionToolNames);
|
||||
actionToolDefs = fetchedActionDefs.map((def) => ({
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
}));
|
||||
actionToolDefs = fetchedActionDefs.map((def) => {
|
||||
if (def.oauth === true) {
|
||||
oauthActionToolNames.push(def.name);
|
||||
}
|
||||
return {
|
||||
name: def.name,
|
||||
description: def.description,
|
||||
parameters: def.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const loadedTools = mcpToolDefs.map((def) => ({
|
||||
|
|
@ -395,5 +407,6 @@ export async function loadToolDefinitions(
|
|||
expectedToolCount: expectedMCPToolCount,
|
||||
resolvedToolCount: resolvedMCPToolCount,
|
||||
},
|
||||
oauthActionToolNames,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
import type { AgentToolOptions } from './types/assistants';
|
||||
import { removeCodeExecutionCaller } from './agentToolOptions';
|
||||
import { normalizeActionToolName, removeCodeExecutionCaller } from './agentToolOptions';
|
||||
|
||||
describe('normalizeActionToolName', () => {
|
||||
it('normalizes only the encoded action domain', () => {
|
||||
expect(normalizeActionToolName('get_foo---bar_action_swapi---tech')).toBe(
|
||||
'get_foo---bar_action_swapi_tech',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves non-action tool names unchanged', () => {
|
||||
expect(normalizeActionToolName('search_mcp_docs---server')).toBe('search_mcp_docs---server');
|
||||
expect(normalizeActionToolName('get_action_data---x_mcp_srv')).toBe(
|
||||
'get_action_data---x_mcp_srv',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeCodeExecutionCaller', () => {
|
||||
it('removes a programmatic-only entry that has no other options', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,26 @@
|
|||
import type { AgentToolOptions, AllowedCaller } from './types/assistants';
|
||||
import {
|
||||
actionDelimiter,
|
||||
actionDomainSeparator,
|
||||
isActionTool,
|
||||
type AgentToolOptions,
|
||||
type AllowedCaller,
|
||||
} from './types/assistants';
|
||||
|
||||
const actionDomainSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
|
||||
|
||||
/**
|
||||
* Collapses the encoded-domain suffix of an action tool name to the shape used
|
||||
* by runtime tool definitions. The operation id is deliberately preserved.
|
||||
*/
|
||||
export function normalizeActionToolName(toolName: string): string {
|
||||
if (!isActionTool(toolName)) {
|
||||
return toolName;
|
||||
}
|
||||
const delimiterIndex = toolName.lastIndexOf(actionDelimiter);
|
||||
const prefixEnd = delimiterIndex + actionDelimiter.length;
|
||||
const encodedDomain = toolName.slice(prefixEnd);
|
||||
return toolName.slice(0, prefixEnd) + encodedDomain.replace(actionDomainSeparatorRegex, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes Code Interpreter as an allowed caller without mutating the input.
|
||||
|
|
|
|||
|
|
@ -359,6 +359,8 @@ export type Agent = {
|
|||
owner_contact?: AgentOwnerContact;
|
||||
/** Per-tool configuration options (deferred loading, allowed callers, etc.) */
|
||||
tool_options?: AgentToolOptions;
|
||||
/** Attached action registrations, each `${encodedDomain}${actionDelimiter}${action_id}` */
|
||||
actions?: string[];
|
||||
/** Optional allowlist of skill ObjectIds. Only applies when `skills_enabled`. */
|
||||
skills?: string[];
|
||||
/** Master toggle for skill use on this agent. `true` = active (full catalog unless
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue