🧭 test: Cover Agent Handoffs End to End (#14428)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled

* test: cover agent handoffs end to end

* style: sort handoff imports

* fix: normalize missing agent handoff edges

* chore: update package dependencies and versions in package-lock.json and package.json

* chore: bump agents SDK
This commit is contained in:
Danny Avila 2026-07-27 08:47:15 -04:00 • committed by GitHub
parent 8374b8416a
commit a53936d273
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3160 additions and 527 deletions

View file

@ -116,7 +116,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.68",
"@librechat/agents": "^3.3.2",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",

View file

@ -465,6 +465,24 @@ describe('summarizationConfig field passthrough', () => {
// Suite 5: Multi-agent + per-agent overrides
// ---------------------------------------------------------------------------
describe('multi-agent + per-agent overrides', () => {
it('normalizes missing persisted edges before creating the SDK graph', async () => {
await createRun({
agents: [makeAgent({ id: 'agent_1' }), makeAgent({ id: 'agent_2' })] as never,
signal: new AbortController().signal,
streaming: true,
streamUsage: true,
});
const createMock = Run.create as jest.Mock;
const runConfig = createMock.mock.calls[0][0] as {
graphConfig: { type: string; edges: unknown[] };
};
expect(runConfig.graphConfig).toMatchObject({
type: 'multi-agent',
edges: [],
});
});
it('different agents get different effectiveMaxContextTokens', async () => {
const agents = await callAndCapture({
agents: [

View file

@ -3,11 +3,56 @@ import {
getEdgeKey,
getEdgeParticipants,
collectEdgeAgentIds,
replaceEdgeSourceId,
filterOrphanedEdges,
createEdgeCollector,
} from './edges';
describe('edges utilities', () => {
describe('replaceEdgeSourceId', () => {
it('should assign a newly created agent id to placeholder sources', () => {
const edges: GraphEdge[] = [
{ from: '', to: 'agent_target', edgeType: 'handoff' },
{ from: 'agent_other', to: 'agent_target', edgeType: 'handoff' },
];
expect(replaceEdgeSourceId(edges, '', 'agent_router')).toEqual([
{ from: 'agent_router', to: 'agent_target', edgeType: 'handoff' },
{ from: 'agent_other', to: 'agent_target', edgeType: 'handoff' },
]);
});
it('should rewrite copied agent ids inside multi-source edges', () => {
const edges: GraphEdge[] = [
{
from: ['agent_original', 'agent_peer'],
to: 'agent_target',
edgeType: 'handoff',
},
];
expect(replaceEdgeSourceId(edges, 'agent_original', 'agent_clone')).toEqual([
{
from: ['agent_clone', 'agent_peer'],
to: 'agent_target',
edgeType: 'handoff',
},
]);
});
it('should preserve untouched edge references', () => {
const edge: GraphEdge = {
from: 'agent_other',
to: 'agent_target',
edgeType: 'handoff',
};
const result = replaceEdgeSourceId([edge], 'agent_original', 'agent_clone');
expect(result?.[0]).toBe(edge);
});
});
describe('getEdgeKey', () => {
it('should create key from simple string from/to', () => {
const edge: GraphEdge = { from: 'agent_a', to: 'agent_b', edgeType: 'handoff' };

View file

@ -1,5 +1,38 @@
import type { GraphEdge } from 'librechat-data-provider';
/**
* Rewrites an agent id wherever it appears as an edge source.
*
* Agent creation uses an empty source id until the server assigns the
* persisted id, while agent duplication needs to move the copied router's
* outgoing edges to the clone. Keeping both cases here makes the rewrite
* consistent for scalar and multi-source edges.
*/
export function replaceEdgeSourceId(
edges: GraphEdge[] | undefined,
previousSourceId: string,
nextSourceId: string,
): GraphEdge[] | undefined {
if (!edges?.length || previousSourceId === nextSourceId) {
return edges;
}
return edges.map((edge) => {
if (Array.isArray(edge.from)) {
if (!edge.from.includes(previousSourceId)) {
return edge;
}
return {
...edge,
from: edge.from.map((sourceId) =>
sourceId === previousSourceId ? nextSourceId : sourceId,
),
};
}
return edge.from === previousSourceId ? { ...edge, from: nextSourceId } : edge;
});
}
/**
* Creates a stable key for edge deduplication.
* Handles both single and array-based from/to values.

View file

@ -0,0 +1,279 @@
import { Constants } from '@librechat/agents';
import { HumanMessage, ToolMessage } from '@librechat/agents/langchain/messages';
import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents';
import type { BaseMessage } from '@librechat/agents/langchain/messages';
import { applyCustomHandoffPromptKeyCompatibility } from './handoffPromptKeyCompatibility';
type HandoffReceptionResult = {
filteredMessages: BaseMessage[];
instructions: string | null;
sourceAgentName: string | null;
parallelSiblings: string[];
} | null;
type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult;
type TestGraph = {
processHandoffReception: ProcessHandoffReception;
};
const createGraphConfig = (edges: GraphEdge[]): RunConfig['graphConfig'] => ({
type: 'multi-agent',
agents: [],
edges,
});
const createRun = (
processHandoffReception: ProcessHandoffReception,
): { run: Run<IState>; graph: TestGraph } => {
const graph: TestGraph = { processHandoffReception };
return {
run: { Graph: graph } as unknown as Run<IState>,
graph,
};
};
const findTransfer = (messages: BaseMessage[], agentId: string): ToolMessage | undefined =>
messages.find(
(message): message is ToolMessage =>
ToolMessage.isInstance(message) &&
(message.name === `${Constants.LC_TRANSFER_TO_}${agentId}` ||
(message.name === 'conditional_transfer' &&
message.additional_kwargs.handoff_destination === agentId)),
);
/**
* Models the reception behavior in @librechat/agents 3.2.68: filtering and
* metadata work, but only the built-in Instructions/Context labels are read.
*/
const createSdkProcess = (): jest.MockedFunction<ProcessHandoffReception> =>
jest.fn((messages, agentId) => {
const transfer = findTransfer(messages, agentId);
if (!transfer) {
return null;
}
const content =
typeof transfer.content === 'string' ? transfer.content : JSON.stringify(transfer.content);
const instructions =
content.match(/(?:Instructions?|Context):\s*([\s\S]+)/i)?.[1]?.trim() ?? null;
const rawSiblings = transfer.additional_kwargs.handoff_parallel_siblings;
return {
filteredMessages: messages.filter((message) => message !== transfer),
instructions,
sourceAgentName:
typeof transfer.additional_kwargs.handoff_source_name === 'string'
? transfer.additional_kwargs.handoff_source_name
: null,
parallelSiblings: Array.isArray(rawSiblings)
? rawSiblings.filter((sibling): sibling is string => typeof sibling === 'string')
: [],
};
});
describe('applyCustomHandoffPromptKeyCompatibility', () => {
it('leaves multi-agent graphs without edges unpatched', () => {
const sdkProcess = createSdkProcess();
const { run, graph } = createRun(sdkProcess);
const originalProcess = graph.processHandoffReception;
// Persisted agents can predate `edges`, even though the current SDK type requires it.
const graphConfig = {
type: 'multi-agent',
agents: [],
} as unknown as RunConfig['graphConfig'];
expect(() => applyCustomHandoffPromptKeyCompatibility(run, graphConfig)).not.toThrow();
expect(graph.processHandoffReception).toBe(originalProcess);
});
it('recovers a custom prompt key for scalar and array handoff endpoints', () => {
const sdkProcess = createSdkProcess();
const { run, graph } = createRun(sdkProcess);
const userMessage = new HumanMessage('Delegate the audit');
const transferMessage = new ToolMessage({
id: 'transfer-message',
name: `${Constants.LC_TRANSFER_TO_}specialist`,
tool_call_id: 'transfer-call',
content: 'Successfully transferred to specialist\n\nWork_items: Audit cache invalidation',
status: 'success',
artifact: { preserved: true },
metadata: { trace: 'handoff' },
response_metadata: { provider: 'mock' },
additional_kwargs: {
handoff_source_name: 'Router',
handoff_parallel_siblings: ['peer', 42],
},
});
const messages = [userMessage, transferMessage];
applyCustomHandoffPromptKeyCompatibility(
run,
createGraphConfig([
{
from: ['router', 'peer'],
to: ['specialist', 'backup'],
edgeType: 'handoff',
prompt: 'Work to complete',
promptKey: 'work_items',
},
]),
);
const result = graph.processHandoffReception(messages, 'specialist');
expect(result).toEqual({
filteredMessages: [userMessage],
instructions: 'Audit cache invalidation',
sourceAgentName: 'Router',
parallelSiblings: ['peer'],
});
expect(sdkProcess).toHaveBeenCalledTimes(2);
expect(sdkProcess.mock.calls[0]?.[0]).toBe(messages);
const retryMessages = sdkProcess.mock.calls[1]?.[0];
const normalizedTransfer = retryMessages?.[1];
expect(retryMessages).not.toBe(messages);
expect(normalizedTransfer).toBeInstanceOf(ToolMessage);
expect(normalizedTransfer).not.toBe(transferMessage);
expect(normalizedTransfer?.content).toBe(
'Successfully transferred to specialist\n\nInstructions: Audit cache invalidation',
);
expect(normalizedTransfer).toMatchObject({
id: 'transfer-message',
name: `${Constants.LC_TRANSFER_TO_}specialist`,
tool_call_id: 'transfer-call',
status: 'success',
artifact: { preserved: true },
metadata: { trace: 'handoff' },
response_metadata: { provider: 'mock' },
additional_kwargs: {
handoff_source_name: 'Router',
handoff_parallel_siblings: ['peer', 42],
},
});
expect(transferMessage.content).toContain('Work_items:');
});
it.each([
{
name: 'the default instructions key',
promptKey: undefined,
label: 'Instructions',
},
{
name: 'the already-supported context key',
promptKey: 'context',
label: 'Context',
},
])('leaves $name on the SDK path', ({ promptKey, label }) => {
const sdkProcess = createSdkProcess();
const { run, graph } = createRun(sdkProcess);
const originalProcess = graph.processHandoffReception;
const edge: GraphEdge = {
from: 'router',
to: 'specialist',
edgeType: 'handoff',
prompt: 'Work to complete',
...(promptKey && { promptKey }),
};
applyCustomHandoffPromptKeyCompatibility(run, createGraphConfig([edge]));
expect(graph.processHandoffReception).toBe(originalProcess);
expect(
graph.processHandoffReception(
[
new ToolMessage({
name: `${Constants.LC_TRANSFER_TO_}specialist`,
tool_call_id: 'transfer-call',
content: `Successfully transferred\n\n${label}: Keep the native behavior`,
}),
],
'specialist',
)?.instructions,
).toBe('Keep the native behavior');
expect(sdkProcess).toHaveBeenCalledTimes(1);
});
it('self-disables when the SDK already extracts a custom prompt key', () => {
const upstreamResult: Exclude<HandoffReceptionResult, null> = {
filteredMessages: [],
instructions: 'Handled upstream',
sourceAgentName: 'Router',
parallelSiblings: [],
};
const sdkProcess = jest.fn<
ReturnType<ProcessHandoffReception>,
Parameters<ProcessHandoffReception>
>(() => upstreamResult);
const { run, graph } = createRun(sdkProcess);
const config = createGraphConfig([
{
from: 'router',
to: 'specialist',
edgeType: 'handoff',
prompt: 'Work to complete',
promptKey: 'work_items',
},
]);
applyCustomHandoffPromptKeyCompatibility(run, config);
const wrappedProcess = graph.processHandoffReception;
applyCustomHandoffPromptKeyCompatibility(run, config);
expect(graph.processHandoffReception).toBe(wrappedProcess);
expect(
graph.processHandoffReception(
[
new ToolMessage({
name: `${Constants.LC_TRANSFER_TO_}specialist`,
tool_call_id: 'transfer-call',
content: 'Successfully transferred\n\nWork_items: Handled upstream',
}),
],
'specialist',
),
).toBe(upstreamResult);
expect(sdkProcess).toHaveBeenCalledTimes(1);
});
it('ignores custom keys on irrelevant destinations and direct edges', () => {
const sdkProcess = createSdkProcess();
const { run, graph } = createRun(sdkProcess);
applyCustomHandoffPromptKeyCompatibility(
run,
createGraphConfig([
{
from: 'router',
to: 'different-agent',
edgeType: 'handoff',
prompt: 'Work to complete',
promptKey: 'work_items',
},
{
from: ['router', 'peer'],
to: ['specialist', 'backup'],
edgeType: 'direct',
prompt: 'Direct prompt',
promptKey: 'work_items',
},
]),
);
const result = graph.processHandoffReception(
[
new ToolMessage({
name: `${Constants.LC_TRANSFER_TO_}specialist`,
tool_call_id: 'transfer-call',
content: 'Successfully transferred\n\nWork_items: Do not reinterpret this edge',
}),
],
'specialist',
);
expect(result?.instructions).toBeNull();
expect(sdkProcess).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,185 @@
import { Constants } from '@librechat/agents';
import { ToolMessage } from '@librechat/agents/langchain/messages';
import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents';
import type { BaseMessage } from '@librechat/agents/langchain/messages';
type HandoffReceptionResult = {
filteredMessages: BaseMessage[];
instructions: string | null;
sourceAgentName: string | null;
parallelSiblings: string[];
} | null;
type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult;
const PROCESS_HANDOFF_RECEPTION = 'processHandoffReception';
const patchedGraphs = new WeakSet<object>();
const sdkSupportedPromptKeys = new Set(['instruction', 'instructions', 'context']);
function capitalizeFirst(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
function hasDestination(edge: GraphEdge, agentId: string): boolean {
const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
return destinations.includes(agentId);
}
function getCustomPromptLabels(edges: GraphEdge[], agentId: string): string[] {
const labels = new Set<string>();
for (const edge of edges) {
const promptKey = edge.promptKey;
if (
edge.edgeType === 'direct' ||
typeof edge.prompt !== 'string' ||
!promptKey ||
sdkSupportedPromptKeys.has(promptKey.toLowerCase()) ||
!hasDestination(edge, agentId)
) {
continue;
}
labels.add(capitalizeFirst(promptKey));
}
return [...labels];
}
function findTransferMessageIndex(messages: BaseMessage[], agentId: string): number {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (!ToolMessage.isInstance(message)) {
continue;
}
const isStandardTransfer = message.name === `${Constants.LC_TRANSFER_TO_}${agentId}`;
const isConditionalTransfer =
message.name === 'conditional_transfer' &&
message.additional_kwargs.handoff_destination === agentId;
if (isStandardTransfer || isConditionalTransfer) {
return index;
}
}
return -1;
}
function normalizeCustomPromptLabel(content: string, labels: string[]): string | null {
let matchedNeedle: string | null = null;
let matchedIndex = Number.POSITIVE_INFINITY;
for (const label of labels) {
const needle = `\n\n${label}:`;
const index = content.indexOf(needle);
if (index >= 0 && index < matchedIndex) {
matchedNeedle = needle;
matchedIndex = index;
}
}
if (matchedNeedle === null) {
return null;
}
return (
content.slice(0, matchedIndex) +
'\n\nInstructions:' +
content.slice(matchedIndex + matchedNeedle.length)
);
}
function cloneToolMessageWithContent(message: ToolMessage, content: string): ToolMessage {
return new ToolMessage({
content,
id: message.id,
name: message.name,
tool_call_id: message.tool_call_id,
status: message.status,
artifact: message.artifact,
metadata: message.metadata,
additional_kwargs: message.additional_kwargs,
response_metadata: message.response_metadata,
});
}
/**
* Compatibility adapter for @librechat/agents 3.2.68, whose handoff receiver
* recognizes only Instructions/Context even though handoff tools can emit an
* arbitrary configured promptKey. The SDK remains authoritative: its method runs first,
* and the adapter retries with a cloned, normalized ToolMessage only when the
* SDK found the transfer but did not extract instructions.
*
* The adapter patches only the current Run graph and is intentionally
* self-disabling when the upstream receiver begins handling custom keys.
*/
export function applyCustomHandoffPromptKeyCompatibility(
run: Run<IState>,
graphConfig: RunConfig['graphConfig'],
): void {
if (graphConfig.type !== 'multi-agent') {
return;
}
const edges = graphConfig.edges ?? [];
const hasCustomPromptKey = edges.some((edge) => {
const promptKey = edge.promptKey;
return (
edge.edgeType !== 'direct' &&
typeof edge.prompt === 'string' &&
!!promptKey &&
!sdkSupportedPromptKeys.has(promptKey.toLowerCase())
);
});
if (!hasCustomPromptKey || !run.Graph || patchedGraphs.has(run.Graph)) {
return;
}
const graph = run.Graph;
const graphMethods = graph as unknown as Record<string, unknown>;
const candidate = graphMethods[PROCESS_HANDOFF_RECEPTION];
if (typeof candidate !== 'function') {
return;
}
const original = candidate as ProcessHandoffReception;
graphMethods[PROCESS_HANDOFF_RECEPTION] = function (
this: unknown,
messages: BaseMessage[],
agentId: string,
): HandoffReceptionResult {
const result = original.call(this, messages, agentId);
if (result === null || result.instructions !== null) {
return result;
}
const labels = getCustomPromptLabels(edges, agentId);
if (labels.length === 0) {
return result;
}
const transferIndex = findTransferMessageIndex(messages, agentId);
if (transferIndex < 0) {
return result;
}
const transferMessage = messages[transferIndex];
if (!ToolMessage.isInstance(transferMessage) || typeof transferMessage.content !== 'string') {
return result;
}
const normalizedContent = normalizeCustomPromptLabel(transferMessage.content, labels);
if (normalizedContent === null) {
return result;
}
const normalizedMessages = [...messages];
normalizedMessages[transferIndex] = cloneToolMessageWithContent(
transferMessage,
normalizedContent,
);
return original.call(this, normalizedMessages, agentId);
};
patchedGraphs.add(graph);
}

View file

@ -47,6 +47,7 @@ import {
createAskUserQuestionTool,
} from '~/agents/hitl/askUserQuestionTool';
import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy';
import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility';
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
import { getProviderConfig } from '~/endpoints/config/providers';
@ -1361,7 +1362,7 @@ export async function createRun({
const graphConfig: RunConfig['graphConfig'] = {
signal,
agents: agentInputs,
edges: agents[0].edges,
edges: agents[0].edges ?? [],
};
if (agentInputs.length > 1 || ((graphConfig as MultiAgentGraphConfig).edges?.length ?? 0) > 0) {
@ -1558,6 +1559,7 @@ export async function createRun({
};
const run = await Run.create(runConfig);
applyCustomHandoffPromptKeyCompatibility(run, runConfig.graphConfig);
applyTestRunHook(run, { messages, agents });
return run;
}

View file

@ -772,47 +772,172 @@ describe('Agent Methods', () => {
expect(aclEntriesAfter).toHaveLength(0);
});
test('should remove handoff edges referencing deleted agent from other agents', async () => {
test('should remove a deleted agent from scalar and array edge endpoints', async () => {
const authorId = new mongoose.Types.ObjectId();
const targetAgentId = `agent_${uuidv4()}`;
const deletedAgentId = `agent_${uuidv4()}`;
const graphAgentId = `agent_${uuidv4()}`;
const sourceAgentId = `agent_${uuidv4()}`;
const targetAgentId = `agent_${uuidv4()}`;
// Create target agent (handoff destination)
await createAgent({
id: targetAgentId,
name: 'Target Agent',
id: deletedAgentId,
name: 'Agent To Delete',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Create source agent with handoff edge to target
await createAgent({
id: sourceAgentId,
name: 'Source Agent',
id: graphAgentId,
name: 'Agent With Connected Edges',
provider: 'test',
model: 'test-model',
author: authorId,
edges: [
{
from: deletedAgentId,
to: targetAgentId,
edgeType: 'handoff',
},
{
from: sourceAgentId,
to: deletedAgentId,
edgeType: 'handoff',
},
{
from: [deletedAgentId, sourceAgentId],
to: targetAgentId,
edgeType: 'direct',
},
{
from: sourceAgentId,
to: [deletedAgentId, targetAgentId],
edgeType: 'handoff',
},
{
from: [deletedAgentId],
to: targetAgentId,
edgeType: 'handoff',
},
{
from: sourceAgentId,
to: [deletedAgentId],
edgeType: 'handoff',
},
{
from: [deletedAgentId, sourceAgentId],
to: [deletedAgentId, targetAgentId],
edgeType: 'direct',
},
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
description: 'Unrelated edge',
},
],
});
// Verify edge exists before deletion
const sourceAgentBefore = await getAgent({ id: sourceAgentId });
expect(sourceAgentBefore!.edges).toHaveLength(1);
expect(sourceAgentBefore!.edges![0].to).toBe(targetAgentId);
await deleteAgent({ id: deletedAgentId });
// Delete the target agent
await deleteAgent({ id: targetAgentId });
const graphAgent = await getAgent({ id: graphAgentId });
expect(graphAgent!.edges).toEqual([
{
from: [sourceAgentId],
to: targetAgentId,
edgeType: 'direct',
},
{
from: sourceAgentId,
to: [targetAgentId],
edgeType: 'handoff',
},
{
from: [sourceAgentId],
to: [targetAgentId],
edgeType: 'direct',
},
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
description: 'Unrelated edge',
},
]);
});
// Verify the edge is removed from source agent
const sourceAgentAfter = await getAgent({ id: sourceAgentId });
expect(sourceAgentAfter!.edges).toHaveLength(0);
test('should remove every bulk-deleted agent while preserving surviving edge members', async () => {
const deletingAuthorId = new mongoose.Types.ObjectId();
const graphAuthorId = new mongoose.Types.ObjectId();
const firstDeletedId = `agent_${uuidv4()}`;
const secondDeletedId = `agent_${uuidv4()}`;
const graphAgentId = `agent_${uuidv4()}`;
const sourceAgentId = `agent_${uuidv4()}`;
const targetAgentId = `agent_${uuidv4()}`;
await createAgent({
id: firstDeletedId,
name: 'First Bulk-Deleted Agent',
provider: 'test',
model: 'test-model',
author: deletingAuthorId,
});
await createAgent({
id: secondDeletedId,
name: 'Second Bulk-Deleted Agent',
provider: 'test',
model: 'test-model',
author: deletingAuthorId,
});
await createAgent({
id: graphAgentId,
name: 'Bulk Edge Graph',
provider: 'test',
model: 'test-model',
author: graphAuthorId,
edges: [
{
from: [firstDeletedId, sourceAgentId],
to: [secondDeletedId, targetAgentId],
edgeType: 'direct',
},
{
from: firstDeletedId,
to: targetAgentId,
edgeType: 'handoff',
},
{
from: sourceAgentId,
to: [firstDeletedId, secondDeletedId],
edgeType: 'handoff',
},
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
description: 'Unrelated bulk edge',
},
],
});
await deleteUserAgents(deletingAuthorId.toString());
expect(await getAgent({ id: firstDeletedId })).toBeNull();
expect(await getAgent({ id: secondDeletedId })).toBeNull();
const graphAgent = await getAgent({ id: graphAgentId });
expect(graphAgent!.edges).toEqual([
{
from: [sourceAgentId],
to: [targetAgentId],
edgeType: 'direct',
},
{
from: sourceAgentId,
to: targetAgentId,
edgeType: 'handoff',
description: 'Unrelated bulk edge',
},
]);
});
test('should remove agent from user favorites when agent is deleted', async () => {

View file

@ -6,8 +6,8 @@ import {
actionDelimiter,
isActionTool,
} from 'librechat-data-provider';
import type { FilterQuery, Model, PipelineStage, Types } from 'mongoose';
import type { AgentToolResources } from 'librechat-data-provider';
import type { FilterQuery, Model, Types } from 'mongoose';
import type { IAgent, IAclEntry } from '~/types';
import { filterExistingSkillIds } from './skill';
import logger from '~/config/winston';
@ -28,6 +28,84 @@ const TOOL_RESOURCE_KEYS: ReadonlyArray<keyof AgentToolResources> = [
EToolResources.ocr,
];
/** Builds an atomic update that prunes deleted IDs without discarding surviving edge members. */
function createEdgeCleanupPipeline(agentIds: string[]): PipelineStage[] {
const cleanEndpoint = (endpoint: string) => ({
$cond: [
{ $isArray: endpoint },
{
$filter: {
input: endpoint,
as: 'agentId',
cond: { $not: [{ $in: ['$$agentId', agentIds] }] },
},
},
{ $cond: [{ $in: [endpoint, agentIds] }, null, endpoint] },
],
});
const hasEndpoint = (endpoint: string) => ({
$cond: [{ $isArray: endpoint }, { $gt: [{ $size: endpoint }, 0] }, { $ne: [endpoint, null] }],
});
return [
{
$set: {
edges: {
$filter: {
input: {
$map: {
input: { $ifNull: ['$edges', []] },
as: 'edge',
in: {
$let: {
vars: {
cleanedFrom: cleanEndpoint('$$edge.from'),
cleanedTo: cleanEndpoint('$$edge.to'),
},
in: {
$cond: [
{
$and: [hasEndpoint('$$cleanedFrom'), hasEndpoint('$$cleanedTo')],
},
{
$mergeObjects: [
'$$edge',
{
from: '$$cleanedFrom',
to: '$$cleanedTo',
},
],
},
null,
],
},
},
},
},
},
as: 'edge',
cond: { $ne: ['$$edge', null] },
},
},
},
},
];
}
/** Removes deleted agent references from every active graph that contains them. */
async function removeAgentIdsFromEdges(Agent: Model<IAgent>, agentIds: string[]): Promise<void> {
if (agentIds.length === 0) {
return;
}
await Agent.updateMany(
{
$or: [{ 'edges.from': { $in: agentIds } }, { 'edges.to': { $in: agentIds } }],
},
createEdgeCleanupPipeline(agentIds),
);
}
export interface AgentDeps {
/** Removes all ACL permissions for a resource. Injected from PermissionService. */
removeAllPermissions: (params: { resourceType: string; resourceId: unknown }) => Promise<void>;
@ -745,10 +823,7 @@ export function createAgentMethods(
}),
]);
try {
await Agent.updateMany(
{ 'edges.to': (agent as unknown as { id: string }).id },
{ $pull: { edges: { to: (agent as unknown as { id: string }).id } } },
);
await removeAgentIdsFromEdges(Agent, [(agent as unknown as { id: string }).id]);
} catch (error) {
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
}
@ -820,10 +895,7 @@ export function createAgentMethods(
});
try {
await Agent.updateMany(
{ 'edges.to': { $in: agentIds } },
{ $pull: { edges: { to: { $in: agentIds } } } },
);
await removeAgentIdsFromEdges(Agent, agentIds);
} catch (error) {
logger.error('[deleteUserAgents] Error removing agents from handoff edges', error);
}