mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧭 test: Cover Agent Handoffs End to End (#14428)
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
* test: cover agent handoffs end to end * style: sort handoff imports * fix: normalize missing agent handoff edges * chore: update package dependencies and versions in package-lock.json and package.json * chore: bump agents SDK
This commit is contained in:
parent
8374b8416a
commit
a53936d273
26 changed files with 3160 additions and 527 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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') })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export default function VersionPanel() {
|
|||
artifacts: agentWithVersions.artifacts,
|
||||
capabilities: agentWithVersions.capabilities,
|
||||
tools: agentWithVersions.tools,
|
||||
edges: agentWithVersions.edges,
|
||||
};
|
||||
}, [agentWithVersions]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
113
client/src/data-provider/Agents/__tests__/mutations.test.ts
Normal file
113
client/src/data-provider/Agents/__tests__/mutations.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) ??
|
||||
|
|
|
|||
993
e2e/specs/mock/agent-handoffs.spec.ts
Normal file
993
e2e/specs/mock/agent-handoffs.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
794
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^2.8.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.2.68",
|
||||
"@librechat/agents": "^3.3.2",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
|
|
|
|||
|
|
@ -465,6 +465,24 @@ describe('summarizationConfig field passthrough', () => {
|
|||
// Suite 5: Multi-agent + per-agent overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('multi-agent + per-agent overrides', () => {
|
||||
it('normalizes missing persisted edges before creating the SDK graph', async () => {
|
||||
await createRun({
|
||||
agents: [makeAgent({ id: 'agent_1' }), makeAgent({ id: 'agent_2' })] as never,
|
||||
signal: new AbortController().signal,
|
||||
streaming: true,
|
||||
streamUsage: true,
|
||||
});
|
||||
|
||||
const createMock = Run.create as jest.Mock;
|
||||
const runConfig = createMock.mock.calls[0][0] as {
|
||||
graphConfig: { type: string; edges: unknown[] };
|
||||
};
|
||||
expect(runConfig.graphConfig).toMatchObject({
|
||||
type: 'multi-agent',
|
||||
edges: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('different agents get different effectiveMaxContextTokens', async () => {
|
||||
const agents = await callAndCapture({
|
||||
agents: [
|
||||
|
|
|
|||
|
|
@ -3,11 +3,56 @@ import {
|
|||
getEdgeKey,
|
||||
getEdgeParticipants,
|
||||
collectEdgeAgentIds,
|
||||
replaceEdgeSourceId,
|
||||
filterOrphanedEdges,
|
||||
createEdgeCollector,
|
||||
} from './edges';
|
||||
|
||||
describe('edges utilities', () => {
|
||||
describe('replaceEdgeSourceId', () => {
|
||||
it('should assign a newly created agent id to placeholder sources', () => {
|
||||
const edges: GraphEdge[] = [
|
||||
{ from: '', to: 'agent_target', edgeType: 'handoff' },
|
||||
{ from: 'agent_other', to: 'agent_target', edgeType: 'handoff' },
|
||||
];
|
||||
|
||||
expect(replaceEdgeSourceId(edges, '', 'agent_router')).toEqual([
|
||||
{ from: 'agent_router', to: 'agent_target', edgeType: 'handoff' },
|
||||
{ from: 'agent_other', to: 'agent_target', edgeType: 'handoff' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should rewrite copied agent ids inside multi-source edges', () => {
|
||||
const edges: GraphEdge[] = [
|
||||
{
|
||||
from: ['agent_original', 'agent_peer'],
|
||||
to: 'agent_target',
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
];
|
||||
|
||||
expect(replaceEdgeSourceId(edges, 'agent_original', 'agent_clone')).toEqual([
|
||||
{
|
||||
from: ['agent_clone', 'agent_peer'],
|
||||
to: 'agent_target',
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve untouched edge references', () => {
|
||||
const edge: GraphEdge = {
|
||||
from: 'agent_other',
|
||||
to: 'agent_target',
|
||||
edgeType: 'handoff',
|
||||
};
|
||||
|
||||
const result = replaceEdgeSourceId([edge], 'agent_original', 'agent_clone');
|
||||
|
||||
expect(result?.[0]).toBe(edge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEdgeKey', () => {
|
||||
it('should create key from simple string from/to', () => {
|
||||
const edge: GraphEdge = { from: 'agent_a', to: 'agent_b', edgeType: 'handoff' };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,38 @@
|
|||
import type { GraphEdge } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Rewrites an agent id wherever it appears as an edge source.
|
||||
*
|
||||
* Agent creation uses an empty source id until the server assigns the
|
||||
* persisted id, while agent duplication needs to move the copied router's
|
||||
* outgoing edges to the clone. Keeping both cases here makes the rewrite
|
||||
* consistent for scalar and multi-source edges.
|
||||
*/
|
||||
export function replaceEdgeSourceId(
|
||||
edges: GraphEdge[] | undefined,
|
||||
previousSourceId: string,
|
||||
nextSourceId: string,
|
||||
): GraphEdge[] | undefined {
|
||||
if (!edges?.length || previousSourceId === nextSourceId) {
|
||||
return edges;
|
||||
}
|
||||
|
||||
return edges.map((edge) => {
|
||||
if (Array.isArray(edge.from)) {
|
||||
if (!edge.from.includes(previousSourceId)) {
|
||||
return edge;
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
from: edge.from.map((sourceId) =>
|
||||
sourceId === previousSourceId ? nextSourceId : sourceId,
|
||||
),
|
||||
};
|
||||
}
|
||||
return edge.from === previousSourceId ? { ...edge, from: nextSourceId } : edge;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stable key for edge deduplication.
|
||||
* Handles both single and array-based from/to values.
|
||||
|
|
|
|||
279
packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts
Normal file
279
packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { Constants } from '@librechat/agents';
|
||||
import { HumanMessage, ToolMessage } from '@librechat/agents/langchain/messages';
|
||||
import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents';
|
||||
import type { BaseMessage } from '@librechat/agents/langchain/messages';
|
||||
import { applyCustomHandoffPromptKeyCompatibility } from './handoffPromptKeyCompatibility';
|
||||
|
||||
type HandoffReceptionResult = {
|
||||
filteredMessages: BaseMessage[];
|
||||
instructions: string | null;
|
||||
sourceAgentName: string | null;
|
||||
parallelSiblings: string[];
|
||||
} | null;
|
||||
|
||||
type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult;
|
||||
|
||||
type TestGraph = {
|
||||
processHandoffReception: ProcessHandoffReception;
|
||||
};
|
||||
|
||||
const createGraphConfig = (edges: GraphEdge[]): RunConfig['graphConfig'] => ({
|
||||
type: 'multi-agent',
|
||||
agents: [],
|
||||
edges,
|
||||
});
|
||||
|
||||
const createRun = (
|
||||
processHandoffReception: ProcessHandoffReception,
|
||||
): { run: Run<IState>; graph: TestGraph } => {
|
||||
const graph: TestGraph = { processHandoffReception };
|
||||
return {
|
||||
run: { Graph: graph } as unknown as Run<IState>,
|
||||
graph,
|
||||
};
|
||||
};
|
||||
|
||||
const findTransfer = (messages: BaseMessage[], agentId: string): ToolMessage | undefined =>
|
||||
messages.find(
|
||||
(message): message is ToolMessage =>
|
||||
ToolMessage.isInstance(message) &&
|
||||
(message.name === `${Constants.LC_TRANSFER_TO_}${agentId}` ||
|
||||
(message.name === 'conditional_transfer' &&
|
||||
message.additional_kwargs.handoff_destination === agentId)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Models the reception behavior in @librechat/agents 3.2.68: filtering and
|
||||
* metadata work, but only the built-in Instructions/Context labels are read.
|
||||
*/
|
||||
const createSdkProcess = (): jest.MockedFunction<ProcessHandoffReception> =>
|
||||
jest.fn((messages, agentId) => {
|
||||
const transfer = findTransfer(messages, agentId);
|
||||
if (!transfer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content =
|
||||
typeof transfer.content === 'string' ? transfer.content : JSON.stringify(transfer.content);
|
||||
const instructions =
|
||||
content.match(/(?:Instructions?|Context):\s*([\s\S]+)/i)?.[1]?.trim() ?? null;
|
||||
const rawSiblings = transfer.additional_kwargs.handoff_parallel_siblings;
|
||||
|
||||
return {
|
||||
filteredMessages: messages.filter((message) => message !== transfer),
|
||||
instructions,
|
||||
sourceAgentName:
|
||||
typeof transfer.additional_kwargs.handoff_source_name === 'string'
|
||||
? transfer.additional_kwargs.handoff_source_name
|
||||
: null,
|
||||
parallelSiblings: Array.isArray(rawSiblings)
|
||||
? rawSiblings.filter((sibling): sibling is string => typeof sibling === 'string')
|
||||
: [],
|
||||
};
|
||||
});
|
||||
|
||||
describe('applyCustomHandoffPromptKeyCompatibility', () => {
|
||||
it('leaves multi-agent graphs without edges unpatched', () => {
|
||||
const sdkProcess = createSdkProcess();
|
||||
const { run, graph } = createRun(sdkProcess);
|
||||
const originalProcess = graph.processHandoffReception;
|
||||
// Persisted agents can predate `edges`, even though the current SDK type requires it.
|
||||
const graphConfig = {
|
||||
type: 'multi-agent',
|
||||
agents: [],
|
||||
} as unknown as RunConfig['graphConfig'];
|
||||
|
||||
expect(() => applyCustomHandoffPromptKeyCompatibility(run, graphConfig)).not.toThrow();
|
||||
expect(graph.processHandoffReception).toBe(originalProcess);
|
||||
});
|
||||
|
||||
it('recovers a custom prompt key for scalar and array handoff endpoints', () => {
|
||||
const sdkProcess = createSdkProcess();
|
||||
const { run, graph } = createRun(sdkProcess);
|
||||
const userMessage = new HumanMessage('Delegate the audit');
|
||||
const transferMessage = new ToolMessage({
|
||||
id: 'transfer-message',
|
||||
name: `${Constants.LC_TRANSFER_TO_}specialist`,
|
||||
tool_call_id: 'transfer-call',
|
||||
content: 'Successfully transferred to specialist\n\nWork_items: Audit cache invalidation',
|
||||
status: 'success',
|
||||
artifact: { preserved: true },
|
||||
metadata: { trace: 'handoff' },
|
||||
response_metadata: { provider: 'mock' },
|
||||
additional_kwargs: {
|
||||
handoff_source_name: 'Router',
|
||||
handoff_parallel_siblings: ['peer', 42],
|
||||
},
|
||||
});
|
||||
const messages = [userMessage, transferMessage];
|
||||
|
||||
applyCustomHandoffPromptKeyCompatibility(
|
||||
run,
|
||||
createGraphConfig([
|
||||
{
|
||||
from: ['router', 'peer'],
|
||||
to: ['specialist', 'backup'],
|
||||
edgeType: 'handoff',
|
||||
prompt: 'Work to complete',
|
||||
promptKey: 'work_items',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = graph.processHandoffReception(messages, 'specialist');
|
||||
|
||||
expect(result).toEqual({
|
||||
filteredMessages: [userMessage],
|
||||
instructions: 'Audit cache invalidation',
|
||||
sourceAgentName: 'Router',
|
||||
parallelSiblings: ['peer'],
|
||||
});
|
||||
expect(sdkProcess).toHaveBeenCalledTimes(2);
|
||||
expect(sdkProcess.mock.calls[0]?.[0]).toBe(messages);
|
||||
|
||||
const retryMessages = sdkProcess.mock.calls[1]?.[0];
|
||||
const normalizedTransfer = retryMessages?.[1];
|
||||
expect(retryMessages).not.toBe(messages);
|
||||
expect(normalizedTransfer).toBeInstanceOf(ToolMessage);
|
||||
expect(normalizedTransfer).not.toBe(transferMessage);
|
||||
expect(normalizedTransfer?.content).toBe(
|
||||
'Successfully transferred to specialist\n\nInstructions: Audit cache invalidation',
|
||||
);
|
||||
expect(normalizedTransfer).toMatchObject({
|
||||
id: 'transfer-message',
|
||||
name: `${Constants.LC_TRANSFER_TO_}specialist`,
|
||||
tool_call_id: 'transfer-call',
|
||||
status: 'success',
|
||||
artifact: { preserved: true },
|
||||
metadata: { trace: 'handoff' },
|
||||
response_metadata: { provider: 'mock' },
|
||||
additional_kwargs: {
|
||||
handoff_source_name: 'Router',
|
||||
handoff_parallel_siblings: ['peer', 42],
|
||||
},
|
||||
});
|
||||
expect(transferMessage.content).toContain('Work_items:');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'the default instructions key',
|
||||
promptKey: undefined,
|
||||
label: 'Instructions',
|
||||
},
|
||||
{
|
||||
name: 'the already-supported context key',
|
||||
promptKey: 'context',
|
||||
label: 'Context',
|
||||
},
|
||||
])('leaves $name on the SDK path', ({ promptKey, label }) => {
|
||||
const sdkProcess = createSdkProcess();
|
||||
const { run, graph } = createRun(sdkProcess);
|
||||
const originalProcess = graph.processHandoffReception;
|
||||
const edge: GraphEdge = {
|
||||
from: 'router',
|
||||
to: 'specialist',
|
||||
edgeType: 'handoff',
|
||||
prompt: 'Work to complete',
|
||||
...(promptKey && { promptKey }),
|
||||
};
|
||||
|
||||
applyCustomHandoffPromptKeyCompatibility(run, createGraphConfig([edge]));
|
||||
|
||||
expect(graph.processHandoffReception).toBe(originalProcess);
|
||||
expect(
|
||||
graph.processHandoffReception(
|
||||
[
|
||||
new ToolMessage({
|
||||
name: `${Constants.LC_TRANSFER_TO_}specialist`,
|
||||
tool_call_id: 'transfer-call',
|
||||
content: `Successfully transferred\n\n${label}: Keep the native behavior`,
|
||||
}),
|
||||
],
|
||||
'specialist',
|
||||
)?.instructions,
|
||||
).toBe('Keep the native behavior');
|
||||
expect(sdkProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('self-disables when the SDK already extracts a custom prompt key', () => {
|
||||
const upstreamResult: Exclude<HandoffReceptionResult, null> = {
|
||||
filteredMessages: [],
|
||||
instructions: 'Handled upstream',
|
||||
sourceAgentName: 'Router',
|
||||
parallelSiblings: [],
|
||||
};
|
||||
const sdkProcess = jest.fn<
|
||||
ReturnType<ProcessHandoffReception>,
|
||||
Parameters<ProcessHandoffReception>
|
||||
>(() => upstreamResult);
|
||||
const { run, graph } = createRun(sdkProcess);
|
||||
const config = createGraphConfig([
|
||||
{
|
||||
from: 'router',
|
||||
to: 'specialist',
|
||||
edgeType: 'handoff',
|
||||
prompt: 'Work to complete',
|
||||
promptKey: 'work_items',
|
||||
},
|
||||
]);
|
||||
|
||||
applyCustomHandoffPromptKeyCompatibility(run, config);
|
||||
const wrappedProcess = graph.processHandoffReception;
|
||||
applyCustomHandoffPromptKeyCompatibility(run, config);
|
||||
|
||||
expect(graph.processHandoffReception).toBe(wrappedProcess);
|
||||
expect(
|
||||
graph.processHandoffReception(
|
||||
[
|
||||
new ToolMessage({
|
||||
name: `${Constants.LC_TRANSFER_TO_}specialist`,
|
||||
tool_call_id: 'transfer-call',
|
||||
content: 'Successfully transferred\n\nWork_items: Handled upstream',
|
||||
}),
|
||||
],
|
||||
'specialist',
|
||||
),
|
||||
).toBe(upstreamResult);
|
||||
expect(sdkProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores custom keys on irrelevant destinations and direct edges', () => {
|
||||
const sdkProcess = createSdkProcess();
|
||||
const { run, graph } = createRun(sdkProcess);
|
||||
|
||||
applyCustomHandoffPromptKeyCompatibility(
|
||||
run,
|
||||
createGraphConfig([
|
||||
{
|
||||
from: 'router',
|
||||
to: 'different-agent',
|
||||
edgeType: 'handoff',
|
||||
prompt: 'Work to complete',
|
||||
promptKey: 'work_items',
|
||||
},
|
||||
{
|
||||
from: ['router', 'peer'],
|
||||
to: ['specialist', 'backup'],
|
||||
edgeType: 'direct',
|
||||
prompt: 'Direct prompt',
|
||||
promptKey: 'work_items',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = graph.processHandoffReception(
|
||||
[
|
||||
new ToolMessage({
|
||||
name: `${Constants.LC_TRANSFER_TO_}specialist`,
|
||||
tool_call_id: 'transfer-call',
|
||||
content: 'Successfully transferred\n\nWork_items: Do not reinterpret this edge',
|
||||
}),
|
||||
],
|
||||
'specialist',
|
||||
);
|
||||
|
||||
expect(result?.instructions).toBeNull();
|
||||
expect(sdkProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
185
packages/api/src/agents/handoffPromptKeyCompatibility.ts
Normal file
185
packages/api/src/agents/handoffPromptKeyCompatibility.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { Constants } from '@librechat/agents';
|
||||
import { ToolMessage } from '@librechat/agents/langchain/messages';
|
||||
import type { GraphEdge, IState, Run, RunConfig } from '@librechat/agents';
|
||||
import type { BaseMessage } from '@librechat/agents/langchain/messages';
|
||||
|
||||
type HandoffReceptionResult = {
|
||||
filteredMessages: BaseMessage[];
|
||||
instructions: string | null;
|
||||
sourceAgentName: string | null;
|
||||
parallelSiblings: string[];
|
||||
} | null;
|
||||
|
||||
type ProcessHandoffReception = (messages: BaseMessage[], agentId: string) => HandoffReceptionResult;
|
||||
|
||||
const PROCESS_HANDOFF_RECEPTION = 'processHandoffReception';
|
||||
const patchedGraphs = new WeakSet<object>();
|
||||
const sdkSupportedPromptKeys = new Set(['instruction', 'instructions', 'context']);
|
||||
|
||||
function capitalizeFirst(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function hasDestination(edge: GraphEdge, agentId: string): boolean {
|
||||
const destinations = Array.isArray(edge.to) ? edge.to : [edge.to];
|
||||
return destinations.includes(agentId);
|
||||
}
|
||||
|
||||
function getCustomPromptLabels(edges: GraphEdge[], agentId: string): string[] {
|
||||
const labels = new Set<string>();
|
||||
|
||||
for (const edge of edges) {
|
||||
const promptKey = edge.promptKey;
|
||||
if (
|
||||
edge.edgeType === 'direct' ||
|
||||
typeof edge.prompt !== 'string' ||
|
||||
!promptKey ||
|
||||
sdkSupportedPromptKeys.has(promptKey.toLowerCase()) ||
|
||||
!hasDestination(edge, agentId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
labels.add(capitalizeFirst(promptKey));
|
||||
}
|
||||
|
||||
return [...labels];
|
||||
}
|
||||
|
||||
function findTransferMessageIndex(messages: BaseMessage[], agentId: string): number {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (!ToolMessage.isInstance(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isStandardTransfer = message.name === `${Constants.LC_TRANSFER_TO_}${agentId}`;
|
||||
const isConditionalTransfer =
|
||||
message.name === 'conditional_transfer' &&
|
||||
message.additional_kwargs.handoff_destination === agentId;
|
||||
|
||||
if (isStandardTransfer || isConditionalTransfer) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function normalizeCustomPromptLabel(content: string, labels: string[]): string | null {
|
||||
let matchedNeedle: string | null = null;
|
||||
let matchedIndex = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const label of labels) {
|
||||
const needle = `\n\n${label}:`;
|
||||
const index = content.indexOf(needle);
|
||||
if (index >= 0 && index < matchedIndex) {
|
||||
matchedNeedle = needle;
|
||||
matchedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedNeedle === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
content.slice(0, matchedIndex) +
|
||||
'\n\nInstructions:' +
|
||||
content.slice(matchedIndex + matchedNeedle.length)
|
||||
);
|
||||
}
|
||||
|
||||
function cloneToolMessageWithContent(message: ToolMessage, content: string): ToolMessage {
|
||||
return new ToolMessage({
|
||||
content,
|
||||
id: message.id,
|
||||
name: message.name,
|
||||
tool_call_id: message.tool_call_id,
|
||||
status: message.status,
|
||||
artifact: message.artifact,
|
||||
metadata: message.metadata,
|
||||
additional_kwargs: message.additional_kwargs,
|
||||
response_metadata: message.response_metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility adapter for @librechat/agents 3.2.68, whose handoff receiver
|
||||
* recognizes only Instructions/Context even though handoff tools can emit an
|
||||
* arbitrary configured promptKey. The SDK remains authoritative: its method runs first,
|
||||
* and the adapter retries with a cloned, normalized ToolMessage only when the
|
||||
* SDK found the transfer but did not extract instructions.
|
||||
*
|
||||
* The adapter patches only the current Run graph and is intentionally
|
||||
* self-disabling when the upstream receiver begins handling custom keys.
|
||||
*/
|
||||
export function applyCustomHandoffPromptKeyCompatibility(
|
||||
run: Run<IState>,
|
||||
graphConfig: RunConfig['graphConfig'],
|
||||
): void {
|
||||
if (graphConfig.type !== 'multi-agent') {
|
||||
return;
|
||||
}
|
||||
|
||||
const edges = graphConfig.edges ?? [];
|
||||
const hasCustomPromptKey = edges.some((edge) => {
|
||||
const promptKey = edge.promptKey;
|
||||
return (
|
||||
edge.edgeType !== 'direct' &&
|
||||
typeof edge.prompt === 'string' &&
|
||||
!!promptKey &&
|
||||
!sdkSupportedPromptKeys.has(promptKey.toLowerCase())
|
||||
);
|
||||
});
|
||||
if (!hasCustomPromptKey || !run.Graph || patchedGraphs.has(run.Graph)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const graph = run.Graph;
|
||||
const graphMethods = graph as unknown as Record<string, unknown>;
|
||||
const candidate = graphMethods[PROCESS_HANDOFF_RECEPTION];
|
||||
if (typeof candidate !== 'function') {
|
||||
return;
|
||||
}
|
||||
const original = candidate as ProcessHandoffReception;
|
||||
|
||||
graphMethods[PROCESS_HANDOFF_RECEPTION] = function (
|
||||
this: unknown,
|
||||
messages: BaseMessage[],
|
||||
agentId: string,
|
||||
): HandoffReceptionResult {
|
||||
const result = original.call(this, messages, agentId);
|
||||
if (result === null || result.instructions !== null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const labels = getCustomPromptLabels(edges, agentId);
|
||||
if (labels.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const transferIndex = findTransferMessageIndex(messages, agentId);
|
||||
if (transferIndex < 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const transferMessage = messages[transferIndex];
|
||||
if (!ToolMessage.isInstance(transferMessage) || typeof transferMessage.content !== 'string') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const normalizedContent = normalizeCustomPromptLabel(transferMessage.content, labels);
|
||||
if (normalizedContent === null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const normalizedMessages = [...messages];
|
||||
normalizedMessages[transferIndex] = cloneToolMessageWithContent(
|
||||
transferMessage,
|
||||
normalizedContent,
|
||||
);
|
||||
return original.call(this, normalizedMessages, agentId);
|
||||
};
|
||||
|
||||
patchedGraphs.add(graph);
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import {
|
|||
createAskUserQuestionTool,
|
||||
} from '~/agents/hitl/askUserQuestionTool';
|
||||
import { resolveToolApprovalPolicy, exemptAskUserQuestionFromApproval } from '~/agents/hitl/policy';
|
||||
import { applyCustomHandoffPromptKeyCompatibility } from '~/agents/handoffPromptKeyCompatibility';
|
||||
import { getLLMConfig as getAnthropicLLMConfig } from '~/endpoints/anthropic/llm';
|
||||
import { CREATE_FILE_TOOL_NAME, EDIT_FILE_TOOL_NAME } from '~/agents/tools';
|
||||
import { getProviderConfig } from '~/endpoints/config/providers';
|
||||
|
|
@ -1361,7 +1362,7 @@ export async function createRun({
|
|||
const graphConfig: RunConfig['graphConfig'] = {
|
||||
signal,
|
||||
agents: agentInputs,
|
||||
edges: agents[0].edges,
|
||||
edges: agents[0].edges ?? [],
|
||||
};
|
||||
|
||||
if (agentInputs.length > 1 || ((graphConfig as MultiAgentGraphConfig).edges?.length ?? 0) > 0) {
|
||||
|
|
@ -1558,6 +1559,7 @@ export async function createRun({
|
|||
};
|
||||
const run = await Run.create(runConfig);
|
||||
|
||||
applyCustomHandoffPromptKeyCompatibility(run, runConfig.graphConfig);
|
||||
applyTestRunHook(run, { messages, agents });
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -772,47 +772,172 @@ describe('Agent Methods', () => {
|
|||
expect(aclEntriesAfter).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should remove handoff edges referencing deleted agent from other agents', async () => {
|
||||
test('should remove a deleted agent from scalar and array edge endpoints', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const targetAgentId = `agent_${uuidv4()}`;
|
||||
const deletedAgentId = `agent_${uuidv4()}`;
|
||||
const graphAgentId = `agent_${uuidv4()}`;
|
||||
const sourceAgentId = `agent_${uuidv4()}`;
|
||||
const targetAgentId = `agent_${uuidv4()}`;
|
||||
|
||||
// Create target agent (handoff destination)
|
||||
await createAgent({
|
||||
id: targetAgentId,
|
||||
name: 'Target Agent',
|
||||
id: deletedAgentId,
|
||||
name: 'Agent To Delete',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
|
||||
// Create source agent with handoff edge to target
|
||||
await createAgent({
|
||||
id: sourceAgentId,
|
||||
name: 'Source Agent',
|
||||
id: graphAgentId,
|
||||
name: 'Agent With Connected Edges',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
edges: [
|
||||
{
|
||||
from: deletedAgentId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: deletedAgentId,
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: [deletedAgentId, sourceAgentId],
|
||||
to: targetAgentId,
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: [deletedAgentId, targetAgentId],
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: [deletedAgentId],
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: [deletedAgentId],
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: [deletedAgentId, sourceAgentId],
|
||||
to: [deletedAgentId, targetAgentId],
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
description: 'Unrelated edge',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Verify edge exists before deletion
|
||||
const sourceAgentBefore = await getAgent({ id: sourceAgentId });
|
||||
expect(sourceAgentBefore!.edges).toHaveLength(1);
|
||||
expect(sourceAgentBefore!.edges![0].to).toBe(targetAgentId);
|
||||
await deleteAgent({ id: deletedAgentId });
|
||||
|
||||
// Delete the target agent
|
||||
await deleteAgent({ id: targetAgentId });
|
||||
const graphAgent = await getAgent({ id: graphAgentId });
|
||||
expect(graphAgent!.edges).toEqual([
|
||||
{
|
||||
from: [sourceAgentId],
|
||||
to: targetAgentId,
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: [targetAgentId],
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: [sourceAgentId],
|
||||
to: [targetAgentId],
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
description: 'Unrelated edge',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Verify the edge is removed from source agent
|
||||
const sourceAgentAfter = await getAgent({ id: sourceAgentId });
|
||||
expect(sourceAgentAfter!.edges).toHaveLength(0);
|
||||
test('should remove every bulk-deleted agent while preserving surviving edge members', async () => {
|
||||
const deletingAuthorId = new mongoose.Types.ObjectId();
|
||||
const graphAuthorId = new mongoose.Types.ObjectId();
|
||||
const firstDeletedId = `agent_${uuidv4()}`;
|
||||
const secondDeletedId = `agent_${uuidv4()}`;
|
||||
const graphAgentId = `agent_${uuidv4()}`;
|
||||
const sourceAgentId = `agent_${uuidv4()}`;
|
||||
const targetAgentId = `agent_${uuidv4()}`;
|
||||
|
||||
await createAgent({
|
||||
id: firstDeletedId,
|
||||
name: 'First Bulk-Deleted Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: deletingAuthorId,
|
||||
});
|
||||
await createAgent({
|
||||
id: secondDeletedId,
|
||||
name: 'Second Bulk-Deleted Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: deletingAuthorId,
|
||||
});
|
||||
await createAgent({
|
||||
id: graphAgentId,
|
||||
name: 'Bulk Edge Graph',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: graphAuthorId,
|
||||
edges: [
|
||||
{
|
||||
from: [firstDeletedId, sourceAgentId],
|
||||
to: [secondDeletedId, targetAgentId],
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: firstDeletedId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: [firstDeletedId, secondDeletedId],
|
||||
edgeType: 'handoff',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
description: 'Unrelated bulk edge',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await deleteUserAgents(deletingAuthorId.toString());
|
||||
|
||||
expect(await getAgent({ id: firstDeletedId })).toBeNull();
|
||||
expect(await getAgent({ id: secondDeletedId })).toBeNull();
|
||||
const graphAgent = await getAgent({ id: graphAgentId });
|
||||
expect(graphAgent!.edges).toEqual([
|
||||
{
|
||||
from: [sourceAgentId],
|
||||
to: [targetAgentId],
|
||||
edgeType: 'direct',
|
||||
},
|
||||
{
|
||||
from: sourceAgentId,
|
||||
to: targetAgentId,
|
||||
edgeType: 'handoff',
|
||||
description: 'Unrelated bulk edge',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('should remove agent from user favorites when agent is deleted', async () => {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
actionDelimiter,
|
||||
isActionTool,
|
||||
} from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, PipelineStage, Types } from 'mongoose';
|
||||
import type { AgentToolResources } from 'librechat-data-provider';
|
||||
import type { FilterQuery, Model, Types } from 'mongoose';
|
||||
import type { IAgent, IAclEntry } from '~/types';
|
||||
import { filterExistingSkillIds } from './skill';
|
||||
import logger from '~/config/winston';
|
||||
|
|
@ -28,6 +28,84 @@ const TOOL_RESOURCE_KEYS: ReadonlyArray<keyof AgentToolResources> = [
|
|||
EToolResources.ocr,
|
||||
];
|
||||
|
||||
/** Builds an atomic update that prunes deleted IDs without discarding surviving edge members. */
|
||||
function createEdgeCleanupPipeline(agentIds: string[]): PipelineStage[] {
|
||||
const cleanEndpoint = (endpoint: string) => ({
|
||||
$cond: [
|
||||
{ $isArray: endpoint },
|
||||
{
|
||||
$filter: {
|
||||
input: endpoint,
|
||||
as: 'agentId',
|
||||
cond: { $not: [{ $in: ['$$agentId', agentIds] }] },
|
||||
},
|
||||
},
|
||||
{ $cond: [{ $in: [endpoint, agentIds] }, null, endpoint] },
|
||||
],
|
||||
});
|
||||
const hasEndpoint = (endpoint: string) => ({
|
||||
$cond: [{ $isArray: endpoint }, { $gt: [{ $size: endpoint }, 0] }, { $ne: [endpoint, null] }],
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
$set: {
|
||||
edges: {
|
||||
$filter: {
|
||||
input: {
|
||||
$map: {
|
||||
input: { $ifNull: ['$edges', []] },
|
||||
as: 'edge',
|
||||
in: {
|
||||
$let: {
|
||||
vars: {
|
||||
cleanedFrom: cleanEndpoint('$$edge.from'),
|
||||
cleanedTo: cleanEndpoint('$$edge.to'),
|
||||
},
|
||||
in: {
|
||||
$cond: [
|
||||
{
|
||||
$and: [hasEndpoint('$$cleanedFrom'), hasEndpoint('$$cleanedTo')],
|
||||
},
|
||||
{
|
||||
$mergeObjects: [
|
||||
'$$edge',
|
||||
{
|
||||
from: '$$cleanedFrom',
|
||||
to: '$$cleanedTo',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
as: 'edge',
|
||||
cond: { $ne: ['$$edge', null] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Removes deleted agent references from every active graph that contains them. */
|
||||
async function removeAgentIdsFromEdges(Agent: Model<IAgent>, agentIds: string[]): Promise<void> {
|
||||
if (agentIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Agent.updateMany(
|
||||
{
|
||||
$or: [{ 'edges.from': { $in: agentIds } }, { 'edges.to': { $in: agentIds } }],
|
||||
},
|
||||
createEdgeCleanupPipeline(agentIds),
|
||||
);
|
||||
}
|
||||
|
||||
export interface AgentDeps {
|
||||
/** Removes all ACL permissions for a resource. Injected from PermissionService. */
|
||||
removeAllPermissions: (params: { resourceType: string; resourceId: unknown }) => Promise<void>;
|
||||
|
|
@ -745,10 +823,7 @@ export function createAgentMethods(
|
|||
}),
|
||||
]);
|
||||
try {
|
||||
await Agent.updateMany(
|
||||
{ 'edges.to': (agent as unknown as { id: string }).id },
|
||||
{ $pull: { edges: { to: (agent as unknown as { id: string }).id } } },
|
||||
);
|
||||
await removeAgentIdsFromEdges(Agent, [(agent as unknown as { id: string }).id]);
|
||||
} catch (error) {
|
||||
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
|
||||
}
|
||||
|
|
@ -820,10 +895,7 @@ export function createAgentMethods(
|
|||
});
|
||||
|
||||
try {
|
||||
await Agent.updateMany(
|
||||
{ 'edges.to': { $in: agentIds } },
|
||||
{ $pull: { edges: { to: { $in: agentIds } } } },
|
||||
);
|
||||
await removeAgentIdsFromEdges(Agent, agentIds);
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserAgents] Error removing agents from handoff edges', error);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue