From c28c20143bcbe24c169fbc7b07a5f86cfa539f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 18 Apr 2026 21:08:33 +0200 Subject: [PATCH] refactor(server): rename Agent exception to Ai; add THREAD_NOT_FOUND / MESSAGE_NOT_FOUND codes (fixes 500s) (#19831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The exception class under `ai-agent/` was serving every AI surface (agent, chat, role, models, generate-text), so `Agent` was a misnomer. Promoted to the `ai/` namespace; renamed `AgentException` → `AiException`, `AgentExceptionCode` → `AiExceptionCode`, and related interceptor / filter / handler / file names accordingly. - Split the single `AGENT_NOT_FOUND` code into entity-specific codes. Chat-thread lookups no longer reuse the agent identifier. - **Fixes Sentry 500s on `GetChatMessages` / `chatThread`.** Every "Thread not found" and "Queued message not found" throw site in ai-chat was previously wired to `AGENT_EXECUTION_FAILED`, which maps to `InternalServerError` (HTTP 500). They now use `THREAD_NOT_FOUND` / `MESSAGE_NOT_FOUND`, both of which map to `NotFoundError` (HTTP 404) in the GraphQL and REST handlers. The underlying cause of *why* clients are asking for threads that no longer resolve for them — per-user chat-thread create events being broadcast workspace-wide — is addressed separately in a follow-up PR. ### Code map - Added: `ai/ai.exception.ts`, `ai/utils/ai-graphql-api-exception-handler.util.ts` (+ spec with new THREAD/MESSAGE cases), `ai/interceptors/ai-graphql-api-exception.interceptor.ts`, `ai/filters/ai-api-exception.filter.ts` - Deleted: `ai/ai-agent/agent.exception.ts`, `ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts` (+ spec), `ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts`, `ai/ai-agent/filters/agent-api-exception.filter.ts` - Updated: 21 call sites across ai-agent, ai-agent-execution, ai-agent-role, ai-chat, ai-generate-text, ai-models, role, and workspace-migration validators. ## Test plan - [x] `npx nx typecheck twenty-server` - [x] `npx jest ai-graphql-api-exception-handler` (3/3 including new THREAD_NOT_FOUND and MESSAGE_NOT_FOUND cases) - [x] `npx jest agent-role.service` (9/9) - [x] `npx oxlint --type-aware` on all changed files (0 warnings/errors) - [x] `npx prettier --check` on all changed files - [ ] CI --- .../services/agent-actor-context.service.ts | 18 +++--- .../services/agent-async-executor.service.ts | 16 ++--- .../ai-agent-role/agent-role.service.spec.ts | 28 ++++----- .../ai/ai-agent-role/ai-agent-role.service.ts | 22 +++---- .../ai/ai-agent/agent.resolver.ts | 4 +- .../ai/ai-agent/agent.service.ts | 18 +++--- .../ai/ai-agent/ai-agent.module.ts | 4 +- ...agent-graphql-api-exception.interceptor.ts | 20 ------ ...graphql-api-exception-handler.util.spec.ts | 35 ----------- ...gent-graphql-api-exception-handler.util.ts | 39 ------------ ...gent-input-to-flat-agent-to-update.util.ts | 16 ++--- .../ai/ai-chat/ai-chat.module.ts | 4 +- .../agent-chat-subscription.resolver.ts | 14 ++--- .../ai-chat/resolvers/agent-chat.resolver.ts | 26 ++++---- .../services/agent-chat-streaming.service.ts | 10 +-- .../ai/ai-chat/services/agent-chat.service.ts | 14 ++--- .../ai-generate-text.controller.ts | 14 ++--- .../services/ai-model-registry.service.ts | 30 ++++----- .../agent.exception.ts => ai.exception.ts} | 52 ++++++++------- .../ai-api-exception.filter.ts} | 32 +++++----- .../ai-graphql-api-exception.interceptor.ts | 20 ++++++ ...graphql-api-exception-handler.util.spec.ts | 63 +++++++++++++++++++ .../ai-graphql-api-exception-handler.util.ts | 41 ++++++++++++ .../metadata-modules/role/role.resolver.ts | 10 +-- .../services/flat-agent-validator.service.ts | 10 +-- .../validate-agent-name-uniqueness.util.ts | 8 +-- ...validate-agent-required-properties.util.ts | 12 ++-- .../validate-agent-response-format.util.ts | 12 ++-- 28 files changed, 313 insertions(+), 279 deletions(-) delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent/agent.exception.ts => ai.exception.ts} (58%) rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent/filters/agent-api-exception.filter.ts => filters/ai-api-exception.filter.ts} (58%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/utils/__tests__/ai-graphql-api-exception-handler.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts index eb3c7d117f..2c918e048a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts @@ -5,9 +5,9 @@ import { type ActorMetadata } from 'twenty-shared/types'; import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -46,9 +46,9 @@ export class AgentActorContextService { await this.userWorkspaceService.findById(userWorkspaceId); if (!userWorkspace) { - throw new AgentException( + throw new AiException( 'User workspace not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } @@ -72,9 +72,9 @@ export class AgentActorContextService { ); if (!workspaceMember) { - throw new AgentException( + throw new AiException( 'Workspace member not found for user', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } @@ -84,9 +84,9 @@ export class AgentActorContextService { }); if (!roleId) { - throw new AgentException( + throw new AiException( 'User role not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index ae9ac2912e..61281a24ac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -23,9 +23,9 @@ import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util'; import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; @@ -239,9 +239,9 @@ export class AgentAsyncExecutorService { }); if (structuredResult.output == null) { - throw new AgentException( + throw new AiException( 'Failed to generate structured output from execution results', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } @@ -255,12 +255,12 @@ export class AgentAsyncExecutorService { nativeWebSearchCallCount, }; } catch (error) { - if (error instanceof AgentException) { + if (error instanceof AiException) { throw error; } - throw new AgentException( + throw new AiException( error instanceof Error ? error.message : 'Agent execution failed', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts index 2a2470659b..de4c683645 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/agent-role.service.spec.ts @@ -4,9 +4,9 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { type Repository } from 'typeorm'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type'; import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type'; @@ -226,7 +226,7 @@ describe('AiAgentRoleService', () => { expect(roleTargetService.create).not.toHaveBeenCalled(); }); - it('should throw AgentException when agent does not exist', async () => { + it('should throw AiException when agent does not exist', async () => { // Arrange const nonExistentAgentId = 'non-existent-agent-id'; @@ -239,7 +239,7 @@ describe('AiAgentRoleService', () => { agentId: nonExistentAgentId, roleId: testRole.id, }), - ).rejects.toThrow(AgentException); + ).rejects.toThrow(AiException); await expect( service.assignRoleToAgent({ @@ -248,12 +248,12 @@ describe('AiAgentRoleService', () => { roleId: testRole.id, }), ).rejects.toMatchObject({ - code: AgentExceptionCode.AGENT_NOT_FOUND, + code: AiExceptionCode.AGENT_NOT_FOUND, message: `Agent with id ${nonExistentAgentId} not found in workspace`, }); }); - it('should throw AgentException when role does not exist', async () => { + it('should throw AiException when role does not exist', async () => { // Arrange const nonExistentRoleId = 'non-existent-role-id'; @@ -267,7 +267,7 @@ describe('AiAgentRoleService', () => { agentId: testAgent.id, roleId: nonExistentRoleId, }), - ).rejects.toThrow(AgentException); + ).rejects.toThrow(AiException); await expect( service.assignRoleToAgent({ @@ -276,12 +276,12 @@ describe('AiAgentRoleService', () => { roleId: nonExistentRoleId, }), ).rejects.toMatchObject({ - code: AgentExceptionCode.ROLE_NOT_FOUND, + code: AiExceptionCode.ROLE_NOT_FOUND, message: `Role with id ${nonExistentRoleId} not found in workspace`, }); }); - it('should throw AgentException when agent belongs to different workspace', async () => { + it('should throw AiException when agent belongs to different workspace', async () => { // Arrange const differentWorkspaceId = 'different-workspace-id'; @@ -294,7 +294,7 @@ describe('AiAgentRoleService', () => { agentId: testAgent.id, roleId: testRole.id, }), - ).rejects.toThrow(AgentException); + ).rejects.toThrow(AiException); await expect( service.assignRoleToAgent({ @@ -303,7 +303,7 @@ describe('AiAgentRoleService', () => { roleId: testRole.id, }), ).rejects.toMatchObject({ - code: AgentExceptionCode.AGENT_NOT_FOUND, + code: AiExceptionCode.AGENT_NOT_FOUND, message: `Agent with id ${testAgent.id} not found in workspace`, }); }); @@ -355,7 +355,7 @@ describe('AiAgentRoleService', () => { workspaceId: testWorkspaceId, agentId: testAgent.id, }), - ).rejects.toThrow(AgentException); + ).rejects.toThrow(AiException); await expect( service.removeRoleFromAgent({ @@ -363,7 +363,7 @@ describe('AiAgentRoleService', () => { agentId: testAgent.id, }), ).rejects.toMatchObject({ - code: AgentExceptionCode.ROLE_NOT_FOUND, + code: AiExceptionCode.ROLE_NOT_FOUND, message: `Role target not found for agent ${testAgent.id}`, }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts index f49cc3705c..bb5616c931 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service.ts @@ -5,9 +5,9 @@ import { isDefined } from 'twenty-shared/utils'; import { In, IsNull, Not, Repository } from 'typeorm'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleTargetService } from 'src/engine/metadata-modules/role-target/services/role-target.service'; @@ -69,9 +69,9 @@ export class AiAgentRoleService { }); if (!isDefined(existingRoleTarget)) { - throw new AgentException( + throw new AiException( `Role target not found for agent ${agentId}`, - AgentExceptionCode.ROLE_NOT_FOUND, + AiExceptionCode.ROLE_NOT_FOUND, ); } @@ -125,9 +125,9 @@ export class AiAgentRoleService { }); if (!agent) { - throw new AgentException( + throw new AiException( `Agent with id ${agentId} not found in workspace`, - AgentExceptionCode.AGENT_NOT_FOUND, + AiExceptionCode.AGENT_NOT_FOUND, ); } @@ -136,16 +136,16 @@ export class AiAgentRoleService { }); if (!role) { - throw new AgentException( + throw new AiException( `Role with id ${roleId} not found in workspace`, - AgentExceptionCode.ROLE_NOT_FOUND, + AiExceptionCode.ROLE_NOT_FOUND, ); } if (!role.canBeAssignedToAgents) { - throw new AgentException( + throw new AiException( `Role "${role.label}" cannot be assigned to agents`, - AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS, + AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.resolver.ts index 7d4d766d4f..805ad35a6c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.resolver.ts @@ -24,7 +24,7 @@ import { AgentIdInput } from './dtos/agent-id.input'; import { AgentDTO } from './dtos/agent.dto'; import { CreateAgentInput } from './dtos/create-agent.input'; import { UpdateAgentInput } from './dtos/update-agent.input'; -import { AgentGraphqlApiExceptionInterceptor } from './interceptors/agent-graphql-api-exception.interceptor'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; @UseGuards( WorkspaceAuthGuard, @@ -33,7 +33,7 @@ import { AgentGraphqlApiExceptionInterceptor } from './interceptors/agent-graphq ) @UseInterceptors( WorkspaceMigrationGraphqlApiExceptionInterceptor, - AgentGraphqlApiExceptionInterceptor, + AiGraphqlApiExceptionInterceptor, ) @MetadataResolver() export class AgentResolver { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts index f84184d847..3f2599b847 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts @@ -16,7 +16,10 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; -import { AgentException, AgentExceptionCode } from './agent.exception'; +import { + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AgentEntity } from './entities/agent.entity'; @@ -63,9 +66,9 @@ export class AgentService { if (!agent) { const identifier = `name "${name}"`; - throw new AgentException( + throw new AiException( `Agent with ${identifier} not found`, - AgentExceptionCode.AGENT_NOT_FOUND, + AiExceptionCode.AGENT_NOT_FOUND, ); } @@ -91,10 +94,7 @@ export class AgentService { }); if (!isDefined(flatAgent)) { - throw new AgentException( - `Agent not found`, - AgentExceptionCode.AGENT_NOT_FOUND, - ); + throw new AiException(`Agent not found`, AiExceptionCode.AGENT_NOT_FOUND); } const roleId = flatRoleTargetByAgentIdMaps[flatAgent.id]?.roleId; @@ -281,9 +281,9 @@ export class AgentService { }); if (deletedAgents.length !== 1) { - throw new AgentException( + throw new AiException( 'Could not retrieve deleted agent', - AgentExceptionCode.AGENT_NOT_FOUND, + AiExceptionCode.AGENT_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts index 0991aadcd2..f76aa214da 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts @@ -7,7 +7,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FileModule } from 'src/engine/core-modules/file/file.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; -import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; import { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; @@ -45,7 +45,7 @@ import { AgentEntity } from './entities/agent.entity'; AgentResolver, AgentService, WorkspaceMigrationGraphqlApiExceptionInterceptor, - AgentGraphqlApiExceptionInterceptor, + AiGraphqlApiExceptionInterceptor, ], exports: [AgentService, TypeOrmModule.forFeature([AgentEntity])], }) diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts deleted file mode 100644 index eac9d1aaa0..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { - type CallHandler, - type ExecutionContext, - Injectable, - type NestInterceptor, -} from '@nestjs/common'; - -import { type Observable, catchError } from 'rxjs'; - -import { agentGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util'; - -@Injectable() -export class AgentGraphqlApiExceptionInterceptor implements NestInterceptor { - intercept( - _context: ExecutionContext, - next: CallHandler, - ): Observable { - return next.handle().pipe(catchError(agentGraphqlApiExceptionHandler)); - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts deleted file mode 100644 index d78ee666a9..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/__tests__/agent-graphql-api-exception-handler.util.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { agentGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util'; -import { - ErrorCode, - type BaseGraphQLError, -} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; - -const catchGraphqlError = (error: Error): BaseGraphQLError => { - try { - agentGraphqlApiExceptionHandler(error); - throw new Error('Expected agentGraphqlApiExceptionHandler to throw'); - } catch (graphqlError) { - return graphqlError as BaseGraphQLError; - } -}; - -describe('agentGraphqlApiExceptionHandler', () => { - it('maps API key configuration failures to INTERNAL_SERVER_ERROR with a subCode', () => { - const error = new AgentException( - 'No AI models are available', - AgentExceptionCode.API_KEY_NOT_CONFIGURED, - ); - - const graphqlError = catchGraphqlError(error); - - expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR); - expect(graphqlError.extensions.subCode).toBe( - AgentExceptionCode.API_KEY_NOT_CONFIGURED, - ); - expect(graphqlError.extensions.userFriendlyMessage).toBeDefined(); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts deleted file mode 100644 index 5a29a306f7..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/agent-graphql-api-exception-handler.util.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { assertUnreachable } from 'twenty-shared/utils'; - -import { - ConflictError, - ForbiddenError, - InternalServerError, - NotFoundError, - UserInputError, -} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; - -export const agentGraphqlApiExceptionHandler = (error: Error) => { - if (error instanceof AgentException) { - switch (error.code) { - case AgentExceptionCode.AGENT_NOT_FOUND: - case AgentExceptionCode.ROLE_NOT_FOUND: - throw new NotFoundError(error); - case AgentExceptionCode.INVALID_AGENT_INPUT: - throw new UserInputError(error); - case AgentExceptionCode.AGENT_ALREADY_EXISTS: - throw new ConflictError(error); - case AgentExceptionCode.AGENT_IS_STANDARD: - case AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: - throw new ForbiddenError(error); - case AgentExceptionCode.AGENT_EXECUTION_FAILED: - case AgentExceptionCode.API_KEY_NOT_CONFIGURED: - case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: - throw new InternalServerError(error); - default: { - return assertUnreachable(error.code); - } - } - } - - throw error; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts index 91f4ce6b4c..98ef79e0c3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts @@ -7,9 +7,9 @@ import { import { v4 } from 'uuid'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/update-agent.input'; import { FLAT_AGENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-agent/constants/flat-agent-editable-properties.constant'; import { type FlatAgentMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-agent-maps.type'; @@ -115,13 +115,9 @@ export const fromUpdateAgentInputToFlatAgentToUpdate = ({ }); if (!isDefined(existingFlatAgent)) { - throw new AgentException( - 'Agent not found', - AgentExceptionCode.AGENT_NOT_FOUND, - { - userFriendlyMessage: msg`The agent you are looking for could not be found. It may have been deleted or you may not have access to it.`, - }, - ); + throw new AiException('Agent not found', AiExceptionCode.AGENT_NOT_FOUND, { + userFriendlyMessage: msg`The agent you are looking for could not be found. It may have been deleted or you may not have access to it.`, + }); } const updatedEditableAgentProperties = extractAndSanitizeObjectStringFields( diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts index d11d480202..590c349430 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts @@ -25,7 +25,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; -import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @@ -115,7 +115,7 @@ import { SystemPromptBuilderService } from './services/system-prompt-builder.ser MessagePruningService, StreamAgentChatJob, SystemPromptBuilderService, - AgentGraphqlApiExceptionInterceptor, + AiGraphqlApiExceptionInterceptor, ], exports: [ AgentChatService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts index e4c1f254b5..b6c2de8cd4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat-subscription.resolver.ts @@ -16,10 +16,10 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { AgentChatEventDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-event.dto'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { SubscriptionService } from 'src/engine/subscriptions/subscription.service'; @@ -27,7 +27,7 @@ import { FeatureFlagKey } from 'twenty-shared/types'; @MetadataResolver() @UseGuards(WorkspaceAuthGuard, UserAuthGuard) -@UseInterceptors(AgentGraphqlApiExceptionInterceptor) +@UseInterceptors(AiGraphqlApiExceptionInterceptor) export class AgentChatSubscriptionResolver { constructor( private readonly subscriptionService: SubscriptionService, @@ -56,9 +56,9 @@ export class AgentChatSubscriptionResolver { }); if (!isDefined(thread)) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts index d28d3ca1bf..372c0164fd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts @@ -36,10 +36,10 @@ import { import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; +import { AiGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor'; import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto'; import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto'; @@ -59,7 +59,7 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.AI), ) -@UseInterceptors(AgentGraphqlApiExceptionInterceptor) +@UseInterceptors(AiGraphqlApiExceptionInterceptor) @MetadataResolver(() => AgentChatThreadDTO) export class AgentChatResolver { constructor( @@ -135,9 +135,9 @@ export class AgentChatResolver { @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { if (this.aiModelRegistryService.getAvailableModels().length === 0) { - throw new AgentException( + throw new AiException( 'No AI models are available. Configure at least one AI provider.', - AgentExceptionCode.API_KEY_NOT_CONFIGURED, + AiExceptionCode.API_KEY_NOT_CONFIGURED, ); } @@ -167,9 +167,9 @@ export class AgentChatResolver { }); if (!isDefined(thread)) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } @@ -245,9 +245,9 @@ export class AgentChatResolver { const message = await this.agentChatService.findQueuedMessage(messageId); if (!isDefined(message)) { - throw new AgentException( + throw new AiException( 'Queued message not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.MESSAGE_NOT_FOUND, ); } @@ -256,9 +256,9 @@ export class AgentChatResolver { }); if (!isDefined(thread)) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index 5c34a13a17..2941080574 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -22,9 +22,9 @@ import { } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; import { mapDBPartsToUIMessageParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapDBPartsToUIMessageParts'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { STREAM_AGENT_CHAT_JOB_NAME } from 'src/engine/metadata-modules/ai/ai-chat/jobs/stream-agent-chat-job-name.constant'; @@ -77,9 +77,9 @@ export class AgentChatStreamingService { }); if (!thread) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts index 7130387040..621970a50a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts @@ -17,9 +17,9 @@ import { import { AgentTurnEntity } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-turn.entity'; import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai/ai-agent-execution/utils/mapUIMessagePartsToDBParts'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service'; @@ -96,9 +96,9 @@ export class AgentChatService { }); if (!thread) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } @@ -179,9 +179,9 @@ export class AgentChatService { }); if (!thread) { - throw new AgentException( + throw new AiException( 'Thread not found', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.THREAD_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-generate-text/controllers/ai-generate-text.controller.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-generate-text/controllers/ai-generate-text.controller.ts index fc8310bb3f..7fe5069324 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-generate-text/controllers/ai-generate-text.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-generate-text/controllers/ai-generate-text.controller.ts @@ -10,16 +10,16 @@ import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { AgentRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; +import { AiRestApiExceptionFilter } from 'src/engine/metadata-modules/ai/filters/ai-api-exception.filter'; import { GenerateTextInput } from 'src/engine/metadata-modules/ai/ai-generate-text/dtos/generate-text-input.dto'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; @Controller('rest/ai') @UseGuards(JwtAuthGuard, WorkspaceAuthGuard) -@UseFilters(AgentRestApiExceptionFilter, RestApiExceptionFilter) +@UseFilters(AiRestApiExceptionFilter, RestApiExceptionFilter) export class AiGenerateTextController { constructor( private readonly aiModelRegistryService: AiModelRegistryService, @@ -32,9 +32,9 @@ export class AiGenerateTextController { @AuthWorkspace() workspace: WorkspaceEntity, ) { if (this.aiModelRegistryService.getAvailableModels().length === 0) { - throw new AgentException( + throw new AiException( 'No AI models are available. Please configure at least one AI provider API key.', - AgentExceptionCode.API_KEY_NOT_CONFIGURED, + AiExceptionCode.API_KEY_NOT_CONFIGURED, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts index 1fc72f79b4..a4eb9903a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts @@ -8,9 +8,9 @@ import { ConfigGroupHashService } from 'src/engine/core-modules/twenty-config/se import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-preferences.service'; import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; @@ -219,9 +219,9 @@ export class AiModelRegistryService { } if (!model) { - throw new AgentException( + throw new AiException( 'No AI models are available. Configure at least one AI provider.', - AgentExceptionCode.API_KEY_NOT_CONFIGURED, + AiExceptionCode.API_KEY_NOT_CONFIGURED, ); } @@ -255,9 +255,9 @@ export class AiModelRegistryService { return this.createDefaultConfigForCustomModel(registeredModel); } - throw new AgentException( + throw new AiException( `Model with ID ${modelId} not found`, - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } @@ -296,9 +296,9 @@ export class AiModelRegistryService { workspace: WorkspaceModelAvailabilitySettings, ): void { if (!this.isModelAdminAllowed(modelId)) { - throw new AgentException( + throw new AiException( 'The selected model has been disabled by the administrator.', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } @@ -309,9 +309,9 @@ export class AiModelRegistryService { this.getRecommendedModelIds(), ) ) { - throw new AgentException( + throw new AiException( 'The selected model is not available in this workspace.', - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } } @@ -386,9 +386,9 @@ export class AiModelRegistryService { this.ensureFresh(); if (!this.providerModelDefCache.has(modelId)) { - throw new AgentException( + throw new AiException( `Cannot update model "${modelId}": not found in registry`, - AgentExceptionCode.AGENT_EXECUTION_FAILED, + AiExceptionCode.AGENT_EXECUTION_FAILED, ); } } @@ -409,9 +409,9 @@ export class AiModelRegistryService { const registeredModel = this.getModel(aiModel.modelId); if (!registeredModel) { - throw new AgentException( + throw new AiException( `Model ${aiModel.modelId} not found in registry. Check that the corresponding AI provider is configured.`, - AgentExceptionCode.API_KEY_NOT_CONFIGURED, + AiExceptionCode.API_KEY_NOT_CONFIGURED, ); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.exception.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts similarity index 58% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.exception.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts index a152a3da69..24030daa94 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai.exception.ts @@ -4,52 +4,58 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { CustomException } from 'src/utils/custom-exception'; -export enum AgentExceptionCode { +export enum AiExceptionCode { AGENT_NOT_FOUND = 'AGENT_NOT_FOUND', + AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS', + AGENT_IS_STANDARD = 'AGENT_IS_STANDARD', AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED', + INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT', + THREAD_NOT_FOUND = 'THREAD_NOT_FOUND', + MESSAGE_NOT_FOUND = 'MESSAGE_NOT_FOUND', API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED', USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS', - INVALID_AGENT_INPUT = 'INVALID_AGENT_INPUT', - AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS', - AGENT_IS_STANDARD = 'AGENT_IS_STANDARD', } -const getAgentExceptionUserFriendlyMessage = (code: AgentExceptionCode) => { +const getAiExceptionUserFriendlyMessage = (code: AiExceptionCode) => { switch (code) { - case AgentExceptionCode.AGENT_NOT_FOUND: + case AiExceptionCode.AGENT_NOT_FOUND: return msg`Agent not found.`; - case AgentExceptionCode.AGENT_EXECUTION_FAILED: - return msg`Agent execution failed.`; - case AgentExceptionCode.API_KEY_NOT_CONFIGURED: - return msg`API key is not configured.`; - case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: - return msg`User workspace not found.`; - case AgentExceptionCode.ROLE_NOT_FOUND: - return msg`Role not found.`; - case AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: - return msg`This role cannot be assigned to agents.`; - case AgentExceptionCode.INVALID_AGENT_INPUT: - return msg`Invalid agent input.`; - case AgentExceptionCode.AGENT_ALREADY_EXISTS: + case AiExceptionCode.AGENT_ALREADY_EXISTS: return msg`An agent with this name already exists.`; - case AgentExceptionCode.AGENT_IS_STANDARD: + case AiExceptionCode.AGENT_IS_STANDARD: return msg`Standard agents cannot be modified.`; + case AiExceptionCode.AGENT_EXECUTION_FAILED: + return msg`Agent execution failed.`; + case AiExceptionCode.INVALID_AGENT_INPUT: + return msg`Invalid agent input.`; + case AiExceptionCode.THREAD_NOT_FOUND: + return msg`Chat thread not found.`; + case AiExceptionCode.MESSAGE_NOT_FOUND: + return msg`Chat message not found.`; + case AiExceptionCode.API_KEY_NOT_CONFIGURED: + return msg`API key is not configured.`; + case AiExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: + return msg`User workspace not found.`; + case AiExceptionCode.ROLE_NOT_FOUND: + return msg`Role not found.`; + case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: + return msg`This role cannot be assigned to agents.`; default: assertUnreachable(code); } }; -export class AgentException extends CustomException { +export class AiException extends CustomException { constructor( message: string, - code: AgentExceptionCode, + code: AiExceptionCode, { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, ) { super(message, code, { userFriendlyMessage: - userFriendlyMessage ?? getAgentExceptionUserFriendlyMessage(code), + userFriendlyMessage ?? getAiExceptionUserFriendlyMessage(code), }); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/ai/filters/ai-api-exception.filter.ts similarity index 58% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/filters/ai-api-exception.filter.ts index 0e02a0989e..4a5c1d005f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/filters/agent-api-exception.filter.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/filters/ai-api-exception.filter.ts @@ -8,40 +8,42 @@ import type { Response } from 'express'; import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; -@Catch(AgentException) -export class AgentRestApiExceptionFilter implements ExceptionFilter { +@Catch(AiException) +export class AiRestApiExceptionFilter implements ExceptionFilter { constructor( private readonly httpExceptionHandlerService: HttpExceptionHandlerService, ) {} - catch(exception: AgentException, host: ArgumentsHost) { + catch(exception: AiException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); switch (exception.code) { - case AgentExceptionCode.AGENT_NOT_FOUND: - case AgentExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: - case AgentExceptionCode.ROLE_NOT_FOUND: + case AiExceptionCode.AGENT_NOT_FOUND: + case AiExceptionCode.THREAD_NOT_FOUND: + case AiExceptionCode.MESSAGE_NOT_FOUND: + case AiExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: + case AiExceptionCode.ROLE_NOT_FOUND: return this.httpExceptionHandlerService.handleError( exception, response, 404, ); - case AgentExceptionCode.API_KEY_NOT_CONFIGURED: + case AiExceptionCode.API_KEY_NOT_CONFIGURED: return this.httpExceptionHandlerService.handleError( exception, response, 503, // Service Unavailable - the AI service is not configured ); - case AgentExceptionCode.AGENT_EXECUTION_FAILED: - case AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: - case AgentExceptionCode.INVALID_AGENT_INPUT: - case AgentExceptionCode.AGENT_ALREADY_EXISTS: - case AgentExceptionCode.AGENT_IS_STANDARD: + case AiExceptionCode.AGENT_EXECUTION_FAILED: + case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: + case AiExceptionCode.INVALID_AGENT_INPUT: + case AiExceptionCode.AGENT_ALREADY_EXISTS: + case AiExceptionCode.AGENT_IS_STANDARD: return this.httpExceptionHandlerService.handleError( exception, response, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor.ts new file mode 100644 index 0000000000..ce86a2fef5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/interceptors/ai-graphql-api-exception.interceptor.ts @@ -0,0 +1,20 @@ +import { + type CallHandler, + type ExecutionContext, + Injectable, + type NestInterceptor, +} from '@nestjs/common'; + +import { type Observable, catchError } from 'rxjs'; + +import { aiGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util'; + +@Injectable() +export class AiGraphqlApiExceptionInterceptor implements NestInterceptor { + intercept( + _context: ExecutionContext, + next: CallHandler, + ): Observable { + return next.handle().pipe(catchError(aiGraphqlApiExceptionHandler)); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/utils/__tests__/ai-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/utils/__tests__/ai-graphql-api-exception-handler.util.spec.ts new file mode 100644 index 0000000000..f9c61493c0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/utils/__tests__/ai-graphql-api-exception-handler.util.spec.ts @@ -0,0 +1,63 @@ +import { + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; +import { aiGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util'; +import { + ErrorCode, + type BaseGraphQLError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; + +const catchGraphqlError = (error: Error): BaseGraphQLError => { + try { + aiGraphqlApiExceptionHandler(error); + throw new Error('Expected aiGraphqlApiExceptionHandler to throw'); + } catch (graphqlError) { + return graphqlError as BaseGraphQLError; + } +}; + +describe('aiGraphqlApiExceptionHandler', () => { + it('maps API key configuration failures to INTERNAL_SERVER_ERROR with a subCode', () => { + const error = new AiException( + 'No AI models are available', + AiExceptionCode.API_KEY_NOT_CONFIGURED, + ); + + const graphqlError = catchGraphqlError(error); + + expect(graphqlError.extensions.code).toBe(ErrorCode.INTERNAL_SERVER_ERROR); + expect(graphqlError.extensions.subCode).toBe( + AiExceptionCode.API_KEY_NOT_CONFIGURED, + ); + expect(graphqlError.extensions.userFriendlyMessage).toBeDefined(); + }); + + it('maps THREAD_NOT_FOUND to NOT_FOUND', () => { + const error = new AiException( + 'Thread not found', + AiExceptionCode.THREAD_NOT_FOUND, + ); + + const graphqlError = catchGraphqlError(error); + + expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND); + expect(graphqlError.extensions.subCode).toBe( + AiExceptionCode.THREAD_NOT_FOUND, + ); + }); + + it('maps MESSAGE_NOT_FOUND to NOT_FOUND', () => { + const error = new AiException( + 'Message not found', + AiExceptionCode.MESSAGE_NOT_FOUND, + ); + + const graphqlError = catchGraphqlError(error); + + expect(graphqlError.extensions.code).toBe(ErrorCode.NOT_FOUND); + expect(graphqlError.extensions.subCode).toBe( + AiExceptionCode.MESSAGE_NOT_FOUND, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts new file mode 100644 index 0000000000..3d80b615f9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/utils/ai-graphql-api-exception-handler.util.ts @@ -0,0 +1,41 @@ +import { assertUnreachable } from 'twenty-shared/utils'; + +import { + ConflictError, + ForbiddenError, + InternalServerError, + NotFoundError, + UserInputError, +} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; + +export const aiGraphqlApiExceptionHandler = (error: Error) => { + if (error instanceof AiException) { + switch (error.code) { + case AiExceptionCode.AGENT_NOT_FOUND: + case AiExceptionCode.THREAD_NOT_FOUND: + case AiExceptionCode.MESSAGE_NOT_FOUND: + case AiExceptionCode.ROLE_NOT_FOUND: + throw new NotFoundError(error); + case AiExceptionCode.INVALID_AGENT_INPUT: + throw new UserInputError(error); + case AiExceptionCode.AGENT_ALREADY_EXISTS: + throw new ConflictError(error); + case AiExceptionCode.AGENT_IS_STANDARD: + case AiExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS: + throw new ForbiddenError(error); + case AiExceptionCode.AGENT_EXECUTION_FAILED: + case AiExceptionCode.API_KEY_NOT_CONFIGURED: + case AiExceptionCode.USER_WORKSPACE_ID_NOT_FOUND: + throw new InternalServerError(error); + default: { + return assertUnreachable(error.code); + } + } + } + + throw error; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 19f43529e5..5001249b82 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -28,9 +28,9 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; + AiException, + AiExceptionCode, +} from 'src/engine/metadata-modules/ai/ai.exception'; import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto'; import { fromFlatAgentWithRoleIdToAgentDto } from 'src/engine/metadata-modules/flat-agent/utils/from-agent-entity-to-agent-dto.util'; import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; @@ -333,9 +333,9 @@ export class RoleResolver { flatApplicationMaps.byId[agentEntity.applicationId]; if (!isDefined(flatApplication)) { - throw new AgentException( + throw new AiException( `Application not found for agent ${agentEntity.id}`, - AgentExceptionCode.AGENT_NOT_FOUND, + AiExceptionCode.AGENT_NOT_FOUND, ); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service.ts index 2500f8d9a4..9ed030c0cb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service.ts @@ -4,7 +4,7 @@ import { msg, t } from '@lingui/core/macro'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; -import { AgentExceptionCode } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; +import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util'; @@ -89,7 +89,7 @@ export class FlatAgentValidatorService { if (!isDefined(existingAgent)) { validationResult.errors.push({ - code: AgentExceptionCode.AGENT_NOT_FOUND, + code: AiExceptionCode.AGENT_NOT_FOUND, message: t`Agent not found`, userFriendlyMessage: msg`Agent not found`, }); @@ -106,7 +106,7 @@ export class FlatAgentValidatorService { }) ) { validationResult.errors.push({ - code: AgentExceptionCode.AGENT_IS_STANDARD, + code: AiExceptionCode.AGENT_IS_STANDARD, message: t`Cannot delete standard agent`, userFriendlyMessage: msg`Cannot delete standard agent`, }); @@ -140,7 +140,7 @@ export class FlatAgentValidatorService { if (!isDefined(fromFlatAgent)) { validationResult.errors.push({ - code: AgentExceptionCode.AGENT_NOT_FOUND, + code: AiExceptionCode.AGENT_NOT_FOUND, message: t`Agent not found`, userFriendlyMessage: msg`Agent not found`, }); @@ -157,7 +157,7 @@ export class FlatAgentValidatorService { }) ) { validationResult.errors.push({ - code: AgentExceptionCode.AGENT_IS_STANDARD, + code: AiExceptionCode.AGENT_IS_STANDARD, message: t`Cannot update standard agent`, userFriendlyMessage: msg`Cannot update standard agent`, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-name-uniqueness.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-name-uniqueness.util.ts index 18f749dfbf..e3d6aad1c1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-name-uniqueness.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-name-uniqueness.util.ts @@ -1,6 +1,6 @@ import { msg, t } from '@lingui/core/macro'; -import { AgentExceptionCode } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; +import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception'; import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; @@ -10,12 +10,12 @@ export const validateAgentNameUniqueness = ({ }: { name: string; existingFlatAgents: UniversalFlatAgent[]; -}): FlatEntityValidationError[] => { - const errors: FlatEntityValidationError[] = []; +}): FlatEntityValidationError[] => { + const errors: FlatEntityValidationError[] = []; if (existingFlatAgents.some((agent) => agent.name === name)) { errors.push({ - code: AgentExceptionCode.AGENT_ALREADY_EXISTS, + code: AiExceptionCode.AGENT_ALREADY_EXISTS, message: t`Agent with name "${name}" already exists`, userFriendlyMessage: msg`An agent with this name already exists`, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-required-properties.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-required-properties.util.ts index b71f0463de..72c8bccec6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-required-properties.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-required-properties.util.ts @@ -1,7 +1,7 @@ import { msg, t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; -import { AgentExceptionCode } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; +import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception'; import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; @@ -13,8 +13,8 @@ type ValidateAgentRequiredPropertiesArgs = { export const validateAgentRequiredProperties = ({ flatAgent, updatedProperties, -}: ValidateAgentRequiredPropertiesArgs): FlatEntityValidationError[] => { - const errors: FlatEntityValidationError[] = []; +}: ValidateAgentRequiredPropertiesArgs): FlatEntityValidationError[] => { + const errors: FlatEntityValidationError[] = []; // For updates, only validate properties that are being changed const isUpdate = updatedProperties !== undefined; @@ -24,7 +24,7 @@ export const validateAgentRequiredProperties = ({ if (shouldValidateLabel && !isNonEmptyString(flatAgent.label)) { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Label cannot be empty`, userFriendlyMessage: msg`Label cannot be empty`, }); @@ -32,7 +32,7 @@ export const validateAgentRequiredProperties = ({ if (shouldValidatePrompt && !isNonEmptyString(flatAgent.prompt)) { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Prompt cannot be empty`, userFriendlyMessage: msg`Prompt cannot be empty`, }); @@ -40,7 +40,7 @@ export const validateAgentRequiredProperties = ({ if (shouldValidateModelId && !isNonEmptyString(flatAgent.modelId)) { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Model ID cannot be empty`, userFriendlyMessage: msg`Model ID cannot be empty`, }); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-response-format.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-response-format.util.ts index 6e6edd5ffe..f9e73a6f2b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-response-format.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-agent-response-format.util.ts @@ -1,7 +1,7 @@ import { msg, t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { AgentExceptionCode } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; +import { AiExceptionCode } from 'src/engine/metadata-modules/ai/ai.exception'; import { type AgentJsonResponseFormat, type AgentResponseFormat, @@ -12,13 +12,13 @@ export const validateAgentResponseFormat = ({ responseFormat, }: { responseFormat: AgentResponseFormat; -}): FlatEntityValidationError[] => { - const errors: FlatEntityValidationError[] = []; +}): FlatEntityValidationError[] => { + const errors: FlatEntityValidationError[] = []; const type = responseFormat.type; if (type !== 'text' && type !== 'json') { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Response format type must be either "text" or "json"`, userFriendlyMessage: msg`Invalid response format type`, }); @@ -26,7 +26,7 @@ export const validateAgentResponseFormat = ({ if (type === 'json' && !isDefined(responseFormat.schema)) { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Response format with type "json" must include a schema`, userFriendlyMessage: msg`JSON response format requires a schema`, }); @@ -37,7 +37,7 @@ export const validateAgentResponseFormat = ({ isDefined((responseFormat as unknown as AgentJsonResponseFormat).schema) ) { errors.push({ - code: AgentExceptionCode.INVALID_AGENT_INPUT, + code: AiExceptionCode.INVALID_AGENT_INPUT, message: t`Response format with type "text" should not include a schema`, userFriendlyMessage: msg`Text response format should not have a schema`, });