diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index dd414940c1..779f76de33 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts new file mode 100644 index 0000000000..68a4232c17 --- /dev/null +++ b/packages/api/src/agents/management.spec.ts @@ -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'] }], + }, + }, + }); + }); + }); +}); diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts new file mode 100644 index 0000000000..1985fcd1d0 --- /dev/null +++ b/packages/api/src/agents/management.ts @@ -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; +export type AgentManagementUpdate = z.output; +export type AgentManagementList = { + limit: number; + cursor?: string; +}; +export type AgentManagementResponse = Omit< + z.output, + 'provider' | 'model' +> & { + id: string; + provider: string; + model: string | null; + version: number; + createdAt: string; + updatedAt: string; +}; +export type AgentManagementProjectionSource = Partial< + Omit +> & { + 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; 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 +> = agentCreateSchema.strict(); +export const agentManagementUpdateSchema: z.ZodType = + 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = { + invalid_request: 400, + not_found: 404, + permission_denied: 403, + internal_error: 500, +}; + +const ERROR_MESSAGE: Record = { + 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 }; +}