diff --git a/api/package.json b/api/package.json index ec06eda2cd..55e68ad46c 100644 --- a/api/package.json +++ b/api/package.json @@ -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" } } diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 32de58835d..79d5527f1e 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -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} [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([ diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 9c4187b6ab..eecae7e537 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -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'); + }); }); }); diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx index 0a3c798b48..2cc2484de1 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx @@ -36,25 +36,50 @@ const AgentHandoffs: React.FC = ({ 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 = ({ 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 ( @@ -111,7 +141,7 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) removeLabel={localize('com_ui_agent_handoff_remove', { 0: targetName })} > updateHandoffAt(idx, id)} displayValue={getAgent(targetAgentId)?.name ?? ''} @@ -207,7 +237,7 @@ const AgentHandoffs: React.FC = ({ field, currentAgentId }) <> {edges.length > 0 && } -
+
{children != null &&
{children}
} -
+
{info}
diff --git a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx index 874931679c..0cdcf982dd 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx @@ -57,6 +57,7 @@ export default function VersionPanel() { artifacts: agentWithVersions.artifacts, capabilities: agentWithVersions.capabilities, tools: agentWithVersions.tools, + edges: agentWithVersions.edges, }; }, [agentWithVersions]); diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx index f8626ed586..8a0c20d421 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx @@ -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' }), diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts index cf42c94712..fe3adc07b9 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts +++ b/client/src/components/SidePanel/Agents/Version/__tests__/isActiveVersion.spec.ts @@ -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 }); diff --git a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts index e0eb5f66d3..9ef22c685e 100644 --- a/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts +++ b/client/src/components/SidePanel/Agents/Version/isActiveVersion.ts @@ -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() ); diff --git a/client/src/components/SidePanel/Agents/Version/types.ts b/client/src/components/SidePanel/Agents/Version/types.ts index 210e4d32b5..1e5345c6cf 100644 --- a/client/src/components/SidePanel/Agents/Version/types.ts +++ b/client/src/components/SidePanel/Agents/Version/types.ts @@ -1,3 +1,5 @@ +import type { GraphEdge } from 'librechat-data-provider'; + export type VersionRecord = Record; 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; } diff --git a/client/src/data-provider/Agents/__tests__/mutations.test.ts b/client/src/data-provider/Agents/__tests__/mutations.test.ts new file mode 100644 index 0000000000..ac4bb45a1d --- /dev/null +++ b/client/src/data-provider/Agents/__tests__/mutations.test.ts @@ -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, []>() + .mockResolvedValueOnce(staleAffectedAgent) + .mockResolvedValue(refreshedAffectedAgent); + const affectedSourceFetch = jest + .fn, []>() + .mockResolvedValueOnce(staleAffectedSourceAgent) + .mockResolvedValue(refreshedAffectedSourceAgent); + const unrelatedFetch = jest.fn, []>().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(); + }); +}); diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 6c36b3c6f9..133d57a59a 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -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; + 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); diff --git a/e2e/setup/fake-model.js b/e2e/setup/fake-model.js index e284caabc8..32007dfd5c 100644 --- a/e2e/setup/fake-model.js +++ b/e2e/setup/fake-model.js @@ -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) ?? diff --git a/e2e/specs/mock/agent-handoffs.spec.ts b/e2e/specs/mock/agent-handoffs.spec.ts new file mode 100644 index 0000000000..efa419e18c --- /dev/null +++ b/e2e/specs/mock/agent-handoffs.spec.ts @@ -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; + receipt?: string; + targetInstructions?: string; + targetTools?: string[]; +}; + +type MCPToolsResponse = { + servers?: Record }>; +}; + +const handoffMarker = (label: string, routes: HandoffRoute[]) => + `E2E_HANDOFF:${Buffer.from(JSON.stringify({ label, routes })).toString('base64url')}`; + +async function waitForMCPTool(page: Page, token: string): Promise { + let latestTools: MCPToolsResponse | null = null; + + for (let attempt = 0; attempt < 20; attempt++) { + latestTools = await fetchJson(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 { + 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 { + 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 { + 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 { + return requestJson(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 { + 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, +): Promise { + 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( + 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( + 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( + 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( + 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( + 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); + } + }); +}); diff --git a/e2e/specs/mock/agents.helpers.ts b/e2e/specs/mock/agents.helpers.ts index bf5566f6af..cdfded2f2e 100644 --- a/e2e/specs/mock/agents.helpers.ts +++ b/e2e/specs/mock/agents.helpers.ts @@ -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) => diff --git a/package-lock.json b/package-lock.json index c291445d53..5c05796f61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,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", @@ -152,7 +152,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" } }, @@ -1224,9 +1224,9 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.103.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz", - "integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==", + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz", + "integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==", "license": "MIT", "dependencies": { "json-schema-to-ts": "^3.1.1", @@ -1581,20 +1581,18 @@ } }, "node_modules/@aws-sdk/client-bedrock-agent-runtime": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent-runtime/-/client-bedrock-agent-runtime-3.1075.0.tgz", - "integrity": "sha512-gjXKDIadqv9u+VZQYV7b2aGSmGYrgE7A6W1x8AmCQYqYqp6LAGgul2ZXTfChqifsv+oYuE7aE2yN7DaRHKIbZA==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agent-runtime/-/client-bedrock-agent-runtime-3.1095.0.tgz", + "integrity": "sha512-/wRPU+Mjs042fDtQykL39441CiWLc++15vAFGbzf+Hek1G4aNF2RJtIJ69pxQviNNIze65L+o76OI6LR0oqyLg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1602,41 +1600,22 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1075.0.tgz", - "integrity": "sha512-LDGtNMOxnMz0dw9q+8z0f/X+Soj8OyiYg5zPcqToLh6H9/HHlazogFj7PXqFLOhnvhCqyAvKVAC1ZrL0RX418g==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1095.0.tgz", + "integrity": "sha512-DWcwoQdQPrQJxnG3hz1sG88EjfzGv3SReRy4mtAO+pZXtLX+trriTa20o+qcTmVzwqS43JXtX26ythOzErev9A==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/eventstream-handler-node": "^3.972.22", - "@aws-sdk/middleware-eventstream": "^3.972.18", - "@aws-sdk/middleware-websocket": "^3.972.31", - "@aws-sdk/token-providers": "3.1075.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1075.0.tgz", - "integrity": "sha512-SsunyegDXq68TaN5Iut8ElErGIAA6DeuKPKd5/v0lpSmZBI7ZKOC5OALyi1MRHsl/cuO/zHkJL3vKnNHdGaI+Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/eventstream-handler-node": "^3.972.30", + "@aws-sdk/middleware-eventstream": "^3.972.25", + "@aws-sdk/middleware-websocket": "^3.972.43", + "@aws-sdk/token-providers": "3.1095.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1829,20 +1808,18 @@ } }, "node_modules/@aws-sdk/client-kendra": { - "version": "3.1075.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1075.0.tgz", - "integrity": "sha512-tgTRI965x8K/QT8LuTDA2FMd85UmQDpSs1MA3NM/T5KAyQ7+OXUalEFohLp+4GtefYxwZtEP3E2xEoENv2YG+A==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1095.0.tgz", + "integrity": "sha512-Ty3QbO46zzBfG3lbdeybFNZAGcNDnD8Ni5VV5rri5ez2EOlp/B0LMlNtAgWFORb6o9oaOQWpyLbB77ksSXzA7w==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-node": "^3.972.58", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-node": "^3.972.72", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2022,17 +1999,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", - "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "version": "3.977.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", + "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@aws-sdk/xml-builder": "^3.972.31", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2040,6 +2017,15 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/crc64-nvme": { "version": "3.972.5", "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", @@ -2070,15 +2056,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", - "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.61.tgz", + "integrity": "sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2086,17 +2072,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", - "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.63.tgz", + "integrity": "sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2104,23 +2090,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.56", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", - "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.6.tgz", + "integrity": "sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-login": "^3.972.55", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-login": "^3.972.68", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2128,16 +2114,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", - "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.68.tgz", + "integrity": "sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2145,21 +2131,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.58", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", - "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.72.tgz", + "integrity": "sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.49", - "@aws-sdk/credential-provider-http": "^3.972.51", - "@aws-sdk/credential-provider-ini": "^3.972.56", - "@aws-sdk/credential-provider-process": "^3.972.49", - "@aws-sdk/credential-provider-sso": "^3.972.55", - "@aws-sdk/credential-provider-web-identity": "^3.972.55", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/credential-provider-imds": "^4.3.7", - "@smithy/types": "^4.14.3", + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-ini": "^3.973.6", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2167,15 +2153,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.49", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", - "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.61.tgz", + "integrity": "sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2183,17 +2169,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", - "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.5.tgz", + "integrity": "sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/token-providers": "3.1074.0", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/token-providers": "3.1095.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2201,16 +2187,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.55", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", - "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.67.tgz", + "integrity": "sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2249,14 +2235,14 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", - "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.30.tgz", + "integrity": "sha512-hJboPgIpq5+ADc++/B9TBqn65CXV21cZLGB8V5RBQbxkZ/rQ6qMfcxTnW/SvQlasX4jhaSG8B1wsVjhQyDrsnQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2294,14 +2280,14 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", - "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "version": "3.972.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.25.tgz", + "integrity": "sha512-9SFbPzJDHHR5k6Q6KvXVas/veUm/TzNcNTFM2UhdXHZHpyIvI2lS+s4cxljw1BihGpVhsAkQDo/2nW7dHxpf4Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2593,17 +2579,17 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.31.tgz", - "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.43.tgz", + "integrity": "sha512-n29++15Vma64Kd0enp9Bo8a6LTm8TvUoMbJEwqXtIksv0oEs+SUCRMm3gozDfPD1Ly0k/sSBughxarlLlRF6Xw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2611,20 +2597,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.23", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", - "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "version": "3.997.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.35.tgz", + "integrity": "sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/signature-v4-multi-region": "^3.996.35", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/fetch-http-handler": "^5.4.6", - "@smithy/node-http-handler": "^4.7.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2632,14 +2616,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", - "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.13", - "@smithy/signature-v4": "^5.4.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2725,16 +2709,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1074.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", - "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1095.0.tgz", + "integrity": "sha512-65SudS6y4nzaYHybtqcpm3sHe5jLhdMn68HRKS1nUx690BtQeaAQOoujQ+dpOjBATIVGVgKKjEP8tR+U06QJQA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.23", - "@aws-sdk/nested-clients": "^3.997.23", - "@aws-sdk/types": "^3.973.13", - "@smithy/core": "^3.24.6", - "@smithy/types": "^4.14.3", + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2742,12 +2726,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.13", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", - "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -2859,12 +2843,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.31", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", - "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -10053,19 +10037,19 @@ } }, "node_modules/@langchain/anthropic": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz", - "integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.2.tgz", + "integrity": "sha512-lYOHo5BpRbgmQVSggwPLhBNFtatiAFlVirY44tnfocx5tQKfeLYo5emqPwNwcNBuw236sr0tsDxZcmbLASN/GA==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.103.0", + "@anthropic-ai/sdk": "^0.115.0", "zod": "^3.25.76 || ^4" }, "engines": { "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.3" } }, "node_modules/@langchain/anthropic/node_modules/zod": { @@ -10078,15 +10062,15 @@ } }, "node_modules/@langchain/aws": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@langchain/aws/-/aws-1.4.2.tgz", - "integrity": "sha512-QpgB7ogkPxHICXezWQx5GkwulCK5HvbsCEkWLMK3VRHaibHC9UMxJCI/78XDXSsGU6EDMmRXjbNR+2q0kxx++Q==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@langchain/aws/-/aws-1.4.3.tgz", + "integrity": "sha512-X3oNXI1/pLizW6D4Wd1ojWTTPGnRgli8S+rm+CAhFAevIP3UdFfTDY6xih7xrQQ/0sO7po/eD0jHeZoLRKhhBg==", "license": "MIT", "dependencies": { - "@aws-sdk/client-bedrock-agent-runtime": "^3.1059.0", - "@aws-sdk/client-bedrock-runtime": "^3.1059.0", - "@aws-sdk/client-kendra": "^3.1059.0", - "@aws-sdk/credential-provider-node": "^3.972.49" + "@aws-sdk/client-bedrock-agent-runtime": "^3.1078.0", + "@aws-sdk/client-bedrock-runtime": "^3.1078.0", + "@aws-sdk/client-kendra": "^3.1078.0", + "@aws-sdk/credential-provider-node": "^3.972.61" }, "engines": { "node": ">=20" @@ -10096,9 +10080,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.2.tgz", - "integrity": "sha512-KfjEOT6sCg0vvItagfEtGpmrGoLMGfma4Affb5BGEqPmS2YR3AxW54pABSkhQlzCehTB+0BnLquAe1lGF4J9zQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz", + "integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -10123,12 +10107,12 @@ } }, "node_modules/@langchain/deepseek": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@langchain/deepseek/-/deepseek-1.1.3.tgz", - "integrity": "sha512-2fQwIQ7OLKY/WceTaZ/dJN4p+EzDiTqvR/0RG2rm0Y6GLlxXgVlBb5qK3l+UfqmU68FgexbhLZkgUnC72THnww==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@langchain/deepseek/-/deepseek-1.1.5.tgz", + "integrity": "sha512-5IRoEUaHAgIF8TyIncNVhhjavCqsjWTjakWsnus1yJN2X3W15Bw8Qmf+vJzCnFo7yndICsGdOGfHJoIN5xNxoQ==", "license": "MIT", "dependencies": { - "@langchain/openai": "1.5.3" + "@langchain/openai": "1.5.5" }, "engines": { "node": ">=20" @@ -10163,9 +10147,9 @@ } }, "node_modules/@langchain/google-gauth/node_modules/gaxios": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", - "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.0.tgz", + "integrity": "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -10191,9 +10175,9 @@ } }, "node_modules/@langchain/google-gauth/node_modules/google-auth-library": { - "version": "10.9.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", - "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -10431,9 +10415,9 @@ } }, "node_modules/@langchain/openai": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz", - "integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.5.tgz", + "integrity": "sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", @@ -10444,13 +10428,13 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.2" } }, "node_modules/@langchain/openai/node_modules/openai": { - "version": "6.45.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", @@ -10508,12 +10492,12 @@ } }, "node_modules/@langchain/xai": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@langchain/xai/-/xai-1.4.3.tgz", - "integrity": "sha512-eh8DL6x9zbjw2QlyvCFlC2r3duq1CnADHh2cxsrpmWuo2vFgqh8dOxHN2lDqjHZC2o5y94/TIhKhE7QF/IyDFQ==", + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@langchain/xai/-/xai-1.4.5.tgz", + "integrity": "sha512-w5emVjqpguoNHO6rYOWsSIRAWscpN5N3THfe85wseWjrHMaNTto/P8eDQR7zg4M5Z3MmHshNrmSjPQiR+RS4eQ==", "license": "MIT", "dependencies": { - "@langchain/openai": "1.5.3" + "@langchain/openai": "1.5.5" }, "engines": { "node": ">=20" @@ -10523,22 +10507,22 @@ } }, "node_modules/@langfuse/core": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.7.0.tgz", - "integrity": "sha512-kLFYagw3Js5QDesQbMLOzV4L4/0hYndSmAX6em6zDCmA4qMKfdRJMZ5F/fpl9LONFMTu7pU6WGLyUxM80hjYVQ==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/core/-/core-5.9.1.tgz", + "integrity": "sha512-KvyAskAO+2ixJwr9wy148ttR/Zn3oapvOJ2Br4Xcp6zhDpjSIIGB4jW/jkp53/KU9MyEHj0RSFPK/yKoxLC4KA==", "license": "MIT", "peerDependencies": { "@opentelemetry/api": "^1.9.0" } }, "node_modules/@langfuse/langchain": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-5.7.0.tgz", - "integrity": "sha512-bALp9DmuXgTgmiYpzuOi+eLlZcZ5gc8iD/gwXx/wS4Vqv6hZjydRyQBKPPLe6nhpCexIXtXowwlmffwMQ9/Ggg==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/langchain/-/langchain-5.9.1.tgz", + "integrity": "sha512-Qv5Wn8EO2cRiVTBlb/zQwRhjD+93rVjD02in9L0+hyg9OBvc2rzqTfr8zKVDv9pgpsDfWD7Wm/sXyo7vGZmsTA==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0", - "@langfuse/tracing": "^5.7.0" + "@langfuse/core": "^5.9.1", + "@langfuse/tracing": "^5.9.1" }, "peerDependencies": { "@langchain/core": ">=0.3.8", @@ -10546,12 +10530,12 @@ } }, "node_modules/@langfuse/otel": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/otel/-/otel-5.7.0.tgz", - "integrity": "sha512-x5HANvvDV23btbTijz9eW2DzvX/sZ4orsahYXuwsCApOzEWSgEzd0ZrpHPSz4+epytps1RBe3M6e5gm2EgaAmA==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/otel/-/otel-5.9.1.tgz", + "integrity": "sha512-viM5Qq/AIPZPXfO7YdSmDHxEDSrNU/MyGzuE9zKA6hGBII1iLowU+qL99O+TjnXqxoADqlNibhY2pg1/WTIPcw==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0" + "@langfuse/core": "^5.9.1" }, "engines": { "node": ">=20" @@ -10564,12 +10548,12 @@ } }, "node_modules/@langfuse/tracing": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.7.0.tgz", - "integrity": "sha512-FCroWXE0510BUt2vHCEk64Wb+nywc3WVdx3+zaWyW1CI8mSa9i/yX+Oe2a956QRLPhVCeFgO27Y8Zlg7RaDWig==", + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/@langfuse/tracing/-/tracing-5.9.1.tgz", + "integrity": "sha512-tJRyVAv1JkuOPh4Uz5eWUNH8U4jcJVtK2F5QNy5cZUzXCSrXobCSHusPbxY6VFZcLcFgtpDtSaxL7ev1tV2JNQ==", "license": "MIT", "dependencies": { - "@langfuse/core": "^5.7.0" + "@langfuse/core": "^5.9.1" }, "engines": { "node": ">=20" @@ -10630,16 +10614,16 @@ } }, "node_modules/@librechat/agents": { - "version": "3.2.68", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.2.68.tgz", - "integrity": "sha512-8yT0+kU66meD/K4wYa/QBX9FskTwU5Eq52RjNfMO5nylzsoZ2NjAzsxVewC9lhEucU0lKhrZqNU2UiqbiyIcCw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.3.2.tgz", + "integrity": "sha512-BnwdngjFgMEtjJQtVSkQq0GKt7rL2hROCXzeDkwHklSV20V3VWYBkIsLlL2Cxt3/xUTwaQT+KOPWqu8riZ9k/w==", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.103.0", + "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-bedrock-runtime": "^3.1075.0", - "@langchain/anthropic": "^1.5.1", + "@langchain/anthropic": "1.5.2", "@langchain/aws": "^1.4.2", - "@langchain/core": "^1.2.2", + "@langchain/core": "^1.2.3", "@langchain/deepseek": "^1.1.3", "@langchain/google-common": "2.2.0", "@langchain/google-gauth": "2.2.0", @@ -10658,7 +10642,7 @@ "@scarf/scarf": "^1.4.0", "@types/diff": "^7.0.2", "ai-tokenizer": "^1.0.6", - "axios": "^1.16.0", + "axios": "^1.18.1", "cheerio": "^1.0.0", "diff": "^9.0.0", "dotenv": "^16.4.7", @@ -10673,7 +10657,7 @@ "node": ">=24.0.0" }, "peerDependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.54" + "@anthropic-ai/sandbox-runtime": "^0.0.67" }, "peerDependenciesMeta": { "@anthropic-ai/sandbox-runtime": { @@ -10712,23 +10696,6 @@ "node": ">=6" } }, - "node_modules/@librechat/agents/node_modules/@langchain/openai": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.5.tgz", - "integrity": "sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==", - "license": "MIT", - "dependencies": { - "js-tiktoken": "^1.0.12", - "openai": "^6.41.0", - "zod": "^3.25.76 || ^4" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@langchain/core": "^1.2.2" - } - }, "node_modules/@librechat/agents/node_modules/@opentelemetry/api-logs": { "version": "0.220.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz", @@ -10758,9 +10725,9 @@ } }, "node_modules/@librechat/agents/node_modules/@opentelemetry/context-async-hooks": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", - "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.10.0.tgz", + "integrity": "sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -11156,6 +11123,18 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, + "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-trace-base": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz", @@ -11191,6 +11170,18 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@librechat/agents/node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/context-async-hooks": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz", + "integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@librechat/agents/node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -11201,9 +11192,9 @@ } }, "node_modules/@librechat/agents/node_modules/import-in-the-middle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz", - "integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.2.tgz", + "integrity": "sha512-jTd2FfOgOWOdgjkHuk/1Ms8VKFXkPs15ymYBETw1sAOrO/dY3XeGVRWir9qBbw7pXr0T2eTFwfCZ+N02HmiNGA==", "license": "Apache-2.0", "dependencies": { "cjs-module-lexer": "^2.2.0", @@ -11215,9 +11206,9 @@ } }, "node_modules/@librechat/agents/node_modules/openai": { - "version": "6.46.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", - "integrity": "sha512-DFg6jEPT2RO+oAyXtddeUJU8zkGy1OQ1AjGzNIJUMQG03TTqvCpy9tBpQ+2VVVnvrl3E56F8GEin2JYtWpITtA==", + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", @@ -11244,15 +11235,6 @@ } } }, - "node_modules/@librechat/agents/node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@librechat/api": { "resolved": "packages/api", "link": true @@ -17969,13 +17951,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.26.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", - "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.30.0.tgz", + "integrity": "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.15.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -17983,13 +17964,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", - "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.14.tgz", + "integrity": "sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18067,13 +18048,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", - "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", + "version": "5.6.11", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.11.tgz", + "integrity": "sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18334,13 +18315,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", - "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", + "version": "4.9.11", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.11.tgz", + "integrity": "sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18426,13 +18407,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", - "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", + "version": "5.6.10", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.10.tgz", + "integrity": "sha512-EXhWePm3SXJAX38npIy4TXL2Aex/OVgCClTjelN2QHw/U+8CQUH8C7AaxsVzQcYieYPseywn48s89DmvuGjiAg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.25.1", - "@smithy/types": "^4.15.0", + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -18458,9 +18439,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", - "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -20261,29 +20242,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -21710,9 +21668,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bare-events": { "version": "2.8.2", @@ -22039,14 +22001,15 @@ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -23011,12 +22974,6 @@ "node": ">= 0.6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -26495,16 +26452,6 @@ "minimatch": "^5.0.1" } }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/filelist/node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -27149,29 +27096,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/glob/node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -29644,16 +29568,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/jest/node_modules/ci-info": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", @@ -34158,15 +34072,16 @@ } }, "node_modules/nodemon": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", - "integrity": "sha512-7jH/NXbFPxVaMwmBCC2B9F/V6X1VkEdNgx3iu9jji8WxWcvhMWkmhNWhI5077zknOnZnBzba9hZP6bCPJLSReQ==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -34194,6 +34109,22 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -38947,15 +38878,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/rimraf/node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -40562,15 +40484,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/sucrase/node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -42998,29 +42911,6 @@ } } }, - "node_modules/workbox-build/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/workbox-build/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -43809,7 +43699,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", diff --git a/package.json b/package.json index 364b2e852a..d3d5586830 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/api/package.json b/packages/api/package.json index 712498cb60..19e8da0db9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 83ee5f6959..95fd6f19d9 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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: [ diff --git a/packages/api/src/agents/edges.spec.ts b/packages/api/src/agents/edges.spec.ts index 447c65fdb5..d612d670cb 100644 --- a/packages/api/src/agents/edges.spec.ts +++ b/packages/api/src/agents/edges.spec.ts @@ -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' }; diff --git a/packages/api/src/agents/edges.ts b/packages/api/src/agents/edges.ts index 9bc3705c60..98482d214d 100644 --- a/packages/api/src/agents/edges.ts +++ b/packages/api/src/agents/edges.ts @@ -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. diff --git a/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts b/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts new file mode 100644 index 0000000000..0593b3bad4 --- /dev/null +++ b/packages/api/src/agents/handoffPromptKeyCompatibility.spec.ts @@ -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; graph: TestGraph } => { + const graph: TestGraph = { processHandoffReception }; + return { + run: { Graph: graph } as unknown as Run, + 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 => + 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 = { + filteredMessages: [], + instructions: 'Handled upstream', + sourceAgentName: 'Router', + parallelSiblings: [], + }; + const sdkProcess = jest.fn< + ReturnType, + Parameters + >(() => 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); + }); +}); diff --git a/packages/api/src/agents/handoffPromptKeyCompatibility.ts b/packages/api/src/agents/handoffPromptKeyCompatibility.ts new file mode 100644 index 0000000000..f954fa8746 --- /dev/null +++ b/packages/api/src/agents/handoffPromptKeyCompatibility.ts @@ -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(); +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(); + + 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, + 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; + 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); +} diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 2dc42ca707..1ffe249b2d 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -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; } diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index ec55d0fbab..035d37e3ca 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -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 () => { diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 509e13f811..d88b8443fd 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -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 = [ 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, agentIds: string[]): Promise { + 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; @@ -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); }