🧭 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

@ -46,7 +46,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/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
@ -137,7 +137,7 @@
"@types/sanitize-html": "^2.13.0",
"jest": "^30.2.0",
"mongodb-memory-server": "^11.0.1",
"nodemon": "^3.0.3",
"nodemon": "^3.1.14",
"supertest": "^7.1.0"
}
}

View file

@ -8,6 +8,7 @@ const {
agentUpdateSchema,
refreshListAvatars,
collectEdgeAgentIds,
replaceEdgeSourceId,
mergeDeploymentSkillIds,
mergeAgentOcrConversion,
sanitizeModelParameters,
@ -143,18 +144,30 @@ const classifyAgentReferences = async (agentIds, userId, userRole) => {
};
/**
* Validates VIEW access for every agent referenced in `edges`.
* Missing ids are NOT errors here at create time a self-referential
* `from` often names the agent being built, which has no DB record
* yet. Only unauthorized (existing but unviewable) ids are returned.
* Validates that every agent referenced in `edges` exists and is viewable.
* The create path may allow its newly generated self id because that agent
* has not been inserted yet; all other missing references are invalid.
* @param {GraphEdge[]} edges
* @param {string} userId
* @param {string} userRole
* @param {Set<string>} [allowedMissingIds]
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
*/
const validateEdgeAgentAccess = async (edges, userId, userRole) => {
const { unauthorized } = await classifyAgentReferences(
const validateEdgeAgentReferences = async (
edges,
userId,
userRole,
allowedMissingIds = new Set(),
) => {
const { missing, unauthorized } = await classifyAgentReferences(
collectEdgeAgentIds(edges),
userId,
userRole,
);
return unauthorized;
return {
missing: missing.filter((id) => !allowedMissingIds.has(id)),
unauthorized,
};
};
/**
@ -366,6 +379,8 @@ const createAgentHandler = async (req, res) => {
}
const { id: userId, role: userRole } = req.user;
agentData.id = `agent_${nanoid()}`;
agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id);
if (agentData.tool_resources) {
await pruneToolResourceFileIdsForAgent({
@ -376,7 +391,18 @@ const createAgentHandler = async (req, res) => {
}
if (agentData.edges?.length) {
const unauthorized = await validateEdgeAgentAccess(agentData.edges, userId, userRole);
const { missing, unauthorized } = await validateEdgeAgentReferences(
agentData.edges,
userId,
userRole,
new Set([agentData.id]),
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
@ -423,7 +449,6 @@ const createAgentHandler = async (req, res) => {
}
}
agentData.id = `agent_${nanoid()}`;
agentData.author = userId;
agentData.tools = [];
@ -629,9 +654,23 @@ const updateAgentHandler = async (req, res) => {
updateData.avatar = avatarField;
}
if (updateData.edges !== undefined) {
updateData.edges = replaceEdgeSourceId(updateData.edges, '', id);
}
if (updateData.edges?.length) {
const { id: userId, role: userRole } = req.user;
const unauthorized = await validateEdgeAgentAccess(updateData.edges, userId, userRole);
const { missing, unauthorized } = await validateEdgeAgentReferences(
updateData.edges,
userId,
userRole,
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
@ -802,7 +841,7 @@ const updateAgentHandler = async (req, res) => {
*/
const duplicateAgentHandler = async (req, res) => {
const { id } = req.params;
const { id: userId } = req.user;
const { id: userId, role: userRole } = req.user;
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
try {
@ -852,6 +891,29 @@ const duplicateAgentHandler = async (req, res) => {
id: newAgentId,
author: userId,
});
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId);
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId);
if (newAgentData.edges?.length) {
const { missing, unauthorized } = await validateEdgeAgentReferences(
newAgentData.edges,
userId,
userRole,
new Set([newAgentId]),
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
const newActionsList = [];
const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
@ -1274,10 +1336,42 @@ const revertAgentVersionHandler = async (req, res) => {
return res.status(404).json({ error: 'Agent not found' });
}
const revertVersion = existingAgent.versions?.[version_index];
const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : [];
const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id);
const hasLegacyEdgeSource = storedRevertEdges.some((edge) =>
Array.isArray(edge.from) ? edge.from.includes('') : edge.from === '',
);
if (revertEdges.length > 0) {
const { missing, unauthorized } = await validateEdgeAgentReferences(
revertEdges,
req.user.id,
req.user.role,
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
// Permissions are enforced via route middleware (ACL EDIT)
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
const revertUpdates = {};
if (
revertVersion &&
(hasLegacyEdgeSource || (!Array.isArray(revertVersion.edges) && updatedAgent.edges?.length))
) {
revertUpdates.edges = revertEdges;
}
if (updatedAgent.tools?.length) {
const [availableTools, configServers] = await Promise.all([

View file

@ -2475,7 +2475,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
name: 'Attacker Agent',
provider: 'openai',
model: 'gpt-4',
edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }],
};
await createAgentHandler(mockReq, mockRes);
@ -2493,25 +2493,33 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
name: 'Legit Agent',
provider: 'openai',
model: 'gpt-4',
edges: [{ from: 'self_placeholder', to: targetAgent.id, edgeType: 'handoff' }],
edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const response = mockRes.json.mock.calls[0][0];
expect(response.edges).toEqual([
{ from: response.id, to: targetAgent.id, edgeType: 'handoff' },
]);
});
test('createAgentHandler should allow edges referencing non-existent agents (self-reference at create time)', async () => {
test('createAgentHandler should reject a non-existent handoff target', async () => {
mockReq.body = {
name: 'Self-Ref Agent',
name: 'Dangling Edge Agent',
provider: 'openai',
model: 'gpt-4',
edges: [{ from: 'agent_does_not_exist_yet', to: 'agent_also_new', edgeType: 'handoff' }],
edges: [{ from: '', to: 'agent_missing_target', edgeType: 'handoff' }],
};
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'One or more agents referenced in edges do not exist',
agent_ids: ['agent_missing_target'],
});
});
test('updateAgentHandler should return 403 when user lacks VIEW on an edge-referenced agent', async () => {
@ -2540,6 +2548,42 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.agent_ids).not.toContain(ownedAgent.id);
});
test('updateAgentHandler should repair a legacy empty handoff source', async () => {
const ownedAgent = await Agent.create({
id: `agent_${nanoid()}`,
author: mockReq.user.id,
name: 'Legacy Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }],
});
getResourcePermissionsMap.mockResolvedValueOnce(
new Map([
[ownedAgent._id.toString(), PermissionBits.VIEW],
[targetAgent._id.toString(), PermissionBits.VIEW],
]),
);
mockReq.params = { id: ownedAgent.id };
mockReq.body = {
edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }],
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
edges: [{ from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' }],
}),
);
const persisted = await Agent.findOne({ id: ownedAgent.id }).lean();
expect(persisted.edges).toEqual([
{ from: ownedAgent.id, to: targetAgent.id, edgeType: 'handoff' },
]);
});
test('updateAgentHandler should succeed when edges field is absent from payload', async () => {
const ownedAgent = await Agent.create({
id: `agent_${nanoid()}`,
@ -2559,5 +2603,238 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const response = mockRes.json.mock.calls[0][0];
expect(response.name).toBe('Renamed Agent');
});
test('duplicateAgentHandler should move current and legacy handoff sources to the clone', async () => {
const sourceAgentId = `agent_${nanoid()}`;
const secondTarget = await Agent.create({
id: `agent_${nanoid()}`,
author: new mongoose.Types.ObjectId().toString(),
name: 'Second Target Agent',
provider: 'openai',
model: 'gpt-4',
tools: [],
});
const sourceAgent = await Agent.create({
id: sourceAgentId,
author: mockReq.user.id,
name: 'Legacy Clone Source',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [
{ from: sourceAgentId, to: targetAgent.id, edgeType: 'handoff' },
{ from: '', to: secondTarget.id, edgeType: 'handoff' },
],
});
getResourcePermissionsMap.mockResolvedValueOnce(
new Map([
[targetAgent._id.toString(), PermissionBits.VIEW],
[secondTarget._id.toString(), PermissionBits.VIEW],
]),
);
jest.spyOn(require('~/models'), 'getActions').mockResolvedValueOnce([]);
mockReq.params = { id: sourceAgent.id };
await duplicateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const { agent } = mockRes.json.mock.calls[0][0];
expect(agent.edges).toEqual([
{ from: agent.id, to: targetAgent.id, edgeType: 'handoff' },
{ from: agent.id, to: secondTarget.id, edgeType: 'handoff' },
]);
});
test('duplicateAgentHandler should return 400 for a missing handoff target', async () => {
const missingTargetId = `agent_${nanoid()}`;
const sourceAgent = await Agent.create({
id: `agent_${nanoid()}`,
author: mockReq.user.id,
name: 'Stale Clone Source',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: '', to: missingTargetId, edgeType: 'handoff' }],
});
mockReq.params = { id: sourceAgent.id };
await duplicateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'One or more agents referenced in edges do not exist',
agent_ids: [missingTargetId],
});
expect(await Agent.countDocuments()).toBe(2);
});
test('duplicateAgentHandler should return 403 without VIEW access to a handoff target', async () => {
const sourceAgentId = `agent_${nanoid()}`;
const sourceAgent = await Agent.create({
id: sourceAgentId,
author: mockReq.user.id,
name: 'Restricted Clone Source',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: sourceAgentId, to: targetAgent.id, edgeType: 'handoff' }],
});
getResourcePermissionsMap.mockResolvedValueOnce(new Map());
mockReq.params = { id: sourceAgent.id };
await duplicateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: [targetAgent.id],
});
expect(await Agent.countDocuments()).toBe(2);
});
test('revertAgentVersionHandler should clear handoffs when the historical version has none', async () => {
const agentId = `agent_${nanoid()}`;
await Agent.create({
id: agentId,
author: mockReq.user.id,
name: 'Current Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }],
versions: [
{
name: 'Historical Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
},
],
});
mockReq.params = { id: agentId };
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(400);
const persisted = await Agent.findOne({ id: agentId }).lean();
expect(persisted.name).toBe('Historical Router');
expect(persisted.edges).toEqual([]);
});
test('revertAgentVersionHandler should restore accessible historical handoffs', async () => {
const agentId = `agent_${nanoid()}`;
const sourceAgent = await Agent.create({
id: agentId,
author: mockReq.user.id,
name: 'Current Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [],
versions: [
{
name: 'Historical Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: '', to: targetAgent.id, edgeType: 'handoff' }],
},
],
});
getResourcePermissionsMap.mockResolvedValueOnce(
new Map([
[sourceAgent._id.toString(), PermissionBits.VIEW],
[targetAgent._id.toString(), PermissionBits.VIEW],
]),
);
mockReq.params = { id: agentId };
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(400);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
const persisted = await Agent.findOne({ id: agentId }).lean();
expect(persisted.name).toBe('Historical Router');
expect(persisted.edges).toEqual([{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }]);
});
test('revertAgentVersionHandler should return 400 before restoring a missing handoff target', async () => {
const agentId = `agent_${nanoid()}`;
const missingTargetId = `agent_${nanoid()}`;
await Agent.create({
id: agentId,
author: mockReq.user.id,
name: 'Current Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
versions: [
{
name: 'Stale Historical Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: agentId, to: missingTargetId, edgeType: 'handoff' }],
},
],
});
mockReq.params = { id: agentId };
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'One or more agents referenced in edges do not exist',
agent_ids: [missingTargetId],
});
const persisted = await Agent.findOne({ id: agentId }).lean();
expect(persisted.name).toBe('Current Router');
});
test('revertAgentVersionHandler should return 403 before restoring a restricted handoff target', async () => {
const agentId = `agent_${nanoid()}`;
const sourceAgent = await Agent.create({
id: agentId,
author: mockReq.user.id,
name: 'Current Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
versions: [
{
name: 'Restricted Historical Router',
provider: 'openai',
model: 'gpt-4',
tools: [],
edges: [{ from: agentId, to: targetAgent.id, edgeType: 'handoff' }],
},
],
});
getResourcePermissionsMap.mockResolvedValueOnce(
new Map([[sourceAgent._id.toString(), PermissionBits.VIEW]]),
);
mockReq.params = { id: agentId };
mockReq.body = { version_index: 0 };
await revertAgentVersionHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: [targetAgent.id],
});
const persisted = await Agent.findOne({ id: agentId }).lean();
expect(persisted.name).toBe('Current Router');
});
});
});

