Add standard skills backfill and improve skill availability messaging (#19523)

## Summary
This PR adds a database migration command to backfill standard skills
for existing workspaces and improves the skill loading tool to provide
dynamic, workspace-specific skill availability information instead of
hardcoded skill names.

## Key Changes

- **New Migration Command**: Added `BackfillStandardSkillsCommand`
(v1.22.0) that:
  - Identifies missing standard skills in existing workspaces
  - Compares workspace skills against the standard skill definitions
  - Creates missing skills using the workspace migration service
  - Supports dry-run mode for safe testing
  - Properly logs all operations and handles failures

- **Enhanced Skill Loading Tool**: Updated `createLoadSkillTool` to:
  - Accept a new `listAvailableSkillNames` function parameter
- Dynamically fetch available skills from the workspace instead of using
hardcoded skill names
- Provide accurate, context-aware error messages when skills are not
found
  - Gracefully handle workspaces with no available skills

- **Service Updates**: Modified skill tool implementations in:
- `McpProtocolService`: Integrated `findAllFlatSkills` to list available
skills
- `ChatExecutionService`: Integrated `findAllFlatSkills` to list
available skills

- **Module Registration**: Added `BackfillStandardSkillsCommand` to the
v1.22 upgrade module

- **Test Updates**: Updated `McpProtocolService` tests to mock the new
`findAllFlatSkills` method

## Implementation Details

The backfill command uses the existing workspace migration
infrastructure to safely create skills, ensuring consistency with other
metadata operations. The skill availability messaging now reflects the
actual skills present in each workspace, improving user experience when
skills are not found.

https://claude.ai/code/session_012fXeP3bysaEgWsbkyu4ism

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Félix Malfait 2026-04-09 21:28:23 +02:00 committed by GitHub
parent 8fde5d9da3
commit 7ef80dd238
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 163 additions and 8 deletions

View file

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command';
import { BackfillStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000002000-backfill-standard-skills.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@ -15,6 +16,9 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand],
providers: [
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
BackfillStandardSkillsCommand,
],
})
export class V1_22_UpgradeVersionCommandModule {}

View file

@ -0,0 +1,120 @@
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@RegisteredWorkspaceCommand('1.22.0', 1780000002000)
@Command({
name: 'upgrade:1-22:backfill-standard-skills',
description:
'Backfill standard skills for existing workspaces that were created before skills were added',
})
export class BackfillStandardSkillsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Checking standard skills for workspace ${workspaceId}`,
);
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatSkillMaps: existingFlatSkillMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatSkillMaps',
]);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
shouldIncludeRecordPageLayouts: true,
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const standardSkills = Object.values(
standardAllFlatEntityMaps.flatSkillMaps.byUniversalIdentifier,
).filter(isDefined);
const skillsToCreate = standardSkills.filter(
(skill) =>
!isDefined(
existingFlatSkillMaps.byUniversalIdentifier[
skill.universalIdentifier
],
),
);
if (skillsToCreate.length === 0) {
this.logger.log(
`All standard skills already exist for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`Found ${skillsToCreate.length} missing standard skill(s) for workspace ${workspaceId}: ${skillsToCreate.map((s) => s.name).join(', ')}`,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create ${skillsToCreate.length} standard skill(s) for workspace ${workspaceId}`,
);
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: skillsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to backfill standard skills:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to backfill standard skills for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully created ${skillsToCreate.length} standard skill(s) for workspace ${workspaceId}`,
);
}
}

View file

@ -83,7 +83,10 @@ describe('McpProtocolService', () => {
},
{
provide: SkillService,
useValue: { findFlatSkillsByNames: jest.fn().mockResolvedValue([]) },
useValue: {
findFlatSkillsByNames: jest.fn().mockResolvedValue([]),
findAllFlatSkills: jest.fn().mockResolvedValue([]),
},
},
],
}).compile();

View file

@ -148,8 +148,16 @@ export class McpProtocolService {
inputSchema: executeToolInputSchema,
},
[LOAD_SKILL_TOOL_NAME]: {
...createLoadSkillTool((names) =>
this.skillService.findFlatSkillsByNames(names, workspace.id),
...createLoadSkillTool(
(names) =>
this.skillService.findFlatSkillsByNames(names, workspace.id),
async () => {
const allSkills = await this.skillService.findAllFlatSkills(
workspace.id,
);
return allSkills.map((skill) => skill.name);
},
),
inputSchema: zodSchema(loadSkillInputSchema),
},

View file

@ -18,6 +18,7 @@ export {
LOAD_SKILL_TOOL_NAME,
createLoadSkillTool,
loadSkillInputSchema,
type ListAvailableSkillNamesFunction,
type LoadSkillFunction,
type LoadSkillInput,
type LoadSkillResult,

View file

@ -24,8 +24,12 @@ export type LoadSkillResult = {
};
export type LoadSkillFunction = (names: string[]) => Promise<FlatSkill[]>;
export type ListAvailableSkillNamesFunction = () => Promise<string[]>;
export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
export const createLoadSkillTool = (
loadSkills: LoadSkillFunction,
listAvailableSkillNames: ListAvailableSkillNamesFunction,
) => ({
description:
'Load specialized skills for complex tasks. Returns detailed step-by-step instructions for building workflows, dashboards, manipulating data, or managing metadata. Call this before attempting complex operations.',
inputSchema: loadSkillInputSchema,
@ -35,9 +39,16 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
const skills = await loadSkills(skillNames);
if (skills.length === 0) {
const availableNames = await listAvailableSkillNames();
const availableMessage =
availableNames.length > 0
? `Available skills: ${availableNames.join(', ')}.`
: 'No skills are currently available in this workspace.';
return {
skills: [],
message: `No skills found with names: ${skillNames.join(', ')}. Available skills: workflow-building, data-manipulation, dashboard-building, metadata-building, research, code-interpreter, xlsx, pdf, docx, pptx.`,
message: `No skills found with names: ${skillNames.join(', ')}. ${availableMessage}`,
};
}

View file

@ -200,8 +200,16 @@ export class ChatExecutionService {
toolContext,
directTools,
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool((skillNames) =>
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
(skillNames) =>
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
async () => {
const allSkills = await this.skillService.findAllFlatSkills(
workspace.id,
);
return allSkills.map((skill) => skill.name);
},
),
};