Files
twenty/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts
T
Félix Malfait c5564d9bd0 [BREAKING CHANGE] refactor: Add Entity suffix to TypeORM entity classes (#15239)
## Summary

This PR refactors all TypeORM entity classes in the Twenty codebase to
include an 'Entity' suffix (e.g., User → UserEntity, Workspace →
WorkspaceEntity) to improve code clarity and follow TypeORM naming
conventions.

## Changes

### Entity Renaming
-  Renamed **57 core TypeORM entities** with 'Entity' suffix
-  Updated all related imports, decorators, and type references
-  Fixed Repository<T>, @InjectRepository(), and
TypeOrmModule.forFeature() patterns
-  Fixed @ManyToOne/@OneToMany/@OneToOne decorator references

### Backward Compatibility
-  Preserved GraphQL schema names using @ObjectType('OriginalName')
decorators
-  **No breaking changes** to GraphQL API
-  **No database migrations** required
-  File names unchanged (user.entity.ts remains as-is)

### Code Quality
-  Fixed **497 TypeScript errors** (82% reduction from 606 to 109)
-  **All linter checks passing**
-  Improved type safety across the codebase

## Entities Renamed

```
User → UserEntity
Workspace → WorkspaceEntity
ApiKey → ApiKeyEntity
AppToken → AppTokenEntity
UserWorkspace → UserWorkspaceEntity
Webhook → WebhookEntity
FeatureFlag → FeatureFlagEntity
ApprovedAccessDomain → ApprovedAccessDomainEntity
TwoFactorAuthenticationMethod → TwoFactorAuthenticationMethodEntity
WorkspaceSSOIdentityProvider → WorkspaceSSOIdentityProviderEntity
EmailingDomain → EmailingDomainEntity
KeyValuePair → KeyValuePairEntity
PublicDomain → PublicDomainEntity
PostgresCredentials → PostgresCredentialsEntity
...and 43 more entities
```

## Impact

### Files Changed
- **400 files** modified
- **2,575 insertions**, **2,191 deletions**

### Progress
-  **82% complete** (497/606 errors fixed)
- ⚠️ **109 TypeScript errors** remain (18% of original)

## Remaining Work

The 109 remaining TypeScript errors are primarily:

1. **Function signature mismatches** (~15 errors) - Test mocks with
incorrect parameter counts
2. **Entity type mismatches** (~25 errors) - UserEntity vs
UserWorkspaceEntity confusion
3. **Pre-existing issues** (~50 errors) - Null safety and DTO
compatibility (unrelated to refactoring)
4. **Import type issues** (~10 errors) - Entities imported with 'import
type' but used as values
5. **Minor decorator issues** (~9 errors) - onDelete property
configurations

These can be addressed in follow-up PRs without blocking this
refactoring.

## Testing Checklist

- [x] Linter passing
- [ ] Unit tests should be run (CI will verify)
- [ ] Integration tests should be run (CI will verify)
- [ ] Manual testing recommended for critical user flows

## Breaking Changes

**None** - This is a pure refactoring with full backward compatibility:
- GraphQL API unchanged (uses original entity names)
- Database schema unchanged
- External APIs unchanged

## Notes

- Created comprehensive `REFACTORING_STATUS.md` documenting the entire
process
- All temporary scripts have been cleaned up
- Branch: `refactor/add-entity-suffix-to-typeorm-entities`

## Reviewers

Please review especially:
- Entity renaming patterns
- GraphQL backward compatibility
- Any areas where entity types are confused (UserEntity vs
UserWorkspaceEntity)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2025-10-22 09:55:20 +02:00

314 lines
10 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import {
convertToModelMessages,
LanguageModelUsage,
stepCountIs,
streamText,
ToolSet,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AgentExecutionContext } from './agent-handoff-executor.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
export interface AgentExecutionResult {
result: object;
usage: LanguageModelUsage;
}
@Injectable()
export class AgentExecutionService implements AgentExecutionContext {
private readonly logger = new Logger(AgentExecutionService.name);
constructor(
private readonly agentHandoffToolService: AgentHandoffToolService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly agentService: AgentService,
) {}
async prepareAIRequestConfig({
messages,
system,
agent,
actorContext,
roleIds,
excludeHandoffTools = false,
}: {
system: string;
agent: AgentEntity | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIds?: string[];
excludeHandoffTools?: boolean;
}) {
try {
if (agent) {
this.logger.log(
`Preparing AI request config for agent ${agent.id} with model ${agent.modelId}`,
);
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
let tools: ToolSet = {};
let providerOptions;
if (agent) {
const baseTools =
await this.agentToolGeneratorService.generateToolsForAgent(
agent.id,
agent.workspaceId,
actorContext,
roleIds,
);
let handoffTools = {};
if (!excludeHandoffTools) {
handoffTools =
await this.agentHandoffToolService.generateHandoffTools(
agent.id,
agent.workspaceId,
this, // Pass execution context
);
}
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent,
);
tools = { ...baseTools, ...handoffTools, ...nativeModelTools };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
agent,
);
}
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
return {
system,
tools,
model: registeredModel.model,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
providerOptions,
};
} catch (error) {
this.logger.error(
`Failed to prepare AI request config for agent ${agent?.id ?? 'no agent'}`,
error instanceof Error ? error.stack : error,
);
throw error;
}
}
private async getContextForSystemPrompt(
workspace: WorkspaceEntity,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
userWorkspaceId: string,
) {
const roleId =
await this.workspacePermissionsCacheService.getRoleIdFromUserWorkspaceId({
workspaceId: workspace.id,
userWorkspaceId,
});
if (!roleId) {
throw new AgentException(
'Failed to retrieve user role.',
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
});
const objectMetadataMaps =
workspaceDataSource.internalContext.objectMetadataMaps;
const objectMetadataPermissions = workspaceDataSource.permissionsPerRoleId;
const contextObject = (
await Promise.all(
recordIdsByObjectMetadataNameSingular.map(
async (recordsWithObjectMetadataNameSingular) => {
if (recordsWithObjectMetadataNameSingular.recordIds.length === 0) {
return [];
}
const objectMetadataMapItem =
getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
);
if (!objectMetadataMapItem) {
this.logger.warn(
`Object metadata not found for ${recordsWithObjectMetadataNameSingular.objectMetadataNameSingular}`,
);
return [];
}
const repository = workspaceDataSource.getRepository(
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
{ unionOf: [roleId] },
);
const restrictedFields =
objectMetadataPermissions?.[roleId]?.[objectMetadataMapItem.id]
?.restrictedFields ?? {};
const hasRestrictedFields = Object.values(restrictedFields).some(
(field) => field.canRead === false,
);
const selectOptions = hasRestrictedFields
? getAllSelectableFields({
restrictedFields,
objectMetadata: { objectMetadataMapItem },
})
: undefined;
return (
await repository.find({
...(selectOptions && { select: selectOptions }),
where: {
id: In(recordsWithObjectMetadataNameSingular.recordIds),
},
})
).map((record) => {
return {
...record,
resourceUrl: this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getAppPath(AppPath.RecordShowPage, {
objectNameSingular:
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
objectRecordId: record.id,
}),
}),
};
});
},
),
)
).flat(2);
return JSON.stringify(contextObject);
}
async streamChatResponse({
workspace,
userWorkspaceId,
agentId,
messages,
recordIdsByObjectMetadataNameSingular,
}: {
workspace: WorkspaceEntity;
userWorkspaceId: string;
agentId: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
}) {
try {
const agent = await this.agentService.findOneAgent(agentId, workspace.id);
let contextString = '';
if (recordIdsByObjectMetadataNameSingular.length > 0) {
const contextPart = await this.getContextForSystemPrompt(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
);
contextString = `\n\nCONTEXT:\n${contextPart}`;
}
const { actorContext, roleId } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
);
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
agent,
messages,
actorContext,
roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])],
});
this.logger.log(
`Sending request to AI model with ${messages.length} messages`,
);
const model =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const stream = streamText(aiRequestConfig);
stream.usage
.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
);
})
.catch((usageError) => {
this.logger.error('Failed to get usage information:', usageError);
});
return stream;
} catch (error) {
this.logger.error('Error in streamChatResponse:', error);
throw new AgentException(
error instanceof Error
? error.message
: 'Failed to stream chat response',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
}
}