View file

@ -36,25 +36,50 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
const edges = useMemo(() => field.value ?? [], [field.value]);
const { options, getAgent } = useSelectableAgents({ currentAgentId });
const selectedAgentIds = useMemo(
() => new Set(edges.map((edge) => getTargetAgentId(edge.to))),
[edges],
);
const addAgentOptions = useMemo(
() =>
options.filter(
(option) => typeof option.value === 'string' && !selectedAgentIds.has(option.value),
),
[options, selectedAgentIds],
);
useEffect(() => {
if (newAgentId && edges.length < MAX_HANDOFFS) {
if (!newAgentId) {
return;
}
if (edges.length < MAX_HANDOFFS && !selectedAgentIds.has(newAgentId)) {
const newEdge: GraphEdge = { from: currentAgentId, to: newAgentId, edgeType: 'handoff' };
field.onChange([...edges, newEdge]);
setNewAgentId('');
}
}, [newAgentId, edges, field, currentAgentId]);
setNewAgentId('');
}, [newAgentId, edges, field, currentAgentId, selectedAgentIds]);
const removeHandoffAt = (index: number) => {
field.onChange(edges.filter((_, i) => i !== index));
setExpandedIndices((prev) => {
const next = new Set(prev);
next.delete(index);
return next;
});
setExpandedIndices(
(prev) =>
new Set(
Array.from(prev)
.filter((expandedIndex) => expandedIndex !== index)
.map((expandedIndex) => (expandedIndex > index ? expandedIndex - 1 : expandedIndex)),
),
);
};
const updateHandoffAt = (index: number, agentId: string) => {
const isAlreadySelected = edges.some(
(edge, edgeIndex) => edgeIndex !== index && getTargetAgentId(edge.to) === agentId,
);
if (isAlreadySelected) {
return;
}
const updated = [...edges];
updated[index] = { ...updated[index], to: agentId };
field.onChange(updated);
@ -101,6 +126,11 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
const targetAgentId = getTargetAgentId(edge.to);
const isExpanded = expandedIndices.has(idx);
const targetName = getAgent(targetAgentId)?.name ?? localize('com_ui_agent');
const rowOptions = options.filter(
(option) =>
typeof option.value === 'string' &&
(option.value === targetAgentId || !selectedAgentIds.has(option.value)),
);
return (
<React.Fragment key={idx}>
@ -111,7 +141,7 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
removeLabel={localize('com_ui_agent_handoff_remove', { 0: targetName })}
>
<AgentSelectInline
options={options}
options={rowOptions}
selectedValue={targetAgentId}
onChange={(id) => updateHandoffAt(idx, id)}
displayValue={getAgent(targetAgentId)?.name ?? ''}
@ -207,7 +237,7 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
<>
{edges.length > 0 && <Connector />}
<AddAgentSelect
options={options}
options={addAgentOptions}
onSelect={setNewAgentId}
placeholder={localize('com_ui_agent_handoff_add')}
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}

View file

@ -32,7 +32,7 @@ export default function OrchestrationPattern({
}: OrchestrationPatternProps) {
return (
<HoverCard openDelay={50}>
<div className="flex flex-col gap-3 py-4">
<section aria-label={title} className="flex flex-col gap-3 py-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 flex-shrink-0 text-text-secondary" aria-hidden="true">
@ -52,7 +52,7 @@ export default function OrchestrationPattern({
)}
</div>
{children != null && <div className="flex flex-col gap-3">{children}</div>}
</div>
</section>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">{info}</div>

View file

@ -57,6 +57,7 @@ export default function VersionPanel() {
artifacts: agentWithVersions.artifacts,
capabilities: agentWithVersions.capabilities,
tools: agentWithVersions.tools,
edges: agentWithVersions.edges,
};
}, [agentWithVersions]);

View file

@ -10,6 +10,7 @@ const mockAgentData = {
instructions: 'Test Instructions',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
edges: [{ from: 'agent-123', to: 'agent-specialist', edgeType: 'handoff' }],
};
const mockVersions = [
@ -235,6 +236,7 @@ describe('VersionPanel', () => {
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
edges: [{ from: 'agent-123', to: 'agent-specialist', edgeType: 'handoff' }],
}),
versions: expect.arrayContaining([
expect.objectContaining({ name: 'Version 2' }),

View file

@ -72,6 +72,36 @@ describe('isActiveVersion', () => {
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns false when handoff edges do not match', () => {
const version = createVersion({
edges: [{ from: 'router', to: 'researcher', edgeType: 'handoff' }],
});
const currentAgent = createAgentState({
edges: [{ from: 'router', to: 'writer', edgeType: 'handoff' }],
});
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(false);
});
test('returns true when handoff edges match', () => {
const edges = [
{
from: 'router',
to: 'researcher',
edgeType: 'handoff',
description: 'Delegate research',
prompt: 'Provide the research brief',
promptKey: 'context',
},
];
const version = createVersion({ edges });
const currentAgent = createAgentState({ edges: edges.map((edge) => ({ ...edge })) });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('matches tools regardless of order', () => {
const version = createVersion({ tools: ['tool1', 'tool2'] });
const currentAgent = createAgentState({ tools: ['tool2', 'tool1'] });
@ -203,6 +233,14 @@ describe('isActiveVersion', () => {
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('treats missing and empty handoff edges as equivalent', () => {
const version = createVersion({ edges: undefined });
const currentAgent = createAgentState({ edges: [] });
const versions = [version];
expect(isActiveVersion(version, currentAgent, versions)).toBe(true);
});
test('handles missing artifacts field', () => {
const version = createVersion({ artifacts: undefined });
const currentAgent = createAgentState({ artifacts: undefined });

View file

@ -1,5 +1,10 @@
import isEqual from 'lodash/isEqual';
import type { GraphEdge } from 'librechat-data-provider';
import type { AgentState, VersionRecord } from './types';
const edgesMatch = (versionEdges?: GraphEdge[], currentEdges?: GraphEdge[]): boolean =>
isEqual(versionEdges ?? [], currentEdges ?? []);
export const isActiveVersion = (
version: VersionRecord,
currentAgent: AgentState,
@ -23,6 +28,7 @@ export const isActiveVersion = (
const matchesDescription = version.description === currentAgent.description;
const matchesInstructions = version.instructions === currentAgent.instructions;
const matchesArtifacts = version.artifacts === currentAgent.artifacts;
const matchesEdges = edgesMatch(version.edges, currentAgent.edges);
const toolsMatch = () => {
if (!version.tools && !currentAgent.tools) return true;
@ -53,6 +59,7 @@ export const isActiveVersion = (
matchesDescription &&
matchesInstructions &&
matchesArtifacts &&
matchesEdges &&
toolsMatch() &&
capabilitiesMatch()
);

View file

@ -1,3 +1,5 @@
import type { GraphEdge } from 'librechat-data-provider';
export type VersionRecord = Record<string, any>;
export type AgentState = {
@ -7,6 +9,7 @@ export type AgentState = {
artifacts?: string | null;
capabilities?: string[];
tools?: string[];
edges?: GraphEdge[];
} | null;
export type VersionWithId = {
@ -31,5 +34,6 @@ export interface AgentWithVersions {
artifacts?: string | null;
capabilities?: string[];
tools?: string[];
edges?: GraphEdge[];
versions?: Array<VersionRecord>;
}

View file

@ -0,0 +1,113 @@
import { createElement } from 'react';
import { dataService, QueryKeys } from 'librechat-data-provider';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Agent, GraphEdge } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { useDeleteAgentMutation } from '../mutations';
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
...actual,
dataService: {
...actual.dataService,
deleteAgent: jest.fn(),
},
};
});
const createAgent = (id: string, edges: GraphEdge[] = []): Agent => ({
id,
name: id,
description: null,
created_at: 0,
avatar: null,
provider: 'openAI',
model: 'test-model',
model_parameters: {
temperature: null,
maxContextTokens: null,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
edges,
});
const createWrapper = (queryClient: QueryClient) =>
function Wrapper({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client: queryClient }, children);
};
describe('useDeleteAgentMutation', () => {
it('refreshes only expanded agent caches with edges that reference the deleted agent', async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const targetId = 'agent_target';
const affectedId = 'agent_affected';
const affectedSourceId = 'agent_affected_source';
const unrelatedId = 'agent_unrelated';
const affectedQueryKey = [QueryKeys.agent, affectedId, 'expanded'];
const affectedSourceQueryKey = [QueryKeys.agent, affectedSourceId, 'expanded'];
const unrelatedQueryKey = [QueryKeys.agent, unrelatedId, 'expanded'];
const staleAffectedAgent = createAgent(affectedId, [
{ from: affectedId, to: targetId, edgeType: 'handoff' },
]);
const refreshedAffectedAgent = createAgent(affectedId);
const staleAffectedSourceAgent = createAgent(affectedSourceId, [
{
from: [targetId, 'agent_surviving_source'],
to: affectedSourceId,
edgeType: 'handoff',
},
]);
const refreshedAffectedSourceAgent = createAgent(affectedSourceId, [
{ from: 'agent_surviving_source', to: affectedSourceId, edgeType: 'handoff' },
]);
const unrelatedAgent = createAgent(unrelatedId, [
{ from: unrelatedId, to: 'agent_other', edgeType: 'handoff' },
]);
const affectedFetch = jest
.fn<Promise<Agent>, []>()
.mockResolvedValueOnce(staleAffectedAgent)
.mockResolvedValue(refreshedAffectedAgent);
const affectedSourceFetch = jest
.fn<Promise<Agent>, []>()
.mockResolvedValueOnce(staleAffectedSourceAgent)
.mockResolvedValue(refreshedAffectedSourceAgent);
const unrelatedFetch = jest.fn<Promise<Agent>, []>().mockResolvedValue(unrelatedAgent);
await queryClient.prefetchQuery(affectedQueryKey, affectedFetch);
await queryClient.prefetchQuery(affectedSourceQueryKey, affectedSourceFetch);
await queryClient.prefetchQuery(unrelatedQueryKey, unrelatedFetch);
queryClient.setQueryData([QueryKeys.agent, targetId], createAgent(targetId));
queryClient.setQueryData([QueryKeys.agent, targetId, 'expanded'], createAgent(targetId));
jest.mocked(dataService.deleteAgent).mockResolvedValue();
const { result } = renderHook(() => useDeleteAgentMutation(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync({ agent_id: targetId });
});
await waitFor(() => expect(affectedFetch).toHaveBeenCalledTimes(2));
await waitFor(() => expect(affectedSourceFetch).toHaveBeenCalledTimes(2));
expect(queryClient.getQueryData(affectedQueryKey)).toEqual(refreshedAffectedAgent);
expect(queryClient.getQueryData(affectedSourceQueryKey)).toEqual(refreshedAffectedSourceAgent);
expect(unrelatedFetch).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryData(unrelatedQueryKey)).toEqual(unrelatedAgent);
expect(queryClient.getQueryData([QueryKeys.agent, targetId])).toBeUndefined();
expect(queryClient.getQueryData([QueryKeys.agent, targetId, 'expanded'])).toBeUndefined();
});
});

View file

@ -11,6 +11,25 @@ export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
{ requiredPermission: PermissionBits.EDIT },
];
const edgeEndpointIncludesAgent = (endpoint: string | string[], agentId: string): boolean =>
Array.isArray(endpoint) ? endpoint.includes(agentId) : endpoint === agentId;
const hasEdgeWithAgent = (data: unknown, agentId: string): boolean => {
if (!data || typeof data !== 'object') {
return false;
}
const { edges } = data as Partial<t.Agent>;
return (
Array.isArray(edges) &&
edges.some(
(edge) =>
edgeEndpointIncludesAgent(edge.from, agentId) ||
edgeEndpointIncludesAgent(edge.to, agentId),
)
);
};
/**
* Create a new agent
*/
@ -132,6 +151,15 @@ export const useDeleteAgentMutation = (
queryClient.removeQueries([QueryKeys.agent, variables.agent_id]);
queryClient.removeQueries([QueryKeys.agent, variables.agent_id, 'expanded']);
/** Deletion removes the agent from every edge endpoint server-side. Expanded queries
* opt out of refetch-on-mount, so refresh every cached graph known to reference it. */
queryClient.invalidateQueries({
queryKey: [QueryKeys.agent],
predicate: (query) =>
query.queryKey[2] === 'expanded' &&
hasEdgeWithAgent(query.state.data, variables.agent_id),
refetchType: 'all',
});
invalidateAgentMarketplaceQueries(queryClient);
return options?.onSuccess?.(_data, variables, data);

View file

@ -38,6 +38,8 @@ const TOOL_APPROVAL_MARKER = 'E2E_TOOL_APPROVAL:';
const TOOL_APPROVAL_BATCH_MARKER = 'E2E_TOOL_APPROVAL_BATCH:';
const TOOL_APPROVAL_RESTRICTED_MARKER = 'E2E_TOOL_APPROVAL_RESTRICTED:';
const TOOL_APPROVAL_REWRITE_MARKER = 'E2E_TOOL_APPROVAL_REWRITE:';
const HANDOFF_MARKER = 'E2E_HANDOFF:';
const HANDOFF_TOOL_PREFIX = 'lc_transfer_to_';
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
const SKILL_ASSERTION_FINAL_TEXT = 'E2E skill assertion passed';
@ -440,12 +442,42 @@ function replyResponses(text) {
* streaming pattern) so token-usage SSE events flow end to end in mock runs.
*/
class UsageEmittingFakeChatModel extends FakeChatModel {
constructor({ resolveOnStream, sleep, ...options }) {
constructor({ resolveInvocation, resolveOnStream, sleep, ...options }) {
super({ ...options, sleep });
this.resolveInvocation = resolveInvocation;
this.resolveOnStream = resolveOnStream;
this.streamSleep = sleep ?? CHUNK_DELAY_MS;
}
async *streamScriptedResponseChunks({ response, toolCalls, runManager }) {
if (this.emitCustomEvent) {
await runManager?.handleCustomEvent('some_test_event', {
someval: true,
});
}
const chunks = response ? response.split(/(?<=\s+)|(?=\s+)/) : [];
for await (const chunk of chunks) {
await new Promise((resolve) => setTimeout(resolve, this.streamSleep));
const responseChunk = this._createResponseChunk(chunk);
yield responseChunk;
void runManager?.handleLLMNewToken(chunk);
}
if (toolCalls?.length) {
await new Promise((resolve) => setTimeout(resolve, this.streamSleep));
const toolCallChunks = toolCalls.map((toolCall, index) => ({
name: toolCall.name,
args: JSON.stringify(toolCall.args),
id: toolCall.id,
index,
type: 'tool_call_chunk',
}));
yield this._createResponseChunk('', toolCallChunks);
void runManager?.handleLLMNewToken('');
}
}
async *streamDynamicResponseChunks({ responses, options, runManager }) {
if (this.emitCustomEvent) {
await runManager?.handleCustomEvent('some_test_event', {
@ -470,14 +502,26 @@ class UsageEmittingFakeChatModel extends FakeChatModel {
async *_streamResponseChunks(messages, options, runManager) {
let outputChars = 0;
const dynamicResponse = await this.resolveOnStream?.(messages, options, runManager);
const chunkStream = dynamicResponse
? this.streamDynamicResponseChunks({
responses: dynamicResponse.responses,
options,
runManager,
})
: super._streamResponseChunks(messages, options, runManager);
const scriptedResponse = await this.resolveInvocation?.(messages, options, runManager);
const dynamicResponse = scriptedResponse
? null
: await this.resolveOnStream?.(messages, options, runManager);
let chunkStream;
if (scriptedResponse) {
chunkStream = this.streamScriptedResponseChunks({
response: scriptedResponse.response ?? '',
toolCalls: scriptedResponse.toolCalls,
runManager,
});
} else if (dynamicResponse) {
chunkStream = this.streamDynamicResponseChunks({
responses: dynamicResponse.responses,
options,
runManager,
});
} else {
chunkStream = super._streamResponseChunks(messages, options, runManager);
}
for await (const chunk of chunkStream) {
outputChars += typeof chunk.text === 'string' ? chunk.text.length : 0;
@ -499,13 +543,22 @@ class UsageEmittingFakeChatModel extends FakeChatModel {
}
}
function overrideModel({ graph, responses, sleep, toolCalls, thrownError, resolveOnStream }) {
function overrideModel({
graph,
responses,
sleep,
toolCalls,
thrownError,
resolveInvocation,
resolveOnStream,
}) {
if (!thrownError) {
graph.overrideModel = new UsageEmittingFakeChatModel({
responses,
sleep: sleep ?? CHUNK_DELAY_MS,
emitCustomEvent: true,
toolCalls,
resolveInvocation,
resolveOnStream,
});
return;
@ -1016,6 +1069,342 @@ function backgroundCollectResponses(messages, toolNames) {
};
}
function parseHandoffScript(text) {
const encodedScript = getMarkerValue(text, HANDOFF_MARKER);
if (!encodedScript) {
return null;
}
let value;
try {
value = JSON.parse(Buffer.from(encodedScript, 'base64url').toString('utf8'));
} catch (error) {
return {
error: `could not decode marker (${error instanceof Error ? error.message : 'unknown error'})`,
};
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { error: 'script must be an object' };
}
if (typeof value.label !== 'string' || value.label.trim() === '') {
return { error: 'script.label must be a non-empty string' };
}
if (!Array.isArray(value.routes) || value.routes.length === 0) {
return { error: 'script.routes must be a non-empty array' };
}
const routes = [];
for (const [index, route] of value.routes.entries()) {
if (!route || typeof route !== 'object' || Array.isArray(route)) {
return { error: `script.routes[${index}] must be an object` };
}
if (typeof route.from !== 'string' || route.from === '') {
return { error: `script.routes[${index}].from must be a non-empty string` };
}
if (typeof route.to !== 'string' || route.to === '') {
return { error: `script.routes[${index}].to must be a non-empty string` };
}
if (route.args != null && (typeof route.args !== 'object' || Array.isArray(route.args))) {
return { error: `script.routes[${index}].args must be an object` };
}
if (route.description != null && typeof route.description !== 'string') {
return { error: `script.routes[${index}].description must be a string` };
}
if (route.prompt != null && typeof route.prompt !== 'string') {
return { error: `script.routes[${index}].prompt must be a string` };
}
if (route.promptKey != null && typeof route.promptKey !== 'string') {
return { error: `script.routes[${index}].promptKey must be a string` };
}
if (route.receipt != null && typeof route.receipt !== 'string') {
return { error: `script.routes[${index}].receipt must be a string` };
}
if (route.targetInstructions != null && typeof route.targetInstructions !== 'string') {
return { error: `script.routes[${index}].targetInstructions must be a string` };
}
if (
route.targetTools != null &&
(!Array.isArray(route.targetTools) ||
route.targetTools.some((toolName) => typeof toolName !== 'string' || toolName === ''))
) {
return {
error: `script.routes[${index}].targetTools must be an array of non-empty strings`,
};
}
const args = route.args ?? {};
let inferredReceipt = null;
if (typeof args.instructions === 'string') {
inferredReceipt = args.instructions;
} else if (typeof args.context === 'string') {
inferredReceipt = args.context;
}
routes.push({
from: route.from,
to: route.to,
description: route.description,
prompt: route.prompt,
promptKey: route.promptKey,
args,
receipt: route.receipt ?? inferredReceipt,
targetInstructions: route.targetInstructions,
targetTools: route.targetTools ?? [],
});
}
return {
script: {
label: value.label.trim(),
routes,
},
};
}
function getGraphTools(agentContext) {
const result = new Map();
const tools =
typeof agentContext?.getToolsForBinding === 'function'
? agentContext.getToolsForBinding()
: agentContext?.graphTools;
for (const tool of tools ?? []) {
if (typeof tool?.name === 'string') {
result.set(tool.name, tool);
}
}
return result;
}
function validateHandoffTool(route, tool, toolName) {
const failures = [];
const expectedDescription = route.description ?? `Transfer control to agent '${route.to}'`;
if (tool.description !== expectedDescription) {
failures.push(
`${toolName} description mismatch (expected "${expectedDescription}", received "${tool.description ?? ''}")`,
);
}
const schema = tool.schema;
const properties =
schema &&
typeof schema === 'object' &&
!Array.isArray(schema) &&
schema.properties &&
typeof schema.properties === 'object' &&
!Array.isArray(schema.properties)
? schema.properties
: null;
if (!properties) {
failures.push(`${toolName} did not expose an object properties schema`);
return failures;
}
const propertyNames = Object.keys(properties);
if (route.prompt == null) {
if (propertyNames.length > 0) {
failures.push(
`${toolName} unexpectedly advertised input properties: ${propertyNames.join(', ')}`,
);
}
return failures;
}
const expectedPromptKey = route.promptKey ?? 'instructions';
const promptProperty = properties[expectedPromptKey];
if (!promptProperty || typeof promptProperty !== 'object' || Array.isArray(promptProperty)) {
failures.push(`${toolName} did not advertise the "${expectedPromptKey}" input property`);
return failures;
}
if (propertyNames.length !== 1) {
failures.push(
`${toolName} advertised unexpected input properties: ${propertyNames.join(', ')}`,
);
}
if (promptProperty.type !== 'string') {
failures.push(`${toolName}.${expectedPromptKey} was not a string input`);
}
if (promptProperty.description !== route.prompt) {
failures.push(
`${toolName}.${expectedPromptKey} description mismatch (expected "${route.prompt}", received "${promptProperty.description ?? ''}")`,
);
}
if (Array.isArray(schema.required) && schema.required.length > 0) {
failures.push(`${toolName} unexpectedly required optional handoff input`);
}
return failures;
}
function validateHandoffScript(graph, script) {
const failures = [];
for (const route of script.routes) {
const agentContext = graph.agentContexts?.get(route.from);
if (!agentContext) {
failures.push(`source agent ${route.from} was not loaded`);
continue;
}
const toolName = `${HANDOFF_TOOL_PREFIX}${route.to}`;
const tool = getGraphTools(agentContext).get(toolName);
if (!tool) {
failures.push(`${toolName} was not advertised by source agent ${route.from}`);
continue;
}
failures.push(...validateHandoffTool(route, tool, toolName));
}
return failures;
}
function getAgentIdFromInvocationOptions(options, runManager) {
const metadataCandidates = [
options?.metadata,
options?.configurable,
runManager?.metadata,
runManager?.inheritableMetadata,
];
for (const metadata of metadataCandidates) {
const node = metadata?.langgraph_node;
if (typeof node === 'string' && node.startsWith('agent=')) {
return node.slice('agent='.length);
}
}
return null;
}
async function validateHandoffReception(graph, script, route, messages) {
const sourceContext = graph.agentContexts?.get(route.from);
const targetContext = graph.agentContexts?.get(route.to);
const sourceName = sourceContext?.name ?? route.from;
const targetName = targetContext?.name ?? route.to;
const promptMessages = targetContext?.systemRunnable
? await targetContext.systemRunnable.invoke(messages ?? [])
: (messages ?? []);
const promptText = promptMessages
.map((message) => getContentText(message?.content))
.filter(Boolean)
.join('\n');
const failures = [];
const identityPreamble = `You are "${targetName}", transferred from "${sourceName}".`;
if (!promptText.includes(identityPreamble)) {
failures.push(`missing identity preamble: ${identityPreamble}`);
}
const siblingNames = Array.from(
new Set(
script.routes
.filter((candidate) => candidate !== route && candidate.from === route.from)
.map((candidate) => graph.agentContexts?.get(candidate.to)?.name ?? candidate.to),
),
);
const parallelPreamble = 'Running in parallel with:';
if (siblingNames.length === 0 && promptText.includes(parallelPreamble)) {
failures.push('unexpected parallel sibling preamble');
}
if (
siblingNames.length > 0 &&
!promptText.includes(`${parallelPreamble} ${siblingNames.join(', ')}.`)
) {
failures.push(`missing parallel sibling preamble for ${siblingNames.join(', ')}`);
}
if (route.targetInstructions && !promptText.includes(route.targetInstructions)) {
failures.push(`missing target instructions: ${route.targetInstructions}`);
}
const sourceTools = getGraphTools(sourceContext);
const targetTools = getGraphTools(targetContext);
for (const toolName of route.targetTools) {
if (!targetTools.has(toolName)) {
failures.push(`target agent ${route.to} did not receive its configured tool ${toolName}`);
}
if (route.from !== route.to && sourceTools.has(toolName)) {
failures.push(`target-only tool ${toolName} leaked to source agent ${route.from}`);
}
}
return failures;
}
function buildHandoffResponses(graph, parsed) {
if (parsed.error) {
return {
responses: [`E2E handoff script invalid: ${parsed.error}`],
};
}
const { script } = parsed;
const failures = validateHandoffScript(graph, script);
if (failures.length > 0) {
return {
responses: [`E2E handoff unavailable: ${failures.join('; ')}`],
};
}
let invocationCount = 0;
return {
responses: [''],
resolveInvocation: async (messages, options, runManager) => {
const latestUserText = getLatestUserText(messages).trim();
const agentId = getAgentIdFromInvocationOptions(options, runManager);
let incomingRoute = script.routes.find(
(route) => route.receipt != null && latestUserText === route.receipt.trim(),
);
if (!agentId) {
return {
response: `E2E handoff routing failed ${script.label}: missing SDK langgraph_node metadata`,
};
}
invocationCount += 1;
const incomingRoutes = script.routes.filter((route) => route.to === agentId);
if (!incomingRoute && incomingRoutes.length === 1) {
incomingRoute = incomingRoutes[0];
}
if (incomingRoute?.receipt != null && latestUserText !== incomingRoute.receipt.trim()) {
return {
response:
`E2E handoff receipt failed ${script.label}: agent=${agentId}; ` +
`expected=${incomingRoute.receipt}; received=${latestUserText || '(empty)'}`,
};
}
if (incomingRoute) {
const receptionFailures = await validateHandoffReception(
graph,
script,
incomingRoute,
messages,
);
if (receptionFailures.length > 0) {
return {
response:
`E2E handoff reception failed ${script.label}: agent=${agentId}; ` +
receptionFailures.join('; '),
};
}
}
const outgoingRoutes = script.routes.filter((route) => route.from === agentId);
if (outgoingRoutes.length === 0) {
const received =
incomingRoute?.receipt == null ? '(no injected handoff content)' : latestUserText;
return {
response: `E2E handoff complete ${script.label}: agent=${agentId}; received=${received}`,
};
}
return {
response: `E2E handoff continuing ${script.label}: agent=${agentId}`,
toolCalls: outgoingRoutes.map((route, index) => ({
id: `call_e2e_handoff_${invocationCount}_${index}_${route.to}`,
name: `${HANDOFF_TOOL_PREFIX}${route.to}`,
args: route.args,
type: 'tool_call',
})),
};
},
};
}
function resolveResponses({ graph, messages, text, toolNames }) {
const batchApprovalLabel = getMarkerValue(text, TOOL_APPROVAL_BATCH_MARKER);
if (batchApprovalLabel) {
@ -1161,18 +1550,23 @@ module.exports = function fakeModelHook(run, context) {
const text = getLatestUserText(context?.messages);
const toolNames = collectToolNames(context?.agents);
const { responses, sleep, toolCalls, thrownError, resolveOnStream } = resolveResponses({
graph,
messages: context?.messages,
text,
toolNames,
});
const handoffScript = parseHandoffScript(text);
const { responses, sleep, toolCalls, thrownError, resolveInvocation, resolveOnStream } =
handoffScript
? buildHandoffResponses(graph, handoffScript)
: resolveResponses({
graph,
messages: context?.messages,
text,
toolNames,
});
overrideModel({
graph,
responses,
sleep,
toolCalls,
thrownError,
resolveInvocation,
resolveOnStream: (streamMessages, streamOptions, runManager) =>
approvalOutcomeResponses(streamMessages) ??
resolveOnStream?.(streamMessages, streamOptions, runManager) ??

View file

@ -0,0 +1,993 @@
import { expect, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import type { GraphEdge } from 'librechat-data-provider';
import type { AgentDetail } from './agents.helpers';
import { cleanupAgent, openAgentBuilder, selectMockModel, uniqueAgentName } from './agents.helpers';
import {
MOCK_ENDPOINTS,
fetchJson,
getAccessToken,
messagesView,
requestJson,
sendMessage,
} from './helpers';
const DESCRIPTION = 'Created by the mock end-to-end suite to verify agent handoffs.';
const INSTRUCTIONS = 'Follow the deterministic handoff instructions from the mock model.';
const HANDOFF_DESCRIPTION = 'Delegate requests that require specialist handling.';
const HANDOFF_PROMPT = 'Pass the specialist the exact request and relevant constraints.';
const HANDOFF_PROMPT_KEY = 'context';
const MCP_SERVER_TOOL_ID = 'sys__server__sys_mcp_e2e-memory';
const MCP_TOOL_ID = 'remember_fact_mcp_e2e-memory';
const MCP_SERVER_NAME = 'e2e-memory';
type HandoffRoute = {
from: string;
to: string;
description?: string;
prompt?: string;
promptKey?: string;
args?: Record<string, unknown>;
receipt?: string;
targetInstructions?: string;
targetTools?: string[];
};
type MCPToolsResponse = {
servers?: Record<string, { tools?: Array<{ pluginKey: string }> }>;
};
const handoffMarker = (label: string, routes: HandoffRoute[]) =>
`E2E_HANDOFF:${Buffer.from(JSON.stringify({ label, routes })).toString('base64url')}`;
async function waitForMCPTool(page: Page, token: string): Promise<void> {
let latestTools: MCPToolsResponse | null = null;
for (let attempt = 0; attempt < 20; attempt++) {
latestTools = await fetchJson<MCPToolsResponse>(page, '/api/mcp/tools', token);
const tools = latestTools.servers?.[MCP_SERVER_NAME]?.tools ?? [];
if (tools.some((tool) => tool.pluginKey === MCP_TOOL_ID)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(
latestTools?.servers?.[MCP_SERVER_NAME]?.tools,
`Expected ${MCP_SERVER_NAME} to expose ${MCP_TOOL_ID}`,
).toEqual(expect.arrayContaining([expect.objectContaining({ pluginKey: MCP_TOOL_ID })]));
}
async function startNewAgent(page: Page): Promise<Locator> {
let form = await openAgentBuilder(page);
const createNewButton = form.getByRole('button', { name: 'Create New Agent' });
if (await createNewButton.isVisible().catch(() => false)) {
await createNewButton.click();
form = page.getByRole('form', { name: 'Agent configuration form' });
}
await expect(form.getByRole('button', { name: 'Create', exact: true })).toBeVisible();
return form;
}
async function configureNewAgent(page: Page, name: string): Promise<Locator> {
let form = await startNewAgent(page);
await form.getByLabel('Agent name').fill(name);
await form.getByLabel('Agent description').fill(DESCRIPTION);
await form.getByLabel('Instructions').fill(INSTRUCTIONS);
await selectMockModel(page, true);
form = page.getByRole('form', { name: 'Agent configuration form' });
return form;
}
async function createConfiguredAgent(form: Locator): Promise<AgentDetail> {
const page = form.page();
const [response] = await Promise.all([
page.waitForResponse(
(candidate) =>
candidate.request().method() === 'POST' &&
new URL(candidate.url()).pathname === '/api/agents' &&
candidate.status() === 201,
{ timeout: 30000 },
),
form.getByRole('button', { name: 'Create', exact: true }).click(),
]);
return (await response.json()) as AgentDetail;
}
async function createAgentViaApi(
page: Page,
token: string,
name: string,
edges?: GraphEdge[],
overrides: { instructions?: string; tools?: string[] } = {},
): Promise<AgentDetail> {
return requestJson<AgentDetail>(page, {
path: '/api/agents',
token,
method: 'POST',
body: {
name,
description: DESCRIPTION,
instructions: INSTRUCTIONS,
provider: MOCK_ENDPOINTS[0].label,
model: MOCK_ENDPOINTS[0].model,
edges,
...overrides,
},
});
}
async function selectAgentForChat(page: Page, agentName: string): Promise<void> {
const form = await openAgentBuilder(page);
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: agentName }).click();
await expect(form.getByLabel('Agent name')).toHaveValue(agentName);
await form.getByRole('button', { name: 'Select Agent' }).click();
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible();
}
async function cleanupAgents(
page: Page,
token: string,
agentIds: Array<string | undefined>,
): Promise<void> {
for (const agentId of agentIds.reverse()) {
if (!agentId) {
continue;
}
await requestJson(page, {
path: `/api/agents/${encodeURIComponent(agentId)}`,
token,
method: 'DELETE',
}).catch(() => undefined);
}
}
test.describe('agent handoffs', () => {
test.describe.configure({ timeout: 60_000 });
test('creates and runs a router with handoffs selected before the router exists', async ({
page,
}) => {
test.setTimeout(180000);
const specialistName = uniqueAgentName('E2E Handoff Specialist');
const bareSpecialistName = uniqueAgentName('E2E Bare Handoff Specialist');
const routerName = uniqueAgentName('E2E Handoff Router');
let specialistId: string | undefined;
let bareSpecialistId: string | undefined;
let routerId: string | undefined;
try {
const specialistForm = await configureNewAgent(page, specialistName);
const specialist = await createConfiguredAgent(specialistForm);
specialistId = specialist.id;
const bareSpecialistForm = await configureNewAgent(page, bareSpecialistName);
const bareSpecialist = await createConfiguredAgent(bareSpecialistForm);
bareSpecialistId = bareSpecialist.id;
const routerForm = await configureNewAgent(page, routerName);
await routerForm.getByRole('button', { name: 'Advanced' }).click();
const handoffs = routerForm.getByRole('region', { name: 'Handoffs' });
await expect(handoffs).toBeVisible();
await handoffs.getByRole('combobox', { name: 'Add agent' }).click();
await page.getByRole('option', { name: specialistName }).click();
await expect(handoffs.getByText('1 / 10', { exact: true })).toBeVisible();
await handoffs.getByRole('button', { name: 'Expand' }).click();
await handoffs.getByLabel('Handoff description').fill(HANDOFF_DESCRIPTION);
await handoffs.getByLabel('Passthrough content').fill(HANDOFF_PROMPT);
await handoffs
.getByLabel("Content parameter name (default: 'instructions')")
.fill(HANDOFF_PROMPT_KEY);
await handoffs.getByRole('combobox', { name: 'Add agent' }).click();
await page.getByRole('option', { name: bareSpecialistName }).click();
await expect(handoffs.getByText('2 / 10', { exact: true })).toBeVisible();
const router = await createConfiguredAgent(routerForm);
routerId = router.id;
const token = await getAccessToken(page);
const persisted = await fetchJson<AgentDetail>(
page,
`/api/agents/${encodeURIComponent(router.id)}/expanded`,
token,
);
expect(persisted.edges).toEqual([
{
from: router.id,
to: specialist.id,
edgeType: 'handoff',
description: HANDOFF_DESCRIPTION,
prompt: HANDOFF_PROMPT,
promptKey: HANDOFF_PROMPT_KEY,
},
{
from: router.id,
to: bareSpecialist.id,
edgeType: 'handoff',
},
]);
const reopenedForm = await openAgentBuilder(page);
await reopenedForm.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: routerName }).click();
await reopenedForm.getByRole('button', { name: 'Advanced' }).click();
const reopenedHandoffs = reopenedForm.getByRole('region', { name: 'Handoffs' });
await expect(reopenedHandoffs.getByText('2 / 10', { exact: true })).toBeVisible();
const reopenedDestinations = reopenedHandoffs.getByRole('combobox', {
name: 'Select agent',
});
await expect(reopenedDestinations).toHaveCount(2);
await expect(reopenedDestinations.first()).toContainText(specialistName);
await expect(reopenedDestinations.last()).toContainText(bareSpecialistName);
await reopenedHandoffs.getByRole('button', { name: 'Expand' }).first().click();
await expect(reopenedHandoffs.getByLabel('Handoff description')).toHaveValue(
HANDOFF_DESCRIPTION,
);
await expect(reopenedHandoffs.getByLabel('Passthrough content')).toHaveValue(HANDOFF_PROMPT);
await expect(
reopenedHandoffs.getByLabel("Content parameter name (default: 'instructions')"),
).toHaveValue(HANDOFF_PROMPT_KEY);
await reopenedForm.getByRole('button', { name: 'Select Agent' }).click();
await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible();
const label = `scratch-bare-${Date.now()}`;
const response = await sendMessage(
page,
handoffMarker(label, [
{
from: router.id,
to: bareSpecialist.id,
args: {},
},
]),
);
expect(response.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${bareSpecialist.id}; received=(no injected handoff content)`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByRole('button', {
name: `Transferred to ${bareSpecialistName}`,
}),
).toBeDisabled();
} finally {
await cleanupAgent(page, routerId);
await cleanupAgent(page, bareSpecialistId);
await cleanupAgent(page, specialistId);
}
});
test('moves copied handoffs from the original router to its duplicate', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const targetName = uniqueAgentName('E2E Handoff Clone Target');
const routerName = uniqueAgentName('E2E Handoff Clone Router');
let targetId: string | undefined;
let routerId: string | undefined;
let cloneId: string | undefined;
try {
const target = await createAgentViaApi(page, token, targetName);
targetId = target.id;
const router = await createAgentViaApi(page, token, routerName, [
{
from: '',
to: target.id,
edgeType: 'handoff',
description: 'Delegate clone work',
prompt: 'Preserve this payload',
promptKey: 'instructions',
},
]);
routerId = router.id;
const duplicate = await requestJson<{ agent: AgentDetail }>(page, {
path: `/api/agents/${encodeURIComponent(router.id)}/duplicate`,
token,
method: 'POST',
});
cloneId = duplicate.agent.id;
expect(duplicate.agent.edges).toEqual([
{
from: duplicate.agent.id,
to: target.id,
edgeType: 'handoff',
description: 'Delegate clone work',
prompt: 'Preserve this payload',
promptKey: 'instructions',
},
]);
} finally {
await cleanupAgent(page, cloneId);
await cleanupAgent(page, routerId);
await cleanupAgent(page, targetId);
}
});
test('edits, saves, reopens, and restores handoff versions without duplicate destinations', async ({
page,
}) => {
test.setTimeout(240000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const firstName = uniqueAgentName('E2E Editable Handoff First');
const secondName = uniqueAgentName('E2E Editable Handoff Second');
const thirdName = uniqueAgentName('E2E Editable Handoff Third');
const routerName = uniqueAgentName('E2E Editable Handoff Router');
const createdIds: string[] = [];
let routerId: string | undefined;
try {
const first = await createAgentViaApi(page, token, firstName);
const second = await createAgentViaApi(page, token, secondName);
const third = await createAgentViaApi(page, token, thirdName);
createdIds.push(first.id, second.id, third.id);
const routerForm = await configureNewAgent(page, routerName);
await routerForm.getByRole('button', { name: 'Advanced' }).click();
const handoffs = routerForm.getByRole('region', { name: 'Handoffs' });
const addAgent = handoffs.getByRole('combobox', { name: 'Add agent' });
await addAgent.click();
await page.getByRole('option', { name: firstName }).click();
await addAgent.click();
await expect(page.getByRole('option', { name: firstName })).toHaveCount(0);
await page.getByRole('option', { name: secondName }).click();
await expect(handoffs.getByText('2 / 10', { exact: true })).toBeVisible();
const expandButtons = handoffs.getByRole('button', { name: 'Expand' });
await expandButtons.first().click();
await expandButtons.first().click();
await handoffs
.getByLabel('Handoff description')
.nth(1)
.fill('The surviving expanded handoff');
await handoffs.getByRole('button', { name: `Remove handoff to ${firstName}` }).click();
await expect(handoffs.getByText('1 / 10', { exact: true })).toBeVisible();
await expect(handoffs.getByText(secondName, { exact: true })).toBeVisible();
await expect(handoffs.getByLabel('Handoff description')).toHaveValue(
'The surviving expanded handoff',
);
const destination = handoffs.getByRole('combobox', { name: 'Select agent' });
await destination.click();
const destinationDialog = page.getByRole('dialog', { name: 'Select agent' }).last();
await expect(destinationDialog.getByRole('option', { name: firstName })).toBeVisible();
await expect(destinationDialog.getByRole('option', { name: thirdName })).toBeVisible();
await destinationDialog.getByRole('option', { name: firstName }).click();
await addAgent.click();
const addDialog = page.getByRole('dialog', { name: 'Add agent' });
await expect(addDialog.getByRole('option', { name: firstName })).toHaveCount(0);
await expect(addDialog.getByRole('option', { name: secondName })).toBeVisible();
await addDialog.getByRole('option', { name: thirdName }).click();
const router = await createConfiguredAgent(routerForm);
routerId = router.id;
const persisted = await fetchJson<AgentDetail>(
page,
`/api/agents/${encodeURIComponent(router.id)}/expanded`,
token,
);
expect(persisted.edges).toEqual([
{
from: router.id,
to: first.id,
edgeType: 'handoff',
description: 'The surviving expanded handoff',
},
{
from: router.id,
to: third.id,
edgeType: 'handoff',
},
]);
let editForm = await openAgentBuilder(page);
await editForm.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: routerName }).click();
await expect(editForm.getByLabel('Agent name')).toHaveValue(routerName);
await editForm.getByRole('button', { name: 'Advanced' }).click();
let editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' });
await expect(editableHandoffs.getByText('2 / 10', { exact: true })).toBeVisible();
await editableHandoffs.getByRole('button', { name: 'Expand' }).first().click();
await editableHandoffs
.getByLabel('Handoff description')
.fill('The updated persisted handoff');
const secondDestination = editableHandoffs
.getByRole('combobox', { name: 'Select agent' })
.nth(1);
await secondDestination.click();
await page
.getByRole('dialog', { name: 'Select agent' })
.last()
.getByRole('option', { name: secondName })
.click();
await editForm.getByRole('button', { name: 'Back to builder' }).click();
const [updateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.request().method() === 'PATCH' &&
new URL(response.url()).pathname === `/api/agents/${router.id}` &&
response.ok(),
{ timeout: 30000 },
),
editForm.getByRole('button', { name: 'Save', exact: true }).click(),
]);
expect(updateResponse.ok()).toBeTruthy();
const updated = await fetchJson<AgentDetail>(
page,
`/api/agents/${encodeURIComponent(router.id)}/expanded`,
token,
);
expect(updated.edges).toEqual([
{
from: router.id,
to: first.id,
edgeType: 'handoff',
description: 'The updated persisted handoff',
},
{
from: router.id,
to: second.id,
edgeType: 'handoff',
},
]);
editForm = await openAgentBuilder(page);
await editForm.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: routerName }).click();
await editForm.getByRole('button', { name: 'Advanced' }).click();
editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' });
await expect(editableHandoffs.getByText('2 / 10', { exact: true })).toBeVisible();
await expect(
editableHandoffs.getByRole('combobox', { name: 'Select agent' }).first(),
).toContainText(firstName);
await expect(
editableHandoffs.getByRole('combobox', { name: 'Select agent' }).nth(1),
).toContainText(secondName);
await editableHandoffs.getByRole('button', { name: 'Expand' }).first().click();
await expect(editableHandoffs.getByLabel('Handoff description')).toHaveValue(
'The updated persisted handoff',
);
await editForm.getByRole('button', { name: 'Back to builder' }).click();
await editForm.getByRole('button', { name: 'Version', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Version History' })).toBeVisible();
const history = page.getByRole('list', { name: 'Version History' });
const versionItems = history.getByRole('listitem');
await expect(versionItems).toHaveCount(2);
await expect(versionItems.first()).toHaveAttribute('aria-current', 'true');
await expect(versionItems.last()).not.toHaveAttribute('aria-current');
await versionItems.last().getByRole('button', { name: 'Restore' }).click();
const restoreDialog = page.getByRole('dialog', {
name: 'Are you sure you want to restore this version?',
});
const [restoreResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === `/api/agents/${router.id}/revert` &&
response.ok(),
{ timeout: 30000 },
),
restoreDialog.getByRole('button', { name: 'Restore', exact: true }).click(),
]);
expect(restoreResponse.ok()).toBeTruthy();
await expect(page.getByText('Version restored successfully', { exact: true })).toBeVisible();
await expect(versionItems.last()).toHaveAttribute('aria-current', 'true');
await page.getByRole('button', { name: 'Back to builder' }).click();
editForm = page.getByRole('form', { name: 'Agent configuration form' });
await editForm.getByRole('button', { name: 'Advanced' }).click();
editableHandoffs = editForm.getByRole('region', { name: 'Handoffs' });
await expect(
editableHandoffs.getByRole('combobox', { name: 'Select agent' }).first(),
).toContainText(firstName);
await expect(
editableHandoffs.getByRole('combobox', { name: 'Select agent' }).nth(1),
).toContainText(thirdName);
const restored = await fetchJson<AgentDetail>(
page,
`/api/agents/${encodeURIComponent(router.id)}/expanded`,
token,
);
expect(restored.edges).toEqual(persisted.edges);
} finally {
await cleanupAgents(page, token, [routerId, ...createdIds]);
}
});
test('enforces the ten-destination handoff limit in the builder', async ({ page }) => {
test.setTimeout(240000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const targetNames = Array.from({ length: 10 }, (_, index) =>
uniqueAgentName(`E2E Handoff Limit ${index + 1}`),
);
const targetIds: string[] = [];
try {
for (const targetName of targetNames) {
const target = await createAgentViaApi(page, token, targetName);
targetIds.push(target.id);
}
const routerForm = await configureNewAgent(page, uniqueAgentName('E2E Handoff Limit Router'));
await routerForm.getByRole('button', { name: 'Advanced' }).click();
const handoffs = routerForm.getByRole('region', { name: 'Handoffs' });
for (const targetName of targetNames) {
await handoffs.getByRole('combobox', { name: 'Add agent' }).click();
await page.getByRole('option', { name: targetName }).click();
}
await expect(handoffs.getByText('10 / 10', { exact: true })).toBeVisible();
await expect(
handoffs.getByText('Maximum 10 handoff agents reached.', { exact: true }),
).toBeVisible();
await expect(handoffs.getByRole('combobox', { name: 'Add agent' })).toHaveCount(0);
await expect(handoffs.getByRole('combobox', { name: 'Select agent' })).toHaveCount(10);
} finally {
await cleanupAgents(page, token, targetIds);
}
});
test('refreshes a cached router after its handoff target is deleted', async ({ page }) => {
test.setTimeout(180000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const targetName = uniqueAgentName('E2E Deleted Handoff Target');
const routerName = uniqueAgentName('E2E Cached Handoff Router');
let routerId: string | undefined;
try {
const target = await createAgentViaApi(page, token, targetName);
const router = await createAgentViaApi(page, token, routerName, [
{
from: '',
to: target.id,
edgeType: 'handoff',
description: 'This edge should disappear with its target.',
},
]);
routerId = router.id;
let form = await openAgentBuilder(page);
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: routerName }).click();
await form.getByRole('button', { name: 'Advanced' }).click();
await expect(
form.getByRole('region', { name: 'Handoffs' }).getByText('1 / 10', { exact: true }),
).toBeVisible();
await form.getByRole('button', { name: 'Back to builder' }).click();
await form.getByRole('combobox', { name: 'Agent', exact: true }).click();
await page.getByRole('option', { name: targetName }).click();
await expect(form.getByLabel('Agent name')).toHaveValue(targetName);
await form.getByRole('button', { name: 'Delete Agent' }).click();
const dialog = page.getByRole('dialog', { name: 'Delete Agent' });
await expect(dialog).toBeVisible();
const [deleteResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.request().method() === 'DELETE' &&
new URL(response.url()).pathname === `/api/agents/${target.id}` &&
response.ok(),
{ timeout: 30000 },
),
dialog.getByRole('button', { name: 'Delete', exact: true }).click(),
]);
expect(deleteResponse.ok()).toBeTruthy();
form = page.getByRole('form', { name: 'Agent configuration form' });
await expect(form.getByLabel('Agent name')).toHaveValue(routerName, { timeout: 30000 });
await form.getByRole('button', { name: 'Advanced' }).click();
const handoffs = form.getByRole('region', { name: 'Handoffs' });
await expect(handoffs.getByText('0 / 10', { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(handoffs.getByText(targetName, { exact: true })).toHaveCount(0);
const persisted = await fetchJson<AgentDetail>(
page,
`/api/agents/${encodeURIComponent(router.id)}/expanded`,
token,
);
expect(persisted.edges ?? []).toEqual([]);
} finally {
await cleanupAgents(page, token, [routerId]);
}
});
test('rejects a stale handoff when its target no longer exists', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const target = await createAgentViaApi(
page,
token,
uniqueAgentName('E2E Missing Handoff Target'),
);
const router = await createAgentViaApi(
page,
token,
uniqueAgentName('E2E Missing Handoff Router'),
[{ from: '', to: target.id, edgeType: 'handoff' }],
);
try {
await requestJson(page, {
path: `/api/agents/${encodeURIComponent(target.id)}`,
token,
method: 'DELETE',
});
const staleSave = await page.request.patch(`/api/agents/${encodeURIComponent(router.id)}`, {
headers: { Authorization: `Bearer ${token}` },
data: {
edges: [{ from: router.id, to: target.id, edgeType: 'handoff' }],
},
});
expect(staleSave.status()).toBe(400);
await expect(staleSave.json()).resolves.toMatchObject({
error: 'One or more agents referenced in edges do not exist',
agent_ids: [target.id],
});
} finally {
await cleanupAgents(page, token, [router.id]);
}
});
test('routes to the chosen agent, renders passthrough details, and survives reloads', async ({
page,
}) => {
test.setTimeout(180000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const chosenName = uniqueAgentName('E2E Chosen Handoff');
const unusedName = uniqueAgentName('E2E Unused Handoff');
const routerName = uniqueAgentName('E2E Choice Router');
const label = `choice-${Date.now()}`;
const payload = `receipt-${Date.now()}`;
const chosenInstructions = `Only the chosen specialist has this instruction marker: ${label}.`;
let chosenId: string | undefined;
let unusedId: string | undefined;
let routerId: string | undefined;
try {
await waitForMCPTool(page, token);
const chosen = await createAgentViaApi(page, token, chosenName, undefined, {
instructions: chosenInstructions,
tools: [MCP_SERVER_TOOL_ID, MCP_TOOL_ID],
});
chosenId = chosen.id;
const unused = await createAgentViaApi(page, token, unusedName);
unusedId = unused.id;
const router = await createAgentViaApi(page, token, routerName, [
{
from: '',
to: chosen.id,
edgeType: 'handoff',
description: 'Use the chosen specialist for this request.',
prompt: 'Pass precise instructions to the chosen specialist.',
promptKey: 'brief',
},
{
from: '',
to: unused.id,
edgeType: 'handoff',
description: 'A valid alternative that should not be selected.',
},
]);
routerId = router.id;
await selectAgentForChat(page, routerName);
const noTransferLabel = `no-transfer-${Date.now()}`;
const noTransferResponse = await sendMessage(page, `E2E_REPLY:${noTransferLabel}`);
expect(noTransferResponse.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(`E2E reply ${noTransferLabel}`, { exact: true }),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByRole('button', { name: /^Transferred to / }),
).toHaveCount(0);
const response = await sendMessage(
page,
handoffMarker(label, [
{
from: router.id,
to: chosen.id,
description: 'Use the chosen specialist for this request.',
prompt: 'Pass precise instructions to the chosen specialist.',
promptKey: 'brief',
args: { brief: payload },
receipt: payload,
targetInstructions: chosenInstructions,
targetTools: [MCP_TOOL_ID],
},
]),
);
expect(response.ok()).toBeTruthy();
const finalText = `E2E handoff complete ${label}: agent=${chosen.id}; received=${payload}`;
await expect(messagesView(page).getByText(finalText, { exact: true })).toBeVisible({
timeout: 30000,
});
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${unusedName}` }),
).toHaveCount(0);
let transferCard = messagesView(page).getByRole('button', {
name: `Transferred to ${chosenName}`,
});
await expect(transferCard).toBeEnabled();
await transferCard.click();
await expect(
messagesView(page).getByText('Handoff instructions:', { exact: true }),
).toBeVisible();
await expect(
messagesView(page).getByText(JSON.stringify({ brief: payload }), { exact: true }),
).toBeVisible();
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
const conversationUrl = page.url();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(conversationUrl);
await expect(messagesView(page).getByText(finalText, { exact: true })).toBeVisible({
timeout: 30000,
});
transferCard = messagesView(page).getByRole('button', {
name: `Transferred to ${chosenName}`,
});
await expect(transferCard).toBeVisible();
const emptyLabel = `${label}-empty`;
const emptyResponse = await sendMessage(
page,
handoffMarker(emptyLabel, [
{
from: router.id,
to: chosen.id,
description: 'Use the chosen specialist for this request.',
prompt: 'Pass precise instructions to the chosen specialist.',
promptKey: 'brief',
args: {},
},
]),
);
expect(emptyResponse.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(
`E2E handoff complete ${emptyLabel}: agent=${chosen.id}; received=(no injected handoff content)`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page)
.getByRole('button', { name: `Transferred to ${chosenName}` })
.last(),
).toBeDisabled();
} finally {
await cleanupAgent(page, routerId);
await cleanupAgent(page, unusedId);
await cleanupAgent(page, chosenId);
}
});
test('executes a transitive router-to-specialist-to-reviewer handoff', async ({ page }) => {
test.setTimeout(180000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const reviewerName = uniqueAgentName('E2E Handoff Reviewer');
const specialistName = uniqueAgentName('E2E Handoff Middle');
const routerName = uniqueAgentName('E2E Handoff Chain Router');
const label = `chain-${Date.now()}`;
const specialistReceipt = `specialist-context-${Date.now()}`;
const reviewerReceipt = `reviewer-context-${Date.now()}`;
let reviewerId: string | undefined;
let specialistId: string | undefined;
let routerId: string | undefined;
try {
const reviewer = await createAgentViaApi(page, token, reviewerName);
reviewerId = reviewer.id;
const specialist = await createAgentViaApi(page, token, specialistName, [
{
from: '',
to: reviewer.id,
edgeType: 'handoff',
description: 'Send completed specialist work to review.',
prompt: 'Pass review context.',
promptKey: 'context',
},
]);
specialistId = specialist.id;
const router = await createAgentViaApi(page, token, routerName, [
{
from: '',
to: specialist.id,
edgeType: 'handoff',
description: 'Start with the specialist.',
prompt: 'Pass specialist instructions.',
},
]);
routerId = router.id;
await selectAgentForChat(page, routerName);
const response = await sendMessage(
page,
handoffMarker(label, [
{
from: router.id,
to: specialist.id,
description: 'Start with the specialist.',
prompt: 'Pass specialist instructions.',
args: { instructions: specialistReceipt },
},
{
from: specialist.id,
to: reviewer.id,
description: 'Send completed specialist work to review.',
prompt: 'Pass review context.',
promptKey: 'context',
args: { context: reviewerReceipt },
},
]),
);
expect(response.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${reviewer.id}; received=${reviewerReceipt}`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${specialistName}` }),
).toBeVisible();
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${reviewerName}` }),
).toBeVisible();
} finally {
await cleanupAgent(page, routerId);
await cleanupAgent(page, specialistId);
await cleanupAgent(page, reviewerId);
}
});
test('executes simultaneous handoffs and renders both transfer branches', async ({ page }) => {
test.setTimeout(180000);
await page.goto('/c/new', { timeout: 10000 });
const token = await getAccessToken(page);
const leftName = uniqueAgentName('E2E Parallel Left');
const rightName = uniqueAgentName('E2E Parallel Right');
const routerName = uniqueAgentName('E2E Parallel Router');
const label = `parallel-${Date.now()}`;
const leftReceipt = `left-context-${Date.now()}`;
const rightReceipt = `right-context-${Date.now()}`;
let leftId: string | undefined;
let rightId: string | undefined;
let routerId: string | undefined;
try {
const left = await createAgentViaApi(page, token, leftName);
leftId = left.id;
const right = await createAgentViaApi(page, token, rightName);
rightId = right.id;
const router = await createAgentViaApi(page, token, routerName, [
{
from: '',
to: left.id,
edgeType: 'handoff',
description: 'Run the left branch.',
prompt: 'Pass left-branch instructions.',
},
{
from: '',
to: right.id,
edgeType: 'handoff',
description: 'Run the right branch.',
prompt: 'Pass right-branch context.',
promptKey: 'context',
},
]);
routerId = router.id;
await selectAgentForChat(page, routerName);
const response = await sendMessage(
page,
handoffMarker(label, [
{
from: router.id,
to: left.id,
description: 'Run the left branch.',
prompt: 'Pass left-branch instructions.',
args: { instructions: leftReceipt },
},
{
from: router.id,
to: right.id,
description: 'Run the right branch.',
prompt: 'Pass right-branch context.',
promptKey: 'context',
args: { context: rightReceipt },
},
]),
);
expect(response.ok()).toBeTruthy();
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${left.id}; received=${leftReceipt}`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${right.id}; received=${rightReceipt}`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${leftName}` }),
).toBeVisible();
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${rightName}` }),
).toBeVisible();
await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
const conversationUrl = page.url();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(conversationUrl);
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${left.id}; received=${leftReceipt}`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByText(
`E2E handoff complete ${label}: agent=${right.id}; received=${rightReceipt}`,
{ exact: true },
),
).toBeVisible({ timeout: 30000 });
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${leftName}` }),
).toBeVisible();
await expect(
messagesView(page).getByRole('button', { name: `Transferred to ${rightName}` }),
).toBeVisible();
} finally {
await cleanupAgent(page, routerId);
await cleanupAgent(page, rightId);
await cleanupAgent(page, leftId);
}
});
});

View file

@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import type { GraphEdge } from 'librechat-data-provider';
import type { Page } from '@playwright/test';
import { MOCK_ENDPOINTS, NEW_CHAT_PATH, fetchJson, getAccessToken, requestJson } from './helpers';
@ -33,6 +34,7 @@ export type AgentDetail = AgentSummary & {
model_parameters?: ModelParameters;
tools?: string[];
mcpServerNames?: string[];
edges?: GraphEdge[];
};
export const uniqueAgentName = (prefix: string) =>

794
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -153,6 +153,7 @@
"typescript-eslint": "^8.60.1"
},
"overrides": {
"brace-expansion": "^5.0.8",
"@xmldom/xmldom": "^0.8.13",
"elliptic": "^6.6.1",
"form-data": "^4.0.6",

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);
}