🏛️ feat: Establish Agent Management API Contract (#15545)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions

* feat: add agent management API contract

* fix: annotate agent management exports

* fix: strengthen agent projection typing

* fix: decouple agent responses from admission limits
This commit is contained in:
Danny Avila 2026-09-03 14:29:08 -04:00 • committed by GitHub
parent f0dfeed2b0
commit 04e4fd6b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 519 additions and 0 deletions

View file

@ -27,6 +27,7 @@ export * from './legacy';
export * from './lazySubagents';
export * from './lazyHistory';
export * from './memory';
export * from './management';
export * from './mcpIdentity';
export * from './orphans';
export * from './migration';

View file

@ -0,0 +1,218 @@
import { z } from 'zod';
import { MAX_SUBAGENTS, setMaxSubagents } from 'librechat-data-provider';
import {
agentManagementCreateSchema,
agentManagementListResponseSchema,
agentManagementListSchema,
agentManagementResponseSchema,
agentManagementUpdateSchema,
mapAgentManagementError,
projectAgentManagementListResponse,
projectAgentManagementResponse,
} from './management';
const timestamps = {
createdAt: new Date('2026-09-01T10:00:00.000Z'),
updatedAt: new Date('2026-09-02T10:00:00.000Z'),
};
const persistedAgent = {
_id: '64da00000000000000000001',
id: 'agent_public_id',
tenantId: 'tenant-secret',
author: 'user-secret',
credentials: { apiKey: 'secret' },
versions: [{}, {}],
mcpServerNames: ['private-routing-name'],
is_promoted: true,
name: 'Researcher',
description: 'Finds primary sources',
instructions: 'Be precise',
provider: 'openAI',
model: 'gpt-5',
model_parameters: { temperature: 0.2 },
tools: ['web_search'],
skills: ['64da00000000000000000002'],
conversation_starters: ['Research this'],
...timestamps,
};
describe('Agent Management contract', () => {
describe('inputs', () => {
it('keeps create and update fields aligned with the browser Agent validators', () => {
expect(
agentManagementCreateSchema.parse({
provider: 'openAI',
model: 'gpt-5',
name: 'Researcher',
stateful_code_environment: 'agent-user',
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
}),
).toMatchObject({ provider: 'openAI', model: 'gpt-5', tools: [] });
expect(
agentManagementUpdateSchema.parse({
instructions: 'Updated',
model_parameters: { temperature: 0.1 },
code_environment_id: null,
}),
).toEqual({
instructions: 'Updated',
model_parameters: { temperature: 0.1 },
code_environment_id: null,
});
});
it.each([agentManagementCreateSchema, agentManagementUpdateSchema])(
'rejects unknown and read-only fields',
(schema) => {
const base =
schema === agentManagementCreateSchema ? { provider: 'openAI', model: 'gpt-5' } : {};
expect(schema.safeParse({ ...base, _id: '64da00000000000000000001' }).success).toBe(false);
expect(schema.safeParse({ ...base, id: 'agent_forged' }).success).toBe(false);
expect(schema.safeParse({ ...base, tenantId: 'tenant_forged' }).success).toBe(false);
expect(schema.safeParse({ ...base, credentials: { apiKey: 'secret' } }).success).toBe(
false,
);
expect(schema.safeParse({ ...base, versions: [] }).success).toBe(false);
},
);
});
describe('pagination', () => {
const cursor = Buffer.from(
JSON.stringify({
updatedAt: '2026-09-02T10:00:00.000Z',
_id: '64da00000000000000000001',
}),
).toString('base64');
it('normalizes a bounded limit and accepts the opaque database cursor', () => {
expect(agentManagementListSchema.parse({ limit: '25', cursor })).toEqual({
limit: 25,
cursor,
});
expect(agentManagementListSchema.parse({})).toEqual({ limit: 20 });
});
it.each([
{ limit: 0 },
{ limit: 101 },
{ limit: 1.5 },
{ cursor: 'not-a-cursor' },
{
cursor: Buffer.from(JSON.stringify({ updatedAt: 'nope', _id: 'nope' })).toString('base64'),
},
{ search: 'unsupported' },
])('rejects invalid list input %#', (input) => {
expect(agentManagementListSchema.safeParse(input).success).toBe(false);
});
it('projects every list item and emits only a usable next cursor', () => {
const response = projectAgentManagementListResponse({
data: [persistedAgent],
has_more: true,
after: cursor,
});
expect(agentManagementListResponseSchema.parse(response)).toEqual(response);
expect(response).toMatchObject({
object: 'list',
first_id: 'agent_public_id',
last_id: 'agent_public_id',
has_more: true,
after: cursor,
});
expect(
projectAgentManagementListResponse({ data: [], has_more: false, after: cursor }),
).toEqual({
object: 'list',
data: [],
first_id: null,
last_id: null,
has_more: false,
after: null,
});
expect(
agentManagementListResponseSchema.safeParse({
...response,
has_more: true,
after: null,
}).success,
).toBe(false);
});
});
describe('responses', () => {
it('allowlists supported configuration and stable metadata', () => {
const response = projectAgentManagementResponse(persistedAgent);
expect(agentManagementResponseSchema.parse(response)).toEqual(response);
expect(response).toMatchObject({
id: 'agent_public_id',
version: 2,
createdAt: '2026-09-01T10:00:00.000Z',
updatedAt: '2026-09-02T10:00:00.000Z',
name: 'Researcher',
provider: 'openAI',
model: 'gpt-5',
});
expect(response).not.toHaveProperty('_id');
expect(response).not.toHaveProperty('tenantId');
expect(response).not.toHaveProperty('author');
expect(response).not.toHaveProperty('credentials');
expect(response).not.toHaveProperty('versions');
expect(response).not.toHaveProperty('mcpServerNames');
expect(response).not.toHaveProperty('is_promoted');
});
it('does not apply the current request admission limit to persisted subagents', () => {
const subagents = {
enabled: true,
agent_ids: Array.from({ length: MAX_SUBAGENTS }, (_, index) => `agent_${index}`),
};
setMaxSubagents(1);
try {
expect(agentManagementUpdateSchema.safeParse({ subagents }).success).toBe(false);
expect(projectAgentManagementResponse({ ...persistedAgent, subagents }).subagents).toEqual(
subagents,
);
} finally {
setMaxSubagents(undefined);
}
});
});
describe('errors', () => {
it.each([
['not_found', 404, 'Agent not found'],
['permission_denied', 403, 'Permission denied'],
['internal_error', 500, 'Internal server error'],
] as const)('maps %s without leaking an internal error', (code, status, message) => {
expect(mapAgentManagementError(code, new Error('database password leaked'))).toEqual({
status,
body: { error: { code, message } },
});
});
it('maps validation issues to safe paths and messages', () => {
const validation = z.object({ limit: z.number().max(100) }).safeParse({ limit: 101 });
if (validation.success) {
throw new Error('Expected validation to fail');
}
expect(mapAgentManagementError('invalid_request', validation.error)).toMatchObject({
status: 400,
body: {
error: {
code: 'invalid_request',
message: 'Invalid request',
details: [{ path: ['limit'] }],
},
},
});
});
});
});

View file

@ -0,0 +1,300 @@
import { z } from 'zod';
import type {
AgentSubagentGraph,
AgentSubagentGraphEdge,
AgentSubagentsConfig,
} from 'librechat-data-provider';
import { agentCreateSchema, agentUpdateSchema } from './validation';
const MAX_LIST_LIMIT = 100;
const DEFAULT_LIST_LIMIT = 20;
const MAX_CURSOR_LENGTH = 512;
export type AgentManagementCreate = z.output<typeof agentCreateSchema>;
export type AgentManagementUpdate = z.output<typeof agentUpdateSchema>;
export type AgentManagementList = {
limit: number;
cursor?: string;
};
export type AgentManagementResponse = Omit<
z.output<typeof agentUpdateSchema>,
'provider' | 'model'
> & {
id: string;
provider: string;
model: string | null;
version: number;
createdAt: string;
updatedAt: string;
};
export type AgentManagementProjectionSource = Partial<
Omit<AgentManagementResponse, 'id' | 'provider' | 'model' | 'version' | 'createdAt' | 'updatedAt'>
> & {
id?: string;
provider?: string;
model?: string | null;
version?: number;
versions?: readonly object[];
createdAt?: string | Date;
updatedAt?: string | Date;
};
export type AgentManagementListProjectionSource = {
data?: AgentManagementProjectionSource[];
has_more?: boolean;
after?: string | null;
};
export type AgentManagementListResponse = {
object: 'list';
data: AgentManagementResponse[];
first_id: string | null;
last_id: string | null;
has_more: boolean;
after: string | null;
};
export type AgentManagementErrorCode =
| 'invalid_request'
| 'not_found'
| 'permission_denied'
| 'internal_error';
export type AgentManagementError = {
error: {
code: AgentManagementErrorCode;
message: string;
details?: Array<{ path: Array<string | number>; message: string }>;
};
};
/**
* Agent Management accepts the browser-supported configuration fields, but unlike the
* browser endpoints it rejects unknown top-level fields instead of silently stripping them.
*/
export const agentManagementCreateSchema: z.ZodType<
AgentManagementCreate,
z.ZodTypeDef,
z.input<typeof agentCreateSchema>
> = agentCreateSchema.strict();
export const agentManagementUpdateSchema: z.ZodType<AgentManagementUpdate> =
agentUpdateSchema.strict();
const agentManagementCursorSchema = z
.string()
.min(1)
.max(MAX_CURSOR_LENGTH)
.superRefine((cursor, context) => {
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as unknown;
const result = z
.object({
updatedAt: z.string().datetime(),
_id: z.string().regex(/^[a-f\d]{24}$/i),
})
.strict()
.safeParse(decoded);
if (!result.success) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid cursor' });
}
} catch {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid cursor' });
}
});
export const agentManagementListSchema: z.ZodType<AgentManagementList, z.ZodTypeDef, unknown> = z
.object({
limit: z.coerce.number().int().min(1).max(MAX_LIST_LIMIT).default(DEFAULT_LIST_LIMIT),
cursor: agentManagementCursorSchema.optional(),
})
.strict();
const timestampSchema = z.string().datetime();
const agentManagementGraphEdgeResponseSchema: z.ZodType<AgentSubagentGraphEdge> = z
.object({
from: z.union([z.string(), z.array(z.string())]),
to: z.union([z.string(), z.array(z.string())]),
description: z.string().optional(),
edgeType: z.literal('direct'),
prompt: z.string().optional(),
excludeResults: z.boolean().optional(),
})
.strict();
const agentManagementGraphResponseSchema: z.ZodType<AgentSubagentGraph> = z
.object({
type: z.string(),
name: z.string(),
description: z.string(),
agent_ids: z.array(z.string()),
edges: z.array(agentManagementGraphEdgeResponseSchema),
entry_agent_id: z.string(),
result_agent_id: z.string(),
})
.strict();
const agentManagementSubagentsResponseSchema: z.ZodType<AgentSubagentsConfig | undefined> = z
.object({
enabled: z.boolean().optional(),
allowSelf: z.boolean().optional(),
agent_ids: z.array(z.string()).optional(),
graphs: z.array(agentManagementGraphResponseSchema).optional(),
})
.strict()
.optional();
/** The externally supported Agent shape. Persistence and ownership fields are intentionally absent. */
export const agentManagementResponseSchema: z.ZodType<AgentManagementResponse> = agentUpdateSchema
.extend({
id: z.string().min(1),
provider: z.string(),
model: z.string().nullable(),
version: z.number().int().nonnegative(),
createdAt: timestampSchema,
updatedAt: timestampSchema,
subagents: agentManagementSubagentsResponseSchema,
})
.strict();
export const agentManagementListResponseSchema: z.ZodType<AgentManagementListResponse> = z
.object({
object: z.literal('list'),
data: z.array(agentManagementResponseSchema),
first_id: z.string().nullable(),
last_id: z.string().nullable(),
has_more: z.boolean(),
after: agentManagementCursorSchema.nullable(),
})
.strict()
.superRefine(({ has_more, after }, context) => {
if (has_more !== (after != null)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['after'],
message: 'A next cursor is required exactly when more results are available',
});
}
});
export const agentManagementErrorCodeSchema: z.ZodType<AgentManagementErrorCode> = z.enum([
'invalid_request',
'not_found',
'permission_denied',
'internal_error',
]);
const agentManagementValidationIssueSchema = z
.object({
path: z.array(z.union([z.string(), z.number()])),
message: z.string(),
})
.strict();
export const agentManagementErrorSchema: z.ZodType<AgentManagementError> = z
.object({
error: z
.object({
code: agentManagementErrorCodeSchema,
message: z.string(),
details: z.array(agentManagementValidationIssueSchema).optional(),
})
.strict(),
})
.strict();
function toTimestamp(value: string | Date | undefined): string | undefined {
if (value instanceof Date) {
return value.toISOString();
}
return value;
}
function getVersion(source: AgentManagementProjectionSource): number | undefined {
if (source.version != null) {
return source.version;
}
return source.versions?.length;
}
/** Build an external response from a persistence result using an explicit allowlist. */
export function projectAgentManagementResponse(
source: AgentManagementProjectionSource,
): AgentManagementResponse {
return agentManagementResponseSchema.parse({
id: source.id,
provider: source.provider,
model: source.model,
version: getVersion(source),
createdAt: toTimestamp(source.createdAt),
updatedAt: toTimestamp(source.updatedAt),
name: source.name,
description: source.description,
instructions: source.instructions,
avatar: source.avatar,
model_parameters: source.model_parameters,
tools: source.tools,
skills: source.skills,
skills_enabled: source.skills_enabled,
skill_authoring_enabled: source.skill_authoring_enabled,
skills_scope: source.skills_scope,
memory_scope: source.memory_scope,
agent_ids: source.agent_ids,
edges: source.edges,
end_after_tools: source.end_after_tools,
hide_sequential_outputs: source.hide_sequential_outputs,
stateful_code_sessions: source.stateful_code_sessions,
stateful_code_environment: source.stateful_code_environment,
code_environment_id: source.code_environment_id,
artifacts: source.artifacts,
recursion_limit: source.recursion_limit,
conversation_starters: source.conversation_starters,
tool_resources: source.tool_resources,
tool_options: source.tool_options,
subagents: source.subagents,
support_contact: source.support_contact,
category: source.category,
});
}
export function projectAgentManagementListResponse(
source: AgentManagementListProjectionSource,
): AgentManagementListResponse {
const data = (source.data ?? []).map(projectAgentManagementResponse);
return agentManagementListResponseSchema.parse({
object: 'list',
data,
first_id: data[0]?.id ?? null,
last_id: data[data.length - 1]?.id ?? null,
has_more: source.has_more,
after: source.has_more === true && typeof source.after === 'string' ? source.after : null,
});
}
const ERROR_STATUS: Record<AgentManagementErrorCode, number> = {
invalid_request: 400,
not_found: 404,
permission_denied: 403,
internal_error: 500,
};
const ERROR_MESSAGE: Record<AgentManagementErrorCode, string> = {
invalid_request: 'Invalid request',
not_found: 'Agent not found',
permission_denied: 'Permission denied',
internal_error: 'Internal server error',
};
/** Map known failure classes to a stable envelope without exposing internal error messages. */
export function mapAgentManagementError(
code: AgentManagementErrorCode,
error?: unknown,
): { status: number; body: AgentManagementError } {
const details =
code === 'invalid_request' && error instanceof z.ZodError
? error.issues.map(({ path, message }) => ({ path, message }))
: undefined;
const body = agentManagementErrorSchema.parse({
error: {
code,
message: ERROR_MESSAGE[code],
...(details != null ? { details } : {}),
},
});
return { status: ERROR_STATUS[code], body };
}