mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🧭 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
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:
parent
8374b8416a
commit
a53936d273
26 changed files with 3160 additions and 527 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
279
packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts
Normal file
279
packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
185
packages/api/src/agents/handoffPromptKeyCompatibility.ts
Normal file
185
packages/api/src/agents/handoffPromptKeyCompatibility.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue