🦥 perf: Lazy-Load Agent Version History in Editor (#13977)

Opening the agent editor fetched the full `versions` array (each a complete
config snapshot) alongside the agent, so agents with large histories were slow
to open. Version history is now loaded only when the user opens it.

- Add `getAgentWithVersionCount` (aggregation: version count, no versions array)
  and `getAgentVersions` data-schemas methods.
- `getAgentHandler` returns the version count without the heavy array; add
  `GET /agents/:id/versions` (EDIT-gated) for lazy retrieval.
- Add `useGetAgentVersionsQuery`; VersionPanel reads current config from the
  cached expanded query and fetches versions on open. Revert keeps the expanded
  cache and versions query in sync.
This commit is contained in:
Danny Avila 2026-06-26 12:19:54 -04:00 committed by GitHub
parent b15d40e3e4
commit 12fea693bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 301 additions and 45 deletions

View file

@ -487,16 +487,15 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
const id = req.params.id;
const author = req.user.id;
// Permissions are validated by middleware before calling this function
// Simply load the agent by ID
const agent = await db.getAgent({ id });
// Permissions are validated by middleware before calling this function.
// Load the agent with a `version` count but without the heavy `versions`
// array; version history is fetched lazily via GET /agents/:id/versions.
const agent = await db.getAgentWithVersionCount({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
agent.version = agent.versions ? agent.versions.length : 0;
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
try {
agent.avatar = {
@ -561,6 +560,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
}
};
/**
* Retrieves an agent's version history.
* Loaded lazily so the editor doesn't transfer large histories up front.
* @route GET /agents/:id/versions
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Promise<Agent[]>} 200 - The agent's version history - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentVersionsHandler = async (req, res) => {
try {
const id = req.params.id;
const versions = await db.getAgentVersions({ id });
if (versions == null) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.status(200).json(versions);
} catch (error) {
logger.error('[/Agents/:id/versions] Error retrieving agent versions', error);
res.status(500).json({ error: error.message });
}
};
/**
* Updates an Agent.
* @route PATCH /Agents/:id
@ -1330,6 +1355,7 @@ const getAgentCategories = async (_req, res) => {
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
getAgentVersions: getAgentVersionsHandler,
updateAgent: updateAgentHandler,
duplicateAgent: duplicateAgentHandler,
deleteAgent: deleteAgentHandler,

View file

@ -79,6 +79,7 @@ jest.mock('~/cache', () => ({
const {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
getAgentVersions: getAgentVersionsHandler,
duplicateAgent: duplicateAgentHandler,
revertAgentVersion: revertAgentVersionHandler,
updateAgent: updateAgentHandler,
@ -603,6 +604,45 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
});
});
describe('getAgentVersionsHandler', () => {
test('returns the version history and excludes it from the basic VIEW response', async () => {
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Versioned Agent',
provider: 'openai',
model: 'gpt-4',
author: mockReq.user.id,
versions: [
{ name: 'V1', provider: 'openai', model: 'gpt-4', updatedAt: new Date() },
{ name: 'V2', provider: 'openai', model: 'gpt-4', updatedAt: new Date() },
],
});
mockReq.params = { id: agent.id };
await getAgentHandler(mockReq, mockRes);
const basicResponse = mockRes.json.mock.calls[0][0];
expect(basicResponse.versions).toBeUndefined();
expect(basicResponse.version).toBe(2);
mockRes.json.mockClear();
await getAgentVersionsHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
const versions = mockRes.json.mock.calls[0][0];
expect(Array.isArray(versions)).toBe(true);
expect(versions).toHaveLength(2);
expect(versions.map((v) => v.name)).toEqual(['V1', 'V2']);
});
test('returns 404 when the agent does not exist', async () => {
mockReq.params = { id: `agent_${uuidv4()}` };
await getAgentVersionsHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(404);
});
});
describe('updateAgentHandler', () => {
let existingAgentId;
let existingAgentAuthorId;

View file

@ -81,6 +81,23 @@ router.get(
}),
(req, res) => v1.getAgent(req, res, true), // Expanded version
);
/**
* Retrieves an agent's version history (EDIT permission required).
* Loaded lazily so the editor doesn't transfer large histories up front.
* @route GET /agents/:id/versions
* @param {string} req.params.id - Agent identifier.
* @returns {Agent[]} 200 - Agent version history - application/json
*/
router.get(
'/:id/versions',
checkAgentAccess,
canAccessAgentResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.getAgentVersions,
);
/**
* Updates an agent.
* @route PATCH /agents/:id

View file

@ -1,8 +1,12 @@
import { ChevronLeft } from 'lucide-react';
import { useCallback, useMemo } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useToastContext } from '@librechat/client';
import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider';
import type { AgentWithVersions, VersionContext } from './types';
import type { AgentWithVersions, VersionContext, VersionRecord } from './types';
import {
useGetAgentVersionsQuery,
useRevertAgentVersionMutation,
useGetExpandedAgentByIdQuery,
} from '~/data-provider';
import { isActiveVersion } from './isActiveVersion';
import { useAgentPanelContext } from '~/Providers';
import VersionContent from './VersionContent';
@ -16,7 +20,15 @@ export default function VersionPanel() {
const selectedAgentId = agent_id ?? '';
const { data: agent, isLoading, error, refetch } = useGetAgentByIdQuery(selectedAgentId);
const { data: agent } = useGetExpandedAgentByIdQuery(selectedAgentId, {
enabled: !!selectedAgentId,
});
const {
data: versionsData,
isLoading,
error,
refetch,
} = useGetAgentVersionsQuery(selectedAgentId);
const revertAgentVersion = useRevertAgentVersionMutation({
onSuccess: () => {
@ -34,7 +46,7 @@ export default function VersionPanel() {
},
});
const agentWithVersions = agent as AgentWithVersions;
const agentWithVersions = agent as AgentWithVersions | undefined;
const currentAgent = useMemo(() => {
if (!agentWithVersions) return null;
@ -48,14 +60,15 @@ export default function VersionPanel() {
};
}, [agentWithVersions]);
const versionRecords = useMemo<VersionRecord[]>(() => versionsData ?? [], [versionsData]);
const versions = useMemo(() => {
const versionsCopy = [...(agentWithVersions?.versions || [])];
return versionsCopy.sort((a, b) => {
return [...versionRecords].sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return bTime - aTime;
});
}, [agentWithVersions?.versions]);
}, [versionRecords]);
const activeVersion = useMemo(() => {
return versions.length > 0
@ -73,7 +86,7 @@ export default function VersionPanel() {
return versions.map((version, displayIndex) => {
const originalIndex =
agentWithVersions?.versions?.findIndex(
versionRecords.findIndex(
(v) =>
v.updatedAt === version.updatedAt &&
v.createdAt === version.createdAt &&
@ -87,7 +100,7 @@ export default function VersionPanel() {
isActive: displayIndex === activeVersionId,
};
});
}, [versions, currentAgent, agentWithVersions?.versions]);
}, [versions, currentAgent, versionRecords]);
const versionContext: VersionContext = useMemo(
() => ({

View file

@ -1,8 +1,8 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
import { Panel } from '~/common/types';
import VersionContent from '../VersionContent';
import VersionPanel from '../VersionPanel';
import { Panel } from '~/common/types';
const mockAgentData = {
name: 'Test Agent',
@ -10,35 +10,42 @@ const mockAgentData = {
instructions: 'Test Instructions',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
versions: [
{
name: 'Version 1',
description: 'Description 1',
instructions: 'Instructions 1',
tools: ['tool1'],
capabilities: ['capability1'],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
{
name: 'Version 2',
description: 'Description 2',
instructions: 'Instructions 2',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
createdAt: '2023-01-02T00:00:00Z',
updatedAt: '2023-01-02T00:00:00Z',
},
],
};
const mockVersions = [
{
name: 'Version 1',
description: 'Description 1',
instructions: 'Instructions 1',
tools: ['tool1'],
capabilities: ['capability1'],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
{
name: 'Version 2',
description: 'Description 2',
instructions: 'Instructions 2',
tools: ['tool1', 'tool2'],
capabilities: ['capability1', 'capability2'],
createdAt: '2023-01-02T00:00:00Z',
updatedAt: '2023-01-02T00:00:00Z',
},
];
jest.mock('~/data-provider', () => ({
useGetAgentByIdQuery: jest.fn(() => ({
useGetExpandedAgentByIdQuery: jest.fn(() => ({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
})),
useGetAgentVersionsQuery: jest.fn(() => ({
data: mockVersions,
isLoading: false,
error: null,
refetch: jest.fn(),
})),
useRevertAgentVersionMutation: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
@ -67,16 +74,24 @@ describe('VersionPanel', () => {
'~/Providers/AgentPanelContext',
).useAgentPanelContext;
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
const mockUseGetExpandedAgentByIdQuery =
jest.requireMock('~/data-provider').useGetExpandedAgentByIdQuery;
const mockUseGetAgentVersionsQuery = jest.requireMock('~/data-provider').useGetAgentVersionsQuery;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAgentByIdQuery.mockReturnValue({
mockUseGetExpandedAgentByIdQuery.mockReturnValue({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
});
mockUseGetAgentVersionsQuery.mockReturnValue({
data: mockVersions,
isLoading: false,
error: null,
refetch: jest.fn(),
});
// Set up the default context mock
mockUseAgentPanelContext.mockReturnValue({
@ -126,7 +141,13 @@ describe('VersionPanel', () => {
);
// Test with null data
mockUseGetAgentByIdQuery.mockReturnValueOnce({
mockUseGetExpandedAgentByIdQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: null,
refetch: jest.fn(),
});
mockUseGetAgentVersionsQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: null,
@ -150,8 +171,8 @@ describe('VersionPanel', () => {
);
// 3. versions is undefined
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: { ...mockAgentData, versions: undefined },
mockUseGetAgentVersionsQuery.mockReturnValueOnce({
data: undefined,
isLoading: false,
error: null,
refetch: jest.fn(),
@ -165,7 +186,7 @@ describe('VersionPanel', () => {
);
// 4. loading state
mockUseGetAgentByIdQuery.mockReturnValueOnce({
mockUseGetAgentVersionsQuery.mockReturnValueOnce({
data: null,
isLoading: true,
error: null,
@ -179,7 +200,7 @@ describe('VersionPanel', () => {
// 5. error state
const testError = new Error('Test error');
mockUseGetAgentByIdQuery.mockReturnValueOnce({
mockUseGetAgentVersionsQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: testError,
@ -193,12 +214,18 @@ describe('VersionPanel', () => {
});
test('memoizes agent data correctly', () => {
mockUseGetAgentByIdQuery.mockReturnValueOnce({
mockUseGetExpandedAgentByIdQuery.mockReturnValueOnce({
data: mockAgentData,
isLoading: false,
error: null,
refetch: jest.fn(),
});
mockUseGetAgentVersionsQuery.mockReturnValueOnce({
data: mockVersions,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel />);
expect(VersionContent).toHaveBeenCalledWith(

View file

@ -379,6 +379,11 @@ export const useRevertAgentVersionMutation = (
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (revertedAgent, variables, context) => {
queryClient.setQueryData<t.Agent>([QueryKeys.agent, variables.agent_id], revertedAgent);
queryClient.setQueryData<t.Agent>(
[QueryKeys.agent, variables.agent_id, 'expanded'],
revertedAgent,
);
queryClient.invalidateQueries([QueryKeys.agent, variables.agent_id, 'versions']);
((keys: t.AgentListParams[]) => {
keys.forEach((key) => {

View file

@ -133,6 +133,33 @@ export const useGetExpandedAgentByIdQuery = (
);
};
/**
* Hook for lazily retrieving an agent's version history (EDIT permission).
* Only fetched when the user opens version history, so editors with large
* histories don't pay the cost on every open.
*/
export const useGetAgentVersionsQuery = (
agent_id: string | null | undefined,
config?: UseQueryOptions<t.Agent[]>,
): QueryObserverResult<t.Agent[]> => {
const isValidAgentId = !!agent_id && !isEphemeralAgent(agent_id);
return useQuery<t.Agent[]>(
[QueryKeys.agent, agent_id, 'versions'],
() =>
dataService.getAgentVersions({
agent_id: agent_id as string,
}),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
...config,
enabled: isValidAgentId && (config?.enabled ?? true),
},
);
};
/**
* MARKETPLACE
*/

View file

@ -525,6 +525,14 @@ export const getExpandedAgentById = ({ agent_id }: { agent_id: string }): Promis
);
};
export const getAgentVersions = ({ agent_id }: { agent_id: string }): Promise<a.Agent[]> => {
return request.get(
endpoints.agents({
path: `${agent_id}/versions`,
}),
);
};
export const updateAgent = ({
agent_id,
data,

View file

@ -47,6 +47,8 @@ let methods: ReturnType<typeof createAgentMethods>;
let createAgent: AgentMethods['createAgent'];
let getAgent: AgentMethods['getAgent'];
let getAgentVersions: AgentMethods['getAgentVersions'];
let getAgentWithVersionCount: AgentMethods['getAgentWithVersionCount'];
let updateAgent: AgentMethods['updateAgent'];
let deleteAgent: AgentMethods['deleteAgent'];
let deleteUserAgents: AgentMethods['deleteUserAgents'];
@ -90,6 +92,8 @@ beforeAll(async () => {
});
createAgent = methods.createAgent;
getAgent = methods.getAgent;
getAgentVersions = methods.getAgentVersions;
getAgentWithVersionCount = methods.getAgentWithVersionCount;
updateAgent = methods.updateAgent;
deleteAgent = methods.deleteAgent;
deleteUserAgents = methods.deleteUserAgents;
@ -1476,6 +1480,55 @@ describe('Agent Methods', () => {
expect(agent!.versions![0].model).toBe('test-model');
});
test('getAgentVersions returns only the versions array', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'First Name',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
await updateAgent({ id: agentId }, { name: 'Second Name' });
const versions = await getAgentVersions({ id: agentId });
expect(Array.isArray(versions)).toBe(true);
expect(versions).toHaveLength(2);
expect(versions![0].name).toBe('First Name');
expect(versions![1].name).toBe('Second Name');
});
test('getAgentVersions returns null for a non-existent agent', async () => {
const versions = await getAgentVersions({ id: `agent_${uuidv4()}` });
expect(versions).toBeNull();
});
test('getAgentWithVersionCount returns the count without the versions array', async () => {
const agentId = `agent_${uuidv4()}`;
await createAgent({
id: agentId,
name: 'First Name',
provider: 'test',
model: 'test-model',
author: new mongoose.Types.ObjectId(),
});
await updateAgent({ id: agentId }, { name: 'Second Name' });
await updateAgent({ id: agentId }, { name: 'Third Name' });
const agent = await getAgentWithVersionCount({ id: agentId });
expect(agent).not.toBeNull();
expect(agent!.name).toBe('Third Name');
expect(agent!.version).toBe(3);
expect(agent!.versions).toBeUndefined();
});
test('getAgentWithVersionCount returns null for a non-existent agent', async () => {
const agent = await getAgentWithVersionCount({ id: `agent_${uuidv4()}` });
expect(agent).toBeNull();
});
test('should accumulate version history across multiple updates', async () => {
const agentId = `agent_${uuidv4()}`;
const author = new mongoose.Types.ObjectId();

View file

@ -252,6 +252,10 @@ export function createAgentMethods(
deps: AgentDeps,
): {
getAgent: (searchParameter: FilterQuery<IAgent>) => Promise<IAgent | null>;
getAgentVersions: (searchParameter: FilterQuery<IAgent>) => Promise<IAgent['versions'] | null>;
getAgentWithVersionCount: (
searchParameter: FilterQuery<IAgent>,
) => Promise<(IAgent & { version: number }) | null>;
getAgents: (searchParameter: FilterQuery<IAgent>) => Promise<IAgent[]>;
createAgent: (agentData: Record<string, unknown>) => Promise<IAgent>;
hasAgentWithMCPServerName: ({
@ -366,6 +370,40 @@ export function createAgentMethods(
return await Agent.findOne(searchParameter).lean<IAgent>();
}
/**
* Get an agent's version history only, without the rest of the document.
* Returns an empty array when the agent exists but has no versions, or `null`
* when no agent matches the search parameter.
*/
async function getAgentVersions(
searchParameter: FilterQuery<IAgent>,
): Promise<IAgent['versions'] | null> {
const Agent = mongoose.models.Agent as Model<IAgent>;
const result = await Agent.findOne(searchParameter, { versions: 1, _id: 0 }).lean<
Pick<IAgent, 'versions'>
>();
if (!result) {
return null;
}
return result.versions ?? [];
}
/**
* Get an agent document with a `version` count, excluding the heavy `versions` array.
* Used when loading the editor so large version histories aren't transferred eagerly.
*/
async function getAgentWithVersionCount(
searchParameter: FilterQuery<IAgent>,
): Promise<(IAgent & { version: number }) | null> {
const Agent = mongoose.models.Agent as Model<IAgent>;
const [agent] = await Agent.aggregate<IAgent & { version: number }>([
{ $match: searchParameter },
{ $addFields: { version: { $size: { $ifNull: ['$versions', []] } } } },
{ $project: { versions: 0 } },
]);
return agent ?? null;
}
/**
* Get multiple agent documents based on the provided search parameters.
*/
@ -994,6 +1032,8 @@ export function createAgentMethods(
return {
getAgent,
getAgentVersions,
getAgentWithVersionCount,
getAgents,
createAgent,
hasAgentWithMCPServerName